refactor: move flat modules into inbound_verify package (Tier 1, behavior-identical)
Relocate 12 root .py modules into inbound_verify/ (sites/, cli/ subpackages). Rewrite all internal imports to package-qualified; drop the site_ prefix on the 5 site modules and their 28 call sites. Fix paths.py BASE_DIR to anchor at the project root. Add main() entry wrappers (cli/router, cli/server, store). No behavior change. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1384
inbound_verify/sites/anneng.py
Normal file
1384
inbound_verify/sites/anneng.py
Normal file
File diff suppressed because it is too large
Load Diff
275
inbound_verify/sites/baishi.py
Normal file
275
inbound_verify/sites/baishi.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# sites/baishi.py
|
||||
|
||||
import os
|
||||
import yaml
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from inbound_verify.paths import DOWNLOAD_DIR, CONFIG_PATH
|
||||
from inbound_verify import state_store
|
||||
|
||||
|
||||
def with_retry(site_name, label, flow, reset, max_attempts=3):
|
||||
"""异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。
|
||||
|
||||
每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。
|
||||
flow 为零参可调用;返回 False 视为失败,其余视为成功。
|
||||
返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。
|
||||
"""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
ret = flow()
|
||||
if ret is False:
|
||||
raise RuntimeError("流程返回失败状态")
|
||||
if attempt > 1:
|
||||
print(f">> 【{site_name}-{label}】第 {attempt} 次尝试成功 ✅")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ 【{site_name}-{label}】第 {attempt}/{max_attempts} 次失败: {e}")
|
||||
print(f" → 重置【{site_name}】到初始态,清理环境 ...")
|
||||
try:
|
||||
reset()
|
||||
except Exception as re:
|
||||
print(f" ⚠️ 重置异常: {re}")
|
||||
if attempt < max_attempts:
|
||||
continue
|
||||
print(
|
||||
f"❌ 【{site_name}-{label}】已达最大尝试次数 {max_attempts},放弃(环境已清理)。"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# 站点首页 URL:异常兜底重置用,也供 main_router 的 SITES_CONFIG 引用(单一来源)
|
||||
HOME_URL = "https://v5.800best.com"
|
||||
|
||||
|
||||
def baishi_reset(page):
|
||||
"""异常兜底:重置百世到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。"""
|
||||
page.goto(HOME_URL)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
|
||||
def dismiss_baishi_popups(page):
|
||||
"""清理百世首页杂乱弹窗:优惠券广告 / 通知消息 / 配置检查面板 / 聊天通知。
|
||||
|
||||
所有弹窗都有时序问题——不是登录瞬间就出现,旧代码一登录就判定"无弹窗"而跳过,
|
||||
导致从未成功关闭过任何一个。修复策略统一为「轮询等待出现 → 关闭」。
|
||||
|
||||
经 CDP 端到端验证的选择器(2026-07-19):
|
||||
- ① 优惠券广告:全屏居中 Ant Design modal(.ant-modal-wrap.ant-modal-centered),
|
||||
关闭键 .ant-modal-close(纯图标×、无文字)。登录后约 2s 才加载。
|
||||
- ② Ant Design 通知("您有新的消息"):.ant-notification 组件,
|
||||
右下角卡片,关闭键 .ant-notification-notice-close(纯图标×)。
|
||||
- ③ 配置检查面板:.modal-config-check.react-draggable(可拖拽浮动面板,
|
||||
默认 display:none,系统检测后展开为 block)。底部 footer 有两个 button:
|
||||
"重新检查"(danger) 和 "关 闭"(primary)。点"关 闭"后面板从 DOM 移除。
|
||||
- ④ 聊天/调查通知("您还有调研单未填写..."):.chat-notification-popover-wrapper
|
||||
(z-index:1000),关闭键 .chat-notification-close(图标×)。
|
||||
|
||||
注意:左侧首页轮播/广告横幅是页面正常内容区(无关闭键),不在此处处理。
|
||||
"""
|
||||
|
||||
import time as _time
|
||||
|
||||
# 单条轮询循环:每个 tick(短间隔)同时检查并关闭「所有当前可见」的弹窗。
|
||||
#
|
||||
# 旧实现是串行四段,每段 _poll_and_click 在「未命中」时会阻塞等待整段
|
||||
# max_poll_s=5s 才返回 False。于是:广告段跑完(~7s)才开始处理通知、配置,
|
||||
# 而广告/通知其实登录后 1~2s 就出现了,却被卡在前面段的等待窗口里,表现为
|
||||
# 「检查配置 / 消息通知关得特别慢」。改为单循环后,谁先出现谁在下一个 tick
|
||||
# (~350ms)就被关掉,四类互不排队。
|
||||
#
|
||||
# 选择器均经 CDP 端到端验证(见函数 docstring)。
|
||||
targets = [
|
||||
(".ant-modal-wrap.ant-modal-centered .ant-modal-close", "优惠券广告"),
|
||||
(".ant-notification-notice-close", "通知消息"),
|
||||
(".modal-config-check button:has-text('关 闭')", "配置检查面板"),
|
||||
(".chat-notification-close", "聊天调查通知"),
|
||||
]
|
||||
|
||||
deadline = _time.monotonic() + 12.0 # 最长处理 12s(兜底,正常几秒内结束)
|
||||
tick_ms = 350
|
||||
idle_rounds = 0 # 连续无处理的轮数
|
||||
while _time.monotonic() < deadline:
|
||||
handled_any = False
|
||||
for sel, label in targets:
|
||||
try:
|
||||
loc = page.locator(sel)
|
||||
if loc.count() > 0 and loc.first.is_visible(timeout=120):
|
||||
loc.first.click(timeout=2000)
|
||||
handled_any = True
|
||||
page.wait_for_timeout(150) # 留一点关闭动画时间
|
||||
except Exception:
|
||||
pass
|
||||
if handled_any:
|
||||
idle_rounds = 0
|
||||
else:
|
||||
idle_rounds += 1
|
||||
if idle_rounds >= 3: # 连续 ~1.05s 无任何弹窗 → 提前结束
|
||||
break
|
||||
page.wait_for_timeout(tick_ms)
|
||||
|
||||
|
||||
def _remove_if_exists(path):
|
||||
"""删除文件(若存在):流程开头清理上次的最终文件,避免无数据/失败时残留旧数据。"""
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _close_tab(page, tab_name):
|
||||
"""关闭指定名称的百世标签页。
|
||||
|
||||
通过 li > span 中的文本定位标签,并点击其内部 title 为 "关闭标签页" 的图标。
|
||||
"""
|
||||
try:
|
||||
tab = page.locator("li").filter(has=page.locator("span", has_text=tab_name))
|
||||
if tab.count() == 0:
|
||||
print(f" ℹ️ 未找到标签页【{tab_name}】(可能尚未打开或已关闭),跳过。")
|
||||
return
|
||||
|
||||
# 点击百世特有的关闭按钮
|
||||
tab.first.locator("i[title='关闭标签页']").click()
|
||||
print(f" 🗙 已关闭标签页【{tab_name}】")
|
||||
page.wait_for_timeout(300)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 关闭标签页【{tab_name}】时出错: {e}")
|
||||
|
||||
|
||||
def baishi_download_undelivered_data(page):
|
||||
"""百世:一键提取应到未到(当日未扫)数据(内部含异常兜底重试,路由层无感)。"""
|
||||
|
||||
return with_retry(
|
||||
"百世",
|
||||
"应到未到",
|
||||
lambda: baishi_download_undelivered_data_impl(page),
|
||||
lambda: baishi_reset(page),
|
||||
)
|
||||
|
||||
|
||||
def baishi_download_undelivered_data_impl(page):
|
||||
"""百世:一键提取应到未到(当日未扫)数据(单次执行,无重试;供自动化测试用)。"""
|
||||
print("\n▶ 开始执行【百世 - 一键提取应到未到数据】任务...")
|
||||
|
||||
download_dir = DOWNLOAD_DIR
|
||||
if not os.path.exists(download_dir):
|
||||
os.makedirs(download_dir)
|
||||
|
||||
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
|
||||
_remove_if_exists(os.path.join(download_dir, "百世-应到未到货物数据.xlsx"))
|
||||
|
||||
try:
|
||||
# 1. 导航与页面加载
|
||||
print(">> 正在进入【扫描综合查询】界面...")
|
||||
# 打开基础服务菜单面板
|
||||
page.locator("div.nav-level1", has_text="基础服务").click()
|
||||
|
||||
# 限制在菜单面板(nav-level2-wrapper)内查找,避免命中右侧同名标签页
|
||||
page.locator(".nav-level2-wrapper").locator(
|
||||
"a", has_text="扫描综合查询"
|
||||
).click()
|
||||
|
||||
# 验证表格主界面加载完毕
|
||||
page.get_by_role("tab", name="实时扫描率").wait_for(state="visible")
|
||||
print("✅ 扫描综合查询界面已加载")
|
||||
|
||||
# 1.5 顺手抓取「到/接件扫描 → 当日」的 应扫/已扫(=应到/实到基数),
|
||||
# 供汇总报表填写百世行的应到件/实到件。
|
||||
# 实时扫描率表头为 14 列 leaf:发/交件[昨日×3, 当日×4] +
|
||||
# 到/接件[昨日×3, 当日×4],到/接件当日四列索引为
|
||||
# 10(应扫) 11(已扫) 12(未扫) 13(率)。(与下方 nth(12) 未扫同源)
|
||||
try:
|
||||
_first_row = page.locator(".ant-table-tbody > tr").first
|
||||
_exp_txt = (
|
||||
_first_row.locator("td").nth(10).inner_text().strip().replace(",", "")
|
||||
)
|
||||
_arr_txt = (
|
||||
_first_row.locator("td").nth(11).inner_text().strip().replace(",", "")
|
||||
)
|
||||
|
||||
def _to_int(v):
|
||||
try:
|
||||
return int(float(v)) if v not in ("", "-") else 0
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
_exp_n, _arr_n = _to_int(_exp_txt), _to_int(_arr_txt)
|
||||
if _exp_n > 0:
|
||||
state_store.set_setting("百世", "scan_expected_pieces", str(_exp_n))
|
||||
state_store.set_setting("百世", "scan_arrived_pieces", str(_arr_n))
|
||||
print(f" ℹ️ 已记录百世应到/实到基数:应扫 {_exp_n} / 已扫 {_arr_n}")
|
||||
except Exception as _e:
|
||||
# 抓取失败绝不影响未到明细下载主流程
|
||||
print(f" ⚠️ 抓取百世应到/实到基数失败(不影响未到明细下载):{_e}")
|
||||
|
||||
# 2. 精准定位并点击【到/接件扫描 -> 当日 -> 未扫】的数字控件
|
||||
print(">> 正在解析表格,提取当日到件未扫明细...")
|
||||
# 定位第一行数据的第 13 列(索引 12)
|
||||
target_cell = page.locator(".ant-table-tbody > tr").first.locator("td").nth(12)
|
||||
|
||||
# 特殊情况处理:检查未扫数量是否为0
|
||||
cell_text = target_cell.inner_text().strip()
|
||||
if cell_text == "0" or cell_text == "":
|
||||
print(
|
||||
f" ℹ️ 注意:当日未扫数量为【{cell_text}】,无数据需要提取,任务结束。"
|
||||
)
|
||||
# 数据为空时,提前结束前也需清理环境
|
||||
_close_tab(page, "扫描综合查询")
|
||||
return True
|
||||
|
||||
# 有未扫数据,继续点击操作
|
||||
target_cell.locator("a").click()
|
||||
|
||||
# 等待下方弹出的明细表格区域加载完毕
|
||||
detail_section = page.locator(".m-query-all-scanRateDetail")
|
||||
detail_section.wait_for(state="visible")
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# 3. 触发导出设置模态框
|
||||
print(">> 正在打开导出配置面板...")
|
||||
# 使用明细区域内的导出按钮,避免误点主表导出
|
||||
detail_section.locator(".export-wrap a[title='导出']").click()
|
||||
|
||||
# 验证模态框弹出
|
||||
page.locator(".ant-modal-title", has_text="导出设置").wait_for(state="visible")
|
||||
|
||||
# 4. 读取百世导出密码(存 state.db,由前端站点配置)
|
||||
print(">> 正在读取配置并填写密码...")
|
||||
password = state_store.get_setting("百世", "password")
|
||||
if not password:
|
||||
print(" ⚠️ 警告:未设置百世导出密码(前端站点配置),可能导致导出失败。")
|
||||
|
||||
page.get_by_placeholder("请输入登录密码").fill(password)
|
||||
|
||||
# 5. 执行最终下载
|
||||
print(">> 正在下载...")
|
||||
with page.expect_download() as download_info:
|
||||
# 点击模态框底部的“导 出”按钮
|
||||
page.locator(".ant-modal-footer").get_by_role(
|
||||
"button", name="导 出"
|
||||
).click()
|
||||
|
||||
download = download_info.value
|
||||
save_path = os.path.join(download_dir, "百世-应到未到货物数据.xlsx")
|
||||
|
||||
# 如果之前已经有同名文件,覆盖保存
|
||||
if os.path.exists(save_path):
|
||||
os.remove(save_path)
|
||||
|
||||
download.save_as(save_path)
|
||||
print(f"====================================================")
|
||||
print(f" 提取成功。")
|
||||
print(f" 已下载: {save_path}")
|
||||
print(f"====================================================")
|
||||
|
||||
# 6. 环境清理
|
||||
print(">> 任务完成,正在清理环境...")
|
||||
_close_tab(page, "扫描综合查询")
|
||||
|
||||
print("\n【百世 - 应到未到数据提取】流程结束。")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 任务执行过程中发生异常: {e}")
|
||||
return False
|
||||
773
inbound_verify/sites/shunxin.py
Normal file
773
inbound_verify/sites/shunxin.py
Normal file
@@ -0,0 +1,773 @@
|
||||
# sites/shunxin.py
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import yaml
|
||||
from datetime import datetime, timedelta
|
||||
import pandas as pd
|
||||
|
||||
from inbound_verify.paths import DOWNLOAD_DIR, CONFIG_PATH
|
||||
from inbound_verify import state_store
|
||||
|
||||
|
||||
def with_retry(site_name, label, flow, reset, max_attempts=3):
|
||||
"""异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。
|
||||
|
||||
每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。
|
||||
flow 为零参可调用;返回 False 视为失败,其余视为成功。
|
||||
返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。
|
||||
"""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
ret = flow()
|
||||
if ret is False:
|
||||
raise RuntimeError("流程返回失败状态")
|
||||
if attempt > 1:
|
||||
print(f">> 【{site_name}-{label}】第 {attempt} 次尝试成功 ✅")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ 【{site_name}-{label}】第 {attempt}/{max_attempts} 次失败: {e}")
|
||||
print(f" → 重置【{site_name}】到初始态,清理环境 ...")
|
||||
try:
|
||||
reset()
|
||||
except Exception as re:
|
||||
print(f" ⚠️ 重置异常: {re}")
|
||||
if attempt < max_attempts:
|
||||
continue
|
||||
print(
|
||||
f"❌ 【{site_name}-{label}】已达最大尝试次数 {max_attempts},放弃(环境已清理)。"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# 站点首页 URL:异常兜底重置用,也供 main_router 的 SITES_CONFIG 引用(单一来源)
|
||||
HOME_URL = "https://sxne.sxjdfreight.com"
|
||||
|
||||
|
||||
def shunxin_reset(page):
|
||||
"""异常兜底:重置顺心到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。"""
|
||||
page.goto(HOME_URL)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
|
||||
def _close_tab(page, tab_name):
|
||||
"""关闭指定名称的标签页(Ant Design Tabs)。
|
||||
|
||||
通过标签文字定位标签容器,点击其右侧的关闭(×)按钮。
|
||||
- tab_name 采用包含匹配,对“ 运单列表”这类带空格的标签同样有效。
|
||||
- 关闭失败不会影响主流程,仅打印提示信息。
|
||||
"""
|
||||
try:
|
||||
tab = page.locator(".ant-tabs-tab").filter(
|
||||
has=page.get_by_role("tab", name=tab_name)
|
||||
)
|
||||
if tab.count() == 0:
|
||||
print(
|
||||
f" ℹ️ 未找到标签页【{tab_name.strip()}】(可能尚未打开或已关闭),跳过。"
|
||||
)
|
||||
return
|
||||
tab.first.locator(".ant-tabs-tab-remove").click()
|
||||
print(f" 🗙 已关闭标签页【{tab_name.strip()}】")
|
||||
page.wait_for_timeout(300)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 关闭标签页【{tab_name.strip()}】时出错: {e}")
|
||||
|
||||
|
||||
def _sanitize_for_filename(name):
|
||||
"""剔除 Windows 文件名非法字符,避免归属地名含特殊字符导致落盘失败。"""
|
||||
return re.sub(r'[\\/:*?"<>|]', "", str(name)).strip()
|
||||
|
||||
|
||||
def _remove_if_exists(path):
|
||||
"""删除文件(若存在):清理上次的本账号中间文件/最终文件,避免残留旧数据。"""
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def shunxin_belonging(page):
|
||||
"""读取顺心当前账号的归属网点名(仅在首页可见,须在导航离开首页前调用)。
|
||||
|
||||
取首页「切换网点」下拉框选中项的 title(形如「【SX】重庆巴南龙海大道店」),
|
||||
去掉【XX】前缀得到归属地(如「重庆巴南龙海大道店」),并做文件名安全处理。
|
||||
读取失败时抛异常,交由上层处理。
|
||||
"""
|
||||
item = page.locator(".site___3o7nH .ant-select-selection-item").first
|
||||
title = (item.get_attribute("title") or item.inner_text() or "").strip()
|
||||
if not title:
|
||||
raise RuntimeError("未能读取顺心归属网点(首页「切换网点」控件为空)")
|
||||
tag = re.sub(r"^【[^】]*】", "", title).strip() or title
|
||||
return _sanitize_for_filename(tag)
|
||||
|
||||
|
||||
def shunxin_merge_final(kind, tags):
|
||||
"""把各归属地的中间产物融合成统一的「顺心-{kind}货物数据.xlsx」。
|
||||
|
||||
kind ∈ {"应到","实到"};tags 为各账号归属地列表。逐个读取
|
||||
「顺心-{tag}-{kind}货物数据.xlsx」(缺失则跳过,容错空数据账号),pd.concat
|
||||
后写出统一文件,并删除中间带 tag 的文件;全部缺失则仅提示、不产出。
|
||||
"""
|
||||
final_name = f"顺心-{kind}货物数据.xlsx"
|
||||
final_path = os.path.join(DOWNLOAD_DIR, final_name)
|
||||
|
||||
frames = []
|
||||
mid_paths = []
|
||||
for tag in tags:
|
||||
mid_name = f"顺心-{tag}-{kind}货物数据.xlsx"
|
||||
mid_path = os.path.join(DOWNLOAD_DIR, mid_name)
|
||||
if not os.path.exists(mid_path):
|
||||
print(f" ℹ️ 归属【{tag}】无{kind}中间文件(可能本次无数据),跳过。")
|
||||
continue
|
||||
mid_paths.append(mid_path)
|
||||
try:
|
||||
df = pd.read_excel(mid_path, dtype=str)
|
||||
if not df.empty:
|
||||
frames.append(df)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 读取中间文件 {mid_name} 失败: {e}")
|
||||
|
||||
if not frames:
|
||||
print(f">> ⚠️ 所有归属地均无{kind}数据,删除残留的 {final_name}(不写空表)。")
|
||||
_remove_if_exists(final_path)
|
||||
return
|
||||
|
||||
combined = pd.concat(frames, ignore_index=True)
|
||||
combined.to_excel(final_path, index=False)
|
||||
print("====================================================")
|
||||
print(
|
||||
f" {kind}数据融合完成(共 {len(combined)} 行),输出: downloads/{final_name}"
|
||||
)
|
||||
print("====================================================")
|
||||
|
||||
for mid_path in mid_paths:
|
||||
try:
|
||||
os.remove(mid_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def shunxin_expected_download(pages):
|
||||
"""顺心:应到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。
|
||||
|
||||
pages 为该站点的 page 列表(双账号在同一窗口的各一个标签页)。
|
||||
先在首页读取各账号归属地并去重校验(两账号登同一归属地则中止,防数据翻倍),
|
||||
再顺序对各账号跑一遍下载(impl 用归属地作输出文件后缀),最后融合成统一的
|
||||
「顺心-应到货物数据.xlsx」。路由层只需传入 page 列表,对双账号无感。
|
||||
"""
|
||||
tags = []
|
||||
for idx, pg in enumerate(pages, start=1):
|
||||
tag = shunxin_belonging(pg)
|
||||
print(f">> 【顺心】账号{idx} 归属网点:{tag}")
|
||||
tags.append(tag)
|
||||
|
||||
if len(set(tags)) != len(tags):
|
||||
raise RuntimeError(
|
||||
f"顺心两个账号归属地相同({tags}),疑似登录了同一账号,已中止以防数据翻倍。"
|
||||
)
|
||||
|
||||
for idx, (pg, tag) in enumerate(zip(pages, tags), start=1):
|
||||
pg.bring_to_front()
|
||||
print(f"\n========== 顺心 · 账号{idx}({tag})应到数据下载 ==========")
|
||||
ok = with_retry(
|
||||
f"顺心-{tag}",
|
||||
"应到",
|
||||
lambda p=pg, t=tag: shunxin_expected_download_impl(p, out_tag=t),
|
||||
lambda p=pg: shunxin_reset(p),
|
||||
)
|
||||
if not ok:
|
||||
return False # 某账号重试耗尽 → 整体失败,不融合(避免部分数据)
|
||||
|
||||
shunxin_merge_final("应到", tags)
|
||||
return True
|
||||
|
||||
|
||||
def shunxin_expected_download_impl(page, out_tag=""):
|
||||
"""顺心:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。
|
||||
|
||||
out_tag 为归属地标签时,合并产物命名为「顺心-{out_tag}-应到货物数据.xlsx」,
|
||||
作为双账号融合前的各账号中间文件;为空时退化为「顺心-应到货物数据.xlsx」。
|
||||
"""
|
||||
print("\n▶ 开始执行【顺心 - 应到货物数据下载】任务...")
|
||||
|
||||
# 初始化并创建下载目录
|
||||
download_dir = DOWNLOAD_DIR
|
||||
if not os.path.exists(download_dir):
|
||||
os.makedirs(download_dir)
|
||||
print(f">> 已创建下载目录: {download_dir}")
|
||||
|
||||
# 清理上次本账号的中间文件,避免本次无数据/失败时残留旧数据被 merge_final 误读
|
||||
_mid_suffix = f"-{out_tag}" if out_tag else ""
|
||||
_remove_if_exists(
|
||||
os.path.join(download_dir, f"顺心{_mid_suffix}-应到货物数据.xlsx")
|
||||
)
|
||||
|
||||
export_times = []
|
||||
target_task_timestamps = []
|
||||
|
||||
try:
|
||||
# 1. 导航与页面加载判断
|
||||
print(">> 正在进入【车辆点到】界面...")
|
||||
page.locator("span.ant-pro-menu-item-title:has-text('派件管理')").click()
|
||||
page.locator("div.ant-pro-menu-item:has-text('车辆点到')").click()
|
||||
|
||||
page.get_by_role("button", name="点到").wait_for(state="visible")
|
||||
page.get_by_role("button", name="打印交接单").wait_for(state="visible")
|
||||
page.get_by_role("button", name="强卸").wait_for(state="visible")
|
||||
print("✅ 车辆点到界面加载完毕")
|
||||
|
||||
# 2. 读取服务端日期偏移(0=今天,1=昨天…),单日范围:起止同日
|
||||
offset = state_store.get_offset("顺心")
|
||||
today = datetime.now()
|
||||
target = today - timedelta(days=offset)
|
||||
target_str = target.strftime("%Y-%m-%d")
|
||||
start_date_str = target_str
|
||||
today_str = target_str
|
||||
print(f">> 正在设置查询日期: [{target_str}](偏移 {offset},0=今天)...")
|
||||
|
||||
# 分两步精准呼出和点击时间控件
|
||||
print(" >> 设置起始时间...")
|
||||
page.get_by_placeholder("开始时间").click()
|
||||
page.wait_for_timeout(500)
|
||||
page.locator(
|
||||
f".ant-picker-dropdown:visible td[title='{start_date_str}']"
|
||||
).first.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
print(" >> 设置截止时间...")
|
||||
page.get_by_placeholder("结束时间").click()
|
||||
page.wait_for_timeout(500)
|
||||
page.locator(
|
||||
f".ant-picker-dropdown:visible td[title='{today_str}']"
|
||||
).first.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# 确认日期
|
||||
page.locator(".ant-picker-dropdown:visible button", has_text="确 定").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
print(">> 正在展开【状态】下拉菜单...")
|
||||
page.locator(
|
||||
".ant-select-selection-item", has_text=re.compile(r"已发|已到")
|
||||
).click()
|
||||
print(">> 正在选择状态为【已到】...")
|
||||
page.locator(".ant-select-item-option", has_text="已到").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# ====================================================================
|
||||
# 🛡️ 双重校验兜底机制:破除顺心表格“暂无数据”的旧状态遗留陷阱
|
||||
# ====================================================================
|
||||
print(">> 正在发起查询与数据状态研判...")
|
||||
has_data = False
|
||||
waybill_btns = None
|
||||
|
||||
for attempt in range(2):
|
||||
print(f" -> 第 {attempt + 1} 次点击【查询】按钮...")
|
||||
page.get_by_role("button", name="search 查询").click()
|
||||
|
||||
# 尝试在极短时间内捕获加载小菊花
|
||||
loading_spinner = page.locator(".ant-spin-dot-spin").first
|
||||
try:
|
||||
loading_spinner.wait_for(state="visible", timeout=800)
|
||||
print(" ⏳ 捕捉到加载动画,等待数据渲染完成...")
|
||||
loading_spinner.wait_for(state="hidden", timeout=15000)
|
||||
except Exception:
|
||||
print(" ⚡ 加载动画闪过过快或未出现,强制安全缓冲1秒...")
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# 解析查询结果
|
||||
empty_desc = page.locator(
|
||||
".ant-empty-description", has_text="暂无数据"
|
||||
).first
|
||||
waybill_btns = page.get_by_role("button", name="运单列表")
|
||||
|
||||
if waybill_btns.count() > 0:
|
||||
has_data = True
|
||||
print(" ✅ 数据已成功加载。")
|
||||
break
|
||||
elif empty_desc.is_visible():
|
||||
print(" ⚠️ 当前表格显示【暂无数据】。")
|
||||
if attempt == 0:
|
||||
print(" -> 疑似前端 DOM 旧状态未刷新,触发二次兜底查询...")
|
||||
page.wait_for_timeout(500)
|
||||
else:
|
||||
print(" -> 已二次确认为空数据环境。")
|
||||
else:
|
||||
# 没出现暂无数据,也没出现按钮,稳妥判定缓冲完毕
|
||||
pass
|
||||
|
||||
if not has_data:
|
||||
print(">> ⚠️ 本次查询区间内没有数据记录,提前结束流程。")
|
||||
_close_tab(page, "车辆点到")
|
||||
return True
|
||||
|
||||
# ====================================================================
|
||||
|
||||
count = waybill_btns.count()
|
||||
print(f">> 共发现 {count} 个班次需要导出。")
|
||||
|
||||
for i in range(count):
|
||||
print(f" ⏳ 正在处理第 {i+1}/{count} 个班次...")
|
||||
|
||||
waybill_btns.nth(i).click()
|
||||
page.locator("label[title='运单查询']").wait_for(state="visible")
|
||||
|
||||
# 4. 执行导出流程
|
||||
page.get_by_role("button", name="export 导出").click()
|
||||
|
||||
page.locator(
|
||||
"span.ant-transfer-list-header-title:has-text('待选导出列')"
|
||||
).wait_for(state="visible")
|
||||
page.locator(".ant-transfer-list").first.locator(
|
||||
".ant-transfer-list-header label"
|
||||
).click()
|
||||
|
||||
page.get_by_label("导出").get_by_role("button", name="right").click()
|
||||
page.get_by_role("button", name="export 导出数据").click()
|
||||
|
||||
export_times.append(datetime.now())
|
||||
|
||||
page.locator("text=任务添加成功!").wait_for(state="visible")
|
||||
page.get_by_role("button", name="知道了").click()
|
||||
|
||||
page.get_by_role("tab", name="车辆点到").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
print("✅ 所有班次的导出任务已成功提交!")
|
||||
|
||||
# 5. 关闭标签页
|
||||
_close_tab(page, "运单列表")
|
||||
_close_tab(page, "车辆点到")
|
||||
|
||||
# 6. 前往数据导出页面去下载
|
||||
print(">> 正在前往【数据导出】界面...")
|
||||
page.locator("a[href='/dataExport']").click()
|
||||
|
||||
page.get_by_role("columnheader", name="任务标题").wait_for(state="visible")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
page.get_by_role("button", name="search 查询").click()
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 7. 轮询任务状态
|
||||
print(">> 列表已加载,开始匹配并检查任务状态...")
|
||||
poll_deadline = (
|
||||
time.monotonic() + 300
|
||||
) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试
|
||||
while True:
|
||||
if time.monotonic() > poll_deadline:
|
||||
raise RuntimeError(
|
||||
"轮询导出任务超时(5 分钟未全部完成/匹配),疑似任务卡死"
|
||||
)
|
||||
rows = page.locator(".ant-table-tbody > tr.ant-table-row")
|
||||
row_count = rows.count()
|
||||
pending_tasks = 0
|
||||
current_ready_timestamps = []
|
||||
|
||||
# 单账号无并发:仅按提交时间容差(40s)认领本批任务,不再校验任务标题
|
||||
# (站点可能调整标题名,写死标题会导致匹配失败、任务一直查不到)。
|
||||
for i in range(row_count):
|
||||
tds = rows.nth(i).locator("td")
|
||||
if tds.count() < 9:
|
||||
continue
|
||||
|
||||
submit_time_str = tds.nth(2).inner_text().strip()
|
||||
status_str = tds.nth(6).inner_text().strip()
|
||||
|
||||
try:
|
||||
row_time = datetime.strptime(submit_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
matched = any(
|
||||
abs((row_time - et).total_seconds()) <= 40
|
||||
for et in export_times
|
||||
)
|
||||
|
||||
if matched:
|
||||
if status_str != "执行完成":
|
||||
pending_tasks += 1
|
||||
if submit_time_str not in current_ready_timestamps:
|
||||
print(
|
||||
f" ⏳ 任务 [{submit_time_str}] 状态为【{status_str}】,数据生成中..."
|
||||
)
|
||||
else:
|
||||
if submit_time_str not in current_ready_timestamps:
|
||||
current_ready_timestamps.append(submit_time_str)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 解析时间时出错: {e}")
|
||||
|
||||
if pending_tasks > 0:
|
||||
print(
|
||||
f">> 共有 {pending_tasks} 个匹配任务还在处理中,等待 5 秒后刷新..."
|
||||
)
|
||||
page.wait_for_timeout(5000)
|
||||
page.get_by_role("button", name="search 查询").click()
|
||||
page.wait_for_timeout(2000)
|
||||
else:
|
||||
target_task_timestamps = current_ready_timestamps
|
||||
if len(target_task_timestamps) > 0:
|
||||
print(">> 所有目标任务已就绪,开始下载...")
|
||||
break
|
||||
|
||||
# 8. 下载逻辑
|
||||
downloaded_files = []
|
||||
for time_str in target_task_timestamps:
|
||||
try:
|
||||
target_row = page.locator(".ant-table-tbody > tr.ant-table-row").filter(
|
||||
has=page.locator(f"td:nth-child(3):has-text('{time_str}')")
|
||||
)
|
||||
print(f" 开始下载任务 [{time_str}] ...")
|
||||
|
||||
with page.expect_download() as download_info:
|
||||
target_row.locator("td").nth(8).locator(
|
||||
"button", has_text=re.compile(r"下\s*载")
|
||||
).click()
|
||||
|
||||
download = download_info.value
|
||||
safe_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
custom_filename = f"顺心_temp_{safe_timestamp}.xlsx"
|
||||
save_path = os.path.join(download_dir, custom_filename)
|
||||
download.save_as(save_path)
|
||||
downloaded_files.append(save_path)
|
||||
print(f" 已下载: downloads/{custom_filename}")
|
||||
except Exception as e:
|
||||
print(f" ❌ 下载任务 [{time_str}] 失败: {e}")
|
||||
|
||||
# 汇总校验:下载成功数必须等于目标任务数,否则判失败
|
||||
if len(downloaded_files) < len(target_task_timestamps):
|
||||
raise RuntimeError(
|
||||
f"仅成功下载 {len(downloaded_files)}/{len(target_task_timestamps)} 个任务,数据不完整"
|
||||
)
|
||||
|
||||
# 9. 合并数据
|
||||
if downloaded_files:
|
||||
print("\n>> 正在合并下载的数据...")
|
||||
all_data_frames = []
|
||||
for file_path in downloaded_files:
|
||||
try:
|
||||
df = pd.read_excel(file_path, dtype=str)
|
||||
if not df.empty:
|
||||
all_data_frames.append(df)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if all_data_frames:
|
||||
combined_df = pd.concat(all_data_frames, ignore_index=True)
|
||||
suffix = f"-{out_tag}" if out_tag else ""
|
||||
final_output_path = os.path.join(
|
||||
download_dir, f"顺心{suffix}-应到货物数据.xlsx"
|
||||
)
|
||||
combined_df.to_excel(final_output_path, index=False)
|
||||
print(f"====================================================")
|
||||
print(f" 合并完成,输出文件: {final_output_path}")
|
||||
print(f"====================================================")
|
||||
|
||||
for file_path in downloaded_files:
|
||||
os.remove(file_path)
|
||||
print("✅ 临时文件已清理。")
|
||||
|
||||
# 10. 关闭标签页
|
||||
_close_tab(page, "数据导出")
|
||||
print("\n【顺心 - 应到货物数据下载】流程结束。")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 任务执行过程中发生异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def shunxin_actual_download(pages):
|
||||
"""顺心:实到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。
|
||||
|
||||
与 shunxin_expected_download 同构:读归属地 → 去重校验 → 顺序各账号下载 →
|
||||
融合成统一的「顺心-实到货物数据.xlsx」。
|
||||
"""
|
||||
tags = []
|
||||
for idx, pg in enumerate(pages, start=1):
|
||||
tag = shunxin_belonging(pg)
|
||||
print(f">> 【顺心】账号{idx} 归属网点:{tag}")
|
||||
tags.append(tag)
|
||||
|
||||
if len(set(tags)) != len(tags):
|
||||
raise RuntimeError(
|
||||
f"顺心两个账号归属地相同({tags}),疑似登录了同一账号,已中止以防数据翻倍。"
|
||||
)
|
||||
|
||||
for idx, (pg, tag) in enumerate(zip(pages, tags), start=1):
|
||||
pg.bring_to_front()
|
||||
print(f"\n========== 顺心 · 账号{idx}({tag})实到数据下载 ==========")
|
||||
ok = with_retry(
|
||||
f"顺心-{tag}",
|
||||
"实到",
|
||||
lambda p=pg, t=tag: shunxin_actual_download_impl(p, out_tag=t),
|
||||
lambda p=pg: shunxin_reset(p),
|
||||
)
|
||||
if not ok:
|
||||
return False
|
||||
|
||||
shunxin_merge_final("实到", tags)
|
||||
return True
|
||||
|
||||
|
||||
def shunxin_actual_download_impl(page, out_tag=""):
|
||||
"""顺心:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。
|
||||
|
||||
out_tag 为归属地标签时,合并产物命名为「顺心-{out_tag}-实到货物数据.xlsx」,
|
||||
作为双账号融合前的各账号中间文件;为空时退化为「顺心-实到货物数据.xlsx」。
|
||||
"""
|
||||
print("\n▶ 开始执行【顺心 - 实到货物数据下载】任务...")
|
||||
|
||||
download_dir = DOWNLOAD_DIR
|
||||
if not os.path.exists(download_dir):
|
||||
os.makedirs(download_dir)
|
||||
|
||||
# 清理上次本账号的中间文件,避免本次无数据/失败时残留旧数据被 merge_final 误读
|
||||
_mid_suffix = f"-{out_tag}" if out_tag else ""
|
||||
_remove_if_exists(
|
||||
os.path.join(download_dir, f"顺心{_mid_suffix}-实到货物数据.xlsx")
|
||||
)
|
||||
|
||||
export_times = []
|
||||
target_task_timestamps = []
|
||||
|
||||
try:
|
||||
# 1. 导航与页面加载
|
||||
print(">> 正在进入【卸车扫描记录】界面...")
|
||||
page.locator("span.ant-pro-menu-item-title:has-text('派件管理')").click()
|
||||
page.locator("div.ant-pro-menu-item:has-text('卸车扫描记录')").click()
|
||||
|
||||
page.get_by_role("radio", name="1天").wait_for(state="visible")
|
||||
print("✅ 卸车扫描记录界面加载完毕")
|
||||
|
||||
# 2. 读取服务端日期偏移(0=今天,1=昨天…),单日范围:起止同日
|
||||
offset = state_store.get_offset("顺心", "actual")
|
||||
today = datetime.now()
|
||||
target = today - timedelta(days=offset)
|
||||
target_str = target.strftime("%Y-%m-%d")
|
||||
start_date_str = target_str
|
||||
today_str = target_str
|
||||
print(f">> 正在设置查询日期: [{target_str}](偏移 {offset},0=今天)...")
|
||||
|
||||
# 分两步精准呼出和点击时间控件
|
||||
print(" >> 设置起始时间...")
|
||||
page.get_by_placeholder("开始时间").click()
|
||||
page.wait_for_timeout(500)
|
||||
page.locator(
|
||||
f".ant-picker-dropdown:visible td[title='{start_date_str}']"
|
||||
).first.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
print(" >> 设置截止时间...")
|
||||
page.get_by_placeholder("结束时间").click()
|
||||
page.wait_for_timeout(500)
|
||||
page.locator(
|
||||
f".ant-picker-dropdown:visible td[title='{today_str}']"
|
||||
).first.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# 确认日期
|
||||
page.locator(".ant-picker-dropdown:visible button", has_text="确 定").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# ====================================================================
|
||||
# 🛡️ 卸车扫描记录页面双重校验兜底机制
|
||||
# ====================================================================
|
||||
print(">> 正在发起查询与数据状态研判...")
|
||||
has_data = False
|
||||
|
||||
for attempt in range(2):
|
||||
print(f" -> 第 {attempt + 1} 次点击【查询】按钮...")
|
||||
page.get_by_role("button", name="search 查询").click()
|
||||
|
||||
loading_spinner = page.locator(".ant-spin-dot-spin").first
|
||||
try:
|
||||
loading_spinner.wait_for(state="visible", timeout=800)
|
||||
print(" ⏳ 捕捉到加载动画,等待数据渲染完成...")
|
||||
loading_spinner.wait_for(state="hidden", timeout=15000)
|
||||
except Exception:
|
||||
print(" ⚡ 加载动画闪过过快或未出现,强制安全缓冲1秒...")
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
empty_desc = page.locator(
|
||||
".ant-empty-description", has_text="暂无数据"
|
||||
).first
|
||||
data_rows = page.locator(".ant-table-tbody > tr.ant-table-row")
|
||||
|
||||
if empty_desc.is_visible():
|
||||
print(" ⚠️ 当前表格显示【暂无数据】。")
|
||||
if attempt == 0:
|
||||
print(" -> 疑似前端 DOM 旧状态未刷新,触发二次兜底查询...")
|
||||
page.wait_for_timeout(500)
|
||||
else:
|
||||
print(" -> 已二次确认为空数据环境。")
|
||||
elif data_rows.count() > 0:
|
||||
has_data = True
|
||||
print(" ✅ 数据已成功加载。")
|
||||
break
|
||||
else:
|
||||
pass
|
||||
|
||||
if not has_data:
|
||||
print(">> ⚠️ 本次查询未产生任何卸车记录,提前结束流程。")
|
||||
_close_tab(page, "卸车扫描记录")
|
||||
return True
|
||||
|
||||
# ====================================================================
|
||||
|
||||
# 3. 直接发起全局导出
|
||||
print(">> 正在发起全局数据导出请求...")
|
||||
page.get_by_role("button", name="export 导出").click()
|
||||
|
||||
page.locator(
|
||||
"span.ant-transfer-list-header-title:has-text('待选导出列')"
|
||||
).wait_for(state="visible")
|
||||
page.locator(".ant-transfer-list").first.locator(
|
||||
".ant-transfer-list-header label"
|
||||
).click()
|
||||
|
||||
page.get_by_label("导出").get_by_role("button", name="right").click()
|
||||
page.get_by_role("button", name="export 导出数据").click()
|
||||
|
||||
export_times.append(datetime.now())
|
||||
|
||||
page.locator("text=任务添加成功!").wait_for(state="visible")
|
||||
page.get_by_role("button", name="知道了").click()
|
||||
print("✅ 卸车扫描记录导出任务已成功提交!")
|
||||
|
||||
# 4. 关闭标签页
|
||||
_close_tab(page, "卸车扫描记录")
|
||||
|
||||
# 5. 前往数据导出页面去下载
|
||||
print(">> 正在前往【数据导出】界面...")
|
||||
page.locator("a[href='/dataExport']").click()
|
||||
|
||||
page.get_by_role("columnheader", name="任务标题").wait_for(state="visible")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
page.get_by_role("button", name="search 查询").click()
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 6. 轮询任务状态
|
||||
print(">> 列表已加载,开始匹配并检查任务状态...")
|
||||
poll_deadline = (
|
||||
time.monotonic() + 300
|
||||
) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试
|
||||
while True:
|
||||
if time.monotonic() > poll_deadline:
|
||||
raise RuntimeError(
|
||||
"轮询导出任务超时(5 分钟未全部完成/匹配),疑似任务卡死"
|
||||
)
|
||||
rows = page.locator(".ant-table-tbody > tr.ant-table-row")
|
||||
row_count = rows.count()
|
||||
pending_tasks = 0
|
||||
current_ready_timestamps = []
|
||||
|
||||
# 单账号无并发:仅按提交时间容差(40s)认领本批任务,不再校验任务标题
|
||||
# (站点可能调整标题名,写死标题会导致匹配失败、任务一直查不到)。
|
||||
for i in range(row_count):
|
||||
tds = rows.nth(i).locator("td")
|
||||
if tds.count() < 9:
|
||||
continue
|
||||
|
||||
submit_time_str = tds.nth(2).inner_text().strip()
|
||||
status_str = tds.nth(6).inner_text().strip()
|
||||
|
||||
try:
|
||||
row_time = datetime.strptime(submit_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
matched = any(
|
||||
abs((row_time - et).total_seconds()) <= 40
|
||||
for et in export_times
|
||||
)
|
||||
|
||||
if matched:
|
||||
if status_str != "执行完成":
|
||||
pending_tasks += 1
|
||||
if submit_time_str not in current_ready_timestamps:
|
||||
print(
|
||||
f" ⏳ 任务 [{submit_time_str}] 状态为【{status_str}】,数据生成中..."
|
||||
)
|
||||
else:
|
||||
if submit_time_str not in current_ready_timestamps:
|
||||
current_ready_timestamps.append(submit_time_str)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 解析时间时出错: {e}")
|
||||
|
||||
if pending_tasks > 0:
|
||||
print(
|
||||
f">> 共有 {pending_tasks} 个匹配任务还在处理中,等待 5 秒后刷新..."
|
||||
)
|
||||
page.wait_for_timeout(5000)
|
||||
page.get_by_role("button", name="search 查询").click()
|
||||
page.wait_for_timeout(2000)
|
||||
else:
|
||||
target_task_timestamps = current_ready_timestamps
|
||||
if len(target_task_timestamps) > 0:
|
||||
print(">> 目标任务已就绪,开始下载...")
|
||||
break
|
||||
|
||||
# 7. 下载逻辑
|
||||
downloaded_files = []
|
||||
for time_str in target_task_timestamps:
|
||||
try:
|
||||
target_row = page.locator(".ant-table-tbody > tr.ant-table-row").filter(
|
||||
has=page.locator(f"td:nth-child(3):has-text('{time_str}')")
|
||||
)
|
||||
print(f" 开始下载任务 [{time_str}] ...")
|
||||
|
||||
with page.expect_download() as download_info:
|
||||
target_row.locator("td").nth(8).locator(
|
||||
"button", has_text=re.compile(r"下\s*载")
|
||||
).click()
|
||||
|
||||
download = download_info.value
|
||||
safe_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
custom_filename = f"顺心_temp_{safe_timestamp}.xlsx"
|
||||
save_path = os.path.join(download_dir, custom_filename)
|
||||
download.save_as(save_path)
|
||||
downloaded_files.append(save_path)
|
||||
print(f" 已下载: downloads/{custom_filename}")
|
||||
except Exception as e:
|
||||
print(f" ❌ 下载任务 [{time_str}] 失败: {e}")
|
||||
|
||||
# 汇总校验:下载成功数必须等于目标任务数,否则判失败
|
||||
if len(downloaded_files) < len(target_task_timestamps):
|
||||
raise RuntimeError(
|
||||
f"仅成功下载 {len(downloaded_files)}/{len(target_task_timestamps)} 个任务,数据不完整"
|
||||
)
|
||||
|
||||
# 8. 合并数据
|
||||
if downloaded_files:
|
||||
print("\n>> 正在合并下载的数据...")
|
||||
all_data_frames = []
|
||||
for file_path in downloaded_files:
|
||||
try:
|
||||
df = pd.read_excel(file_path, dtype=str)
|
||||
if not df.empty:
|
||||
all_data_frames.append(df)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if all_data_frames:
|
||||
combined_df = pd.concat(all_data_frames, ignore_index=True)
|
||||
suffix = f"-{out_tag}" if out_tag else ""
|
||||
final_output_path = os.path.join(
|
||||
download_dir, f"顺心{suffix}-实到货物数据.xlsx"
|
||||
)
|
||||
combined_df.to_excel(final_output_path, index=False)
|
||||
print(f"====================================================")
|
||||
print(f" 合并完成,输出文件: {final_output_path}")
|
||||
print(f"====================================================")
|
||||
|
||||
for file_path in downloaded_files:
|
||||
os.remove(file_path)
|
||||
print("✅ 临时文件已清理。")
|
||||
|
||||
# 9. 关闭标签页
|
||||
_close_tab(page, "数据导出")
|
||||
|
||||
print("\n【顺心 - 实到货物数据下载】流程结束。")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 任务执行过程中发生异常: {e}")
|
||||
return False
|
||||
744
inbound_verify/sites/yunda.py
Normal file
744
inbound_verify/sites/yunda.py
Normal file
@@ -0,0 +1,744 @@
|
||||
# sites/yunda.py
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import yaml
|
||||
from datetime import datetime, timedelta
|
||||
import pandas as pd
|
||||
|
||||
from inbound_verify.paths import DOWNLOAD_DIR, CONFIG_PATH
|
||||
from inbound_verify import state_store
|
||||
|
||||
|
||||
def with_retry(site_name, label, flow, reset, max_attempts=3):
|
||||
"""异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。
|
||||
|
||||
每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。
|
||||
flow 为零参可调用;返回 False 视为失败,其余视为成功。
|
||||
返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。
|
||||
"""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
ret = flow()
|
||||
if ret is False:
|
||||
raise RuntimeError("流程返回失败状态")
|
||||
if attempt > 1:
|
||||
print(f">> 【{site_name}-{label}】第 {attempt} 次尝试成功 ✅")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ 【{site_name}-{label}】第 {attempt}/{max_attempts} 次失败: {e}")
|
||||
print(f" → 重置【{site_name}】到初始态,清理环境 ...")
|
||||
try:
|
||||
reset()
|
||||
except Exception as re:
|
||||
print(f" ⚠️ 重置异常: {re}")
|
||||
if attempt < max_attempts:
|
||||
continue
|
||||
print(
|
||||
f"❌ 【{site_name}-{label}】已达最大尝试次数 {max_attempts},放弃(环境已清理)。"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# 站点首页 URL:异常兜底重置用,也供 main_router 的 SITES_CONFIG 引用(单一来源)
|
||||
HOME_URL = "https://ky-sso.yunda56.com"
|
||||
|
||||
|
||||
def dismiss_audio_prompt(page):
|
||||
"""关闭韵达登录后弹出的阻塞式提示弹窗(如音频设备未授权/未找到)。
|
||||
|
||||
不依赖具体文案(不同主机/音频状态下文案不同:未授权/未找到…),只要出现
|
||||
.ivu-modal-confirm 就点其「确定」。主页加载后调用(初始 + 重试重载)。
|
||||
"""
|
||||
try:
|
||||
modal = page.locator(".ivu-modal-confirm").first
|
||||
modal.wait_for(state="visible", timeout=2000)
|
||||
modal.locator(".ivu-modal-confirm-footer button.ivu-btn-primary").click()
|
||||
print(" ✅ 【韵达】已关闭提示弹窗。")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def yunda_reset(page):
|
||||
"""异常兜底:重置韵达到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。"""
|
||||
page.goto(HOME_URL)
|
||||
page.wait_for_timeout(1500)
|
||||
dismiss_audio_prompt(page) # 主页重载后音频授权提示会复现,清理之
|
||||
|
||||
|
||||
def _remove_if_exists(path):
|
||||
"""删除文件(若存在):流程开头清理上次的最终文件,避免无数据/失败时残留旧数据。"""
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def yunda_login(page):
|
||||
"""韵达自动登录:未登录则填充表单并提交,已登录则跳过。"""
|
||||
print(">> 正在检查韵达登录状态...")
|
||||
try:
|
||||
# 定位“账号密码登录”切换按钮
|
||||
switch_btn = page.locator("span", has_text="账号密码登录")
|
||||
|
||||
# 5 秒内若出现该按钮,说明当前未登录
|
||||
if switch_btn.is_visible(timeout=5000):
|
||||
print(" -> 检测到未登录界面,正在切换到【账号密码登录】...")
|
||||
switch_btn.click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 凭证存 state.db(由前端站点配置)
|
||||
username = state_store.get_setting("韵达", "username")
|
||||
password = state_store.get_setting("韵达", "password")
|
||||
|
||||
print(f" -> 正在填充登录表单 (账号: {username})...")
|
||||
page.locator("#username").fill(username)
|
||||
page.locator("#password").fill(password)
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
print(" -> 正在点击【登录】按钮并提交表单...")
|
||||
page.locator('button[type="submit"]', has_text="登录").click()
|
||||
page.wait_for_timeout(1000)
|
||||
else:
|
||||
print(" -> 未发现登录按钮,判定为已登录,跳过。")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 登录检测出错(可能已在工作台内): {e}")
|
||||
|
||||
|
||||
def yunda_smart_menu_click(page, menu_path):
|
||||
"""韵达多级菜单导航:展开父级菜单并点击目标项(已展开则跳过,避免误折叠)。"""
|
||||
print(f">> 导航韵达菜单: {' -> '.join(menu_path)}")
|
||||
|
||||
for item in menu_path:
|
||||
title_locator = page.locator(
|
||||
f"xpath=//div[contains(@class, 'el-submenu__title') and .//span[normalize-space(.)='{item}']]"
|
||||
).first
|
||||
parent_li = page.locator(
|
||||
f"xpath=//div[contains(@class, 'el-submenu__title') and .//span[normalize-space(.)='{item}']]/.."
|
||||
).first
|
||||
leaf_locator = page.locator(
|
||||
f"xpath=//li[contains(@class, 'el-menu-item') and .//span[normalize-space(.)='{item}']]"
|
||||
).first
|
||||
|
||||
if title_locator.is_visible():
|
||||
current_class = parent_li.get_attribute("class") or ""
|
||||
is_opened = "is-opened" in current_class
|
||||
|
||||
if not is_opened:
|
||||
print(f" -> 父菜单 [{item}] 处于收起状态,点击展开")
|
||||
title_locator.click()
|
||||
page.wait_for_timeout(500)
|
||||
else:
|
||||
print(f" -> 父菜单 [{item}] 已展开,跳过点击")
|
||||
|
||||
elif leaf_locator.is_visible():
|
||||
print(f" -> 点击菜单项 [{item}]")
|
||||
leaf_locator.click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
|
||||
def yunda_expected_download(page):
|
||||
"""韵达:应到货物数据下载(内部含异常兜底重试,路由层无感)。"""
|
||||
|
||||
return with_retry(
|
||||
"韵达",
|
||||
"应到",
|
||||
lambda: yunda_expected_download_impl(page),
|
||||
lambda: yunda_reset(page),
|
||||
)
|
||||
|
||||
|
||||
def yunda_expected_download_impl(page):
|
||||
"""韵达:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。"""
|
||||
print("\n▶ 开始执行【韵达 - 应到货物数据下载】任务...")
|
||||
|
||||
download_dir = DOWNLOAD_DIR
|
||||
if not os.path.exists(download_dir):
|
||||
os.makedirs(download_dir)
|
||||
|
||||
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
|
||||
_remove_if_exists(os.path.join(download_dir, "韵达-应到货物数据.xlsx"))
|
||||
|
||||
export_times = []
|
||||
|
||||
try:
|
||||
# 1. 验证首页并导航菜单
|
||||
page.locator(".el-menu-item", has_text="首页").wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
print("✅ 韵达工作台首页已加载")
|
||||
|
||||
yunda_smart_menu_click(page, ["运营管理", "进站管理", "进站交接单查询"])
|
||||
|
||||
print(">> 正在定位【进站交接单查询】iframe...")
|
||||
ws_frame = page.frame_locator("section iframe")
|
||||
|
||||
ws_frame.locator("#startTime").wait_for(state="attached", timeout=15000)
|
||||
print("✅ 进站交接单查询页面就绪")
|
||||
|
||||
# 2. 读取服务端日期偏移(0=今天,1=昨天…),单日范围:起止同日
|
||||
offset = state_store.get_offset("韵达")
|
||||
today = datetime.now()
|
||||
target = today - timedelta(days=offset)
|
||||
target_ymd = f"{target.year}-{target.month}-{target.day}"
|
||||
start_date_ymd = target_ymd
|
||||
today_ymd = target_ymd
|
||||
print(f">> 设置查询日期: [{target_ymd}](偏移 {offset},0=今天)")
|
||||
|
||||
# 设定起始时间
|
||||
print(" >> 设置起始时间...")
|
||||
page.wait_for_timeout(1000)
|
||||
ws_frame.locator("#startTime").click(force=True)
|
||||
|
||||
calendar1 = ws_frame.locator(".layui-laydate:visible").first
|
||||
calendar1.wait_for(state="visible", timeout=5000)
|
||||
calendar1.locator(f"td[lay-ymd='{start_date_ymd}']").click()
|
||||
calendar1.locator(".laydate-btns-confirm").click()
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
# 设定截止时间
|
||||
print(" >> 设置截止时间...")
|
||||
ws_frame.locator("#endTime").click(force=True)
|
||||
|
||||
calendar2 = ws_frame.locator(".layui-laydate:visible").first
|
||||
calendar2.wait_for(state="visible", timeout=5000)
|
||||
calendar2.locator(f"td[lay-ymd='{today_ymd}']").click()
|
||||
calendar2.locator(".laydate-btns-confirm").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 3. 等待数据加载完成
|
||||
print(">> 正在执行查询...")
|
||||
ws_frame.locator("a.btn-success", has_text="查询").click()
|
||||
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
# 将 Loading 蒙层与数据判断限制在 #tab-1 内
|
||||
loading_mask = ws_frame.locator(
|
||||
"#tab-1 .fixed-table-loading", has_text="正在努力地加载数据中"
|
||||
).first
|
||||
if loading_mask.is_visible():
|
||||
print(" ⏳ 检测到数据加载遮罩,等待加载完成...")
|
||||
loading_mask.wait_for(state="hidden", timeout=30000)
|
||||
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
sum_panel = ws_frame.locator("#sum").first
|
||||
has_data = False
|
||||
|
||||
if sum_panel.is_visible():
|
||||
sum_text = sum_panel.inner_text()
|
||||
match_tickets = re.search(r"进站实际票数:(\d+)", sum_text)
|
||||
if match_tickets and int(match_tickets.group(1)) > 0:
|
||||
has_data = True
|
||||
print(f" ✅ 统计面板已加载,实际票数: [{match_tickets.group(1)}]")
|
||||
|
||||
if not has_data:
|
||||
if ws_frame.locator(
|
||||
"#tab-1 .no-records-found", has_text="没有找到匹配的记录"
|
||||
).first.is_visible():
|
||||
print(" ⚠️ 确认为空数据,正在关闭当前标签页...")
|
||||
page.locator(".tags-view-item", has_text="进站交接单查询").locator(
|
||||
".el-icon-close"
|
||||
).click()
|
||||
return
|
||||
|
||||
# 4. 深度等待表格第一行数据行渲染就绪
|
||||
ws_frame.locator("#exampleTable1 tbody tr[data-index='0']").wait_for(
|
||||
state="visible", timeout=10000
|
||||
)
|
||||
|
||||
main_rows = ws_frame.locator("#exampleTable1 tbody tr[data-index]")
|
||||
row_count = main_rows.count()
|
||||
print(f">> 当前视窗共捕获到活跃交接单记录: {row_count} 条")
|
||||
|
||||
# 5. 逐行双击并提交导出
|
||||
for i in range(row_count):
|
||||
print(f" ⏳ 正在处理第 {i+1}/{row_count} 个交接单模块...")
|
||||
current_row = ws_frame.locator("#exampleTable1 tbody tr[data-index]").nth(i)
|
||||
|
||||
raw_no = current_row.locator("td").nth(1).inner_text().strip()
|
||||
|
||||
# 跳过已绑定的交接单
|
||||
bind_status = current_row.locator("td").nth(2).inner_text().strip()
|
||||
print(f" -> 交接单号: {raw_no} [绑定状态: {bind_status}]")
|
||||
|
||||
if bind_status == "已绑定":
|
||||
print(" ⏭️ 该交接单已绑定,跳过。")
|
||||
continue
|
||||
|
||||
current_row.dblclick()
|
||||
|
||||
ws_frame.locator("#docSum").wait_for(state="visible", timeout=15000)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 导出弹窗双层重试:外层重新打开面板,内层重新提交。
|
||||
# 区分字段漏选(补点全选)与字段列表消失(重新打开面板)。
|
||||
task_success = False
|
||||
for major_attempt in range(3):
|
||||
print(f" >> 正在打开数据导出面板 (尝试 {major_attempt + 1}/3)...")
|
||||
ws_frame.locator('a.btn-info[onclick*="exportFile"]').click()
|
||||
|
||||
ws_frame.locator(".layui-layer-title", has_text="数据导出").wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
|
||||
export_frame = ws_frame.frame_locator('iframe[name="target1"]')
|
||||
|
||||
try:
|
||||
# 校验字段列表是否加载完成(以“交接单号”为标志)
|
||||
export_frame.get_by_text("交接单号").first.wait_for(
|
||||
state="visible", timeout=3000
|
||||
)
|
||||
except Exception:
|
||||
print(" ⚠️ 字段列表未加载,关闭面板后重试...")
|
||||
ws_frame.locator(".layui-layer-close1").click()
|
||||
page.wait_for_timeout(1000)
|
||||
continue
|
||||
|
||||
export_frame.locator(".allRight").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
inner_success = False
|
||||
needs_reopen = False
|
||||
|
||||
print(" -> 正在提交导出任务...")
|
||||
for attempt in range(4):
|
||||
export_frame.locator("#submitbutton", has_text="导出数据").click()
|
||||
confirm_link = export_frame.get_by_role("link", name="确定")
|
||||
try:
|
||||
confirm_link.wait_for(state="visible", timeout=6000)
|
||||
if export_frame.get_by_text("导出任务建立成功").is_visible():
|
||||
print(" ✅ 导出任务已建立成功。")
|
||||
confirm_link.click()
|
||||
inner_success = True
|
||||
break
|
||||
elif export_frame.get_by_text(
|
||||
"请选择格式相应的导出字段"
|
||||
).is_visible():
|
||||
confirm_link.click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 区分:字段漏选 还是 字段列表消失
|
||||
if export_frame.get_by_text("交接单号").first.is_visible():
|
||||
print(
|
||||
" ⚠️ 检测到未选择字段(字段列表仍在),重新点击全选..."
|
||||
)
|
||||
export_frame.locator(".allRight").click()
|
||||
page.wait_for_timeout(500)
|
||||
else:
|
||||
print(
|
||||
" ⚠️ 字段列表异常消失,重新打开导出面板..."
|
||||
)
|
||||
needs_reopen = True
|
||||
break # 跳出内层循环,重新打开面板
|
||||
else:
|
||||
confirm_link.click()
|
||||
page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
if inner_success:
|
||||
task_success = True
|
||||
ws_frame.locator(".layui-layer-close1").click()
|
||||
page.wait_for_timeout(500)
|
||||
break # 跳出外层循环,继续后续步骤
|
||||
elif needs_reopen:
|
||||
ws_frame.locator(".layui-layer-close1").click()
|
||||
page.wait_for_timeout(1000)
|
||||
continue # 重新打开面板
|
||||
else:
|
||||
ws_frame.locator(".layui-layer-close1").click()
|
||||
page.wait_for_timeout(1000)
|
||||
continue
|
||||
|
||||
if not task_success:
|
||||
raise RuntimeError("多次重试后仍未能建立应到数据离线任务。")
|
||||
|
||||
ws_frame.locator("#myTab a", has_text="交接单信息").click()
|
||||
page.wait_for_timeout(800)
|
||||
export_times.append(datetime.now())
|
||||
|
||||
print(">> 任务提交完成,正在关闭【进站交接单查询】标签页...")
|
||||
page.locator(".tags-view-item", has_text="进站交接单查询").locator(
|
||||
".el-icon-close"
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 若所有记录都被跳过,export_times 为空,直接结束
|
||||
if not export_times:
|
||||
print(">> ⚠️ 本次未产生任何离线下载任务(无数据或已全部跳过),结束。")
|
||||
return
|
||||
|
||||
_yunda_poll_and_download_tasks(
|
||||
page,
|
||||
export_times,
|
||||
download_dir,
|
||||
"韵达-应到货物数据.xlsx",
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 任务执行过程中发生异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def yunda_actual_download(page):
|
||||
"""韵达:实到货物数据下载(内部含异常兜底重试,路由层无感)。"""
|
||||
|
||||
return with_retry(
|
||||
"韵达",
|
||||
"实到",
|
||||
lambda: yunda_actual_download_impl(page),
|
||||
lambda: yunda_reset(page),
|
||||
)
|
||||
|
||||
|
||||
def yunda_actual_download_impl(page):
|
||||
"""韵达:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。"""
|
||||
print("\n▶ 开始执行【韵达 - 实到货物数据下载】任务...")
|
||||
|
||||
download_dir = DOWNLOAD_DIR
|
||||
if not os.path.exists(download_dir):
|
||||
os.makedirs(download_dir)
|
||||
|
||||
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
|
||||
_remove_if_exists(os.path.join(download_dir, "韵达-实到货物数据.xlsx"))
|
||||
|
||||
export_times = []
|
||||
|
||||
try:
|
||||
page.locator(".el-menu-item", has_text="首页").wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
print("✅ 韵达工作台首页已加载")
|
||||
|
||||
yunda_smart_menu_click(page, ["报表管理", "扫描记录查询"])
|
||||
|
||||
print(">> 正在定位【扫描记录查询】iframe...")
|
||||
ws_frame = page.frame_locator("section iframe")
|
||||
|
||||
ws_frame.locator(
|
||||
".no-records-found", has_text="没有找到匹配的记录"
|
||||
).first.wait_for(state="visible", timeout=15000)
|
||||
print("✅ 扫描记录查询页面已初始化")
|
||||
|
||||
offset = state_store.get_offset("韵达", "actual")
|
||||
today = datetime.now()
|
||||
target = today - timedelta(days=offset)
|
||||
start_date = target # 单日范围:起止同日
|
||||
today = target # 让下方"截止时间"选择器也指向 target
|
||||
print(
|
||||
f">> 设置实到查询日期: [{target.year}-{target.month}-{target.day}]"
|
||||
f"(偏移 {offset},0=今天)"
|
||||
)
|
||||
|
||||
print(" >> 正在设定起始时间...")
|
||||
ws_frame.locator("#startDate").click()
|
||||
page.wait_for_timeout(400)
|
||||
box1 = ws_frame.locator("#laydate_box:visible").first
|
||||
box1.locator(
|
||||
f"td[y='{start_date.year}'][m='{start_date.month}'][d='{start_date.day}']"
|
||||
).click()
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
print(" >> 正在设定截止时间...")
|
||||
ws_frame.locator("#endDate").click()
|
||||
page.wait_for_timeout(400)
|
||||
box2 = ws_frame.locator("#laydate_box:visible").first
|
||||
box2.locator(
|
||||
f"td[y='{today.year}'][m='{today.month}'][d='{today.day}']"
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
print(" >> 正在变更扫描类型为【到件】...")
|
||||
ws_frame.locator("#scanRecordTyp").select_option(value="03")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
print(">> 正在执行查询...")
|
||||
ws_frame.locator('input[type="button"][value="查询"]').click()
|
||||
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
loading_mask = ws_frame.locator(
|
||||
".fixed-table-loading", has_text="正在努力地加载数据中"
|
||||
).first
|
||||
if loading_mask.is_visible():
|
||||
print(" ⏳ 检测到数据加载遮罩,等待加载完成...")
|
||||
loading_mask.wait_for(state="hidden", timeout=30000)
|
||||
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
pg_info = ws_frame.locator(".pagination-info").first
|
||||
has_records = False
|
||||
|
||||
if pg_info.is_visible():
|
||||
info_text = pg_info.inner_text()
|
||||
match_total = re.search(r"总共\s*(\d+)\s*条记录", info_text)
|
||||
if match_total and int(match_total.group(1)) > 0:
|
||||
has_records = True
|
||||
print(f" ✅ 实到数据已加载,总记录数: [{match_total.group(1)}] 条。")
|
||||
|
||||
if not has_records:
|
||||
if ws_frame.locator(
|
||||
".no-records-found", has_text="没有找到匹配的记录"
|
||||
).first.is_visible():
|
||||
print(" ⚠️ 当前查询范围内为空数据,终止并关闭标签页。")
|
||||
page.locator(".tags-view-item", has_text="扫描记录查询").locator(
|
||||
".el-icon-close"
|
||||
).click()
|
||||
return
|
||||
else:
|
||||
print(" ⚠️ 未找到数据,也未出现空数据提示,结束。")
|
||||
page.locator(".tags-view-item", has_text="扫描记录查询").locator(
|
||||
".el-icon-close"
|
||||
).click()
|
||||
return
|
||||
|
||||
# 导出弹窗双层重试:外层重新打开面板,内层重新提交。
|
||||
# 区分字段漏选(补点全选)与字段列表消失(重新打开面板)。
|
||||
print(">> 正在发起导出...")
|
||||
task_success = False
|
||||
|
||||
for major_attempt in range(3):
|
||||
print(f" >> 正在打开数据导出面板 (尝试 {major_attempt + 1}/3)...")
|
||||
ws_frame.locator('input[type="button"][id="export"]').click()
|
||||
|
||||
ws_frame.locator(".layui-layer-title", has_text="数据导出").wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
export_frame = ws_frame.frame_locator('iframe[name="myFrame"]')
|
||||
|
||||
try:
|
||||
# 校验字段列表是否加载完成(以“扫描类型”为标志)
|
||||
export_frame.get_by_text("扫描类型").first.wait_for(
|
||||
state="visible", timeout=3000
|
||||
)
|
||||
except Exception:
|
||||
print(" ⚠️ 字段列表未加载,关闭面板后重试...")
|
||||
ws_frame.locator(".layui-layer-close1").click()
|
||||
page.wait_for_timeout(1000)
|
||||
continue
|
||||
|
||||
export_frame.locator(".allRight").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
inner_success = False
|
||||
needs_reopen = False
|
||||
|
||||
print(" -> 正在提交导出任务...")
|
||||
for attempt in range(4):
|
||||
export_frame.locator("#submitbutton", has_text="导出数据").click()
|
||||
confirm_link = export_frame.get_by_role("link", name="确定")
|
||||
try:
|
||||
confirm_link.wait_for(state="visible", timeout=6000)
|
||||
if export_frame.get_by_text("导出任务建立成功").is_visible():
|
||||
print(" ✅ 导出任务已建立成功。")
|
||||
confirm_link.click()
|
||||
inner_success = True
|
||||
break
|
||||
elif export_frame.get_by_text(
|
||||
"请选择格式相应的导出字段"
|
||||
).is_visible():
|
||||
confirm_link.click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 区分:字段漏选 还是 字段列表消失
|
||||
if export_frame.get_by_text("扫描类型").first.is_visible():
|
||||
print(
|
||||
" ⚠️ 检测到未选择字段(字段列表仍在),重新点击全选..."
|
||||
)
|
||||
export_frame.locator(".allRight").click()
|
||||
page.wait_for_timeout(500)
|
||||
else:
|
||||
print(" ⚠️ 字段列表异常消失,重新打开导出面板...")
|
||||
needs_reopen = True
|
||||
break
|
||||
else:
|
||||
confirm_link.click()
|
||||
page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
if inner_success:
|
||||
task_success = True
|
||||
ws_frame.locator(".layui-layer-close1").click()
|
||||
page.wait_for_timeout(500)
|
||||
break
|
||||
elif needs_reopen:
|
||||
ws_frame.locator(".layui-layer-close1").click()
|
||||
page.wait_for_timeout(1000)
|
||||
continue
|
||||
else:
|
||||
ws_frame.locator(".layui-layer-close1").click()
|
||||
page.wait_for_timeout(1000)
|
||||
continue
|
||||
|
||||
if not task_success:
|
||||
raise RuntimeError("多次重试后仍未能建立实到数据离线任务。")
|
||||
|
||||
export_times.append(datetime.now())
|
||||
|
||||
print(">> 任务提交完成,正在关闭【扫描记录查询】标签页...")
|
||||
page.locator(".tags-view-item", has_text="扫描记录查询").locator(
|
||||
".el-icon-close"
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 若未产生导出任务则结束
|
||||
if not export_times:
|
||||
print(">> ⚠️ 本次未产生离线下载任务,结束。")
|
||||
return
|
||||
|
||||
# 6. 轮询并下载
|
||||
_yunda_poll_and_download_tasks(
|
||||
page,
|
||||
export_times,
|
||||
download_dir,
|
||||
"韵达-实到货物数据.xlsx",
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 任务执行过程中发生异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _yunda_poll_and_download_tasks(page, export_times, download_dir, final_filename):
|
||||
"""韵达离线任务的轮询与下载"""
|
||||
print("\n>> 正在前往【导出服务】界面...")
|
||||
yunda_smart_menu_click(page, ["基础数据", "导出服务"])
|
||||
|
||||
export_ws_frame = page.frame_locator("section iframe")
|
||||
|
||||
export_ws_frame.get_by_role("cell", name="模块名称", exact=True).wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
print(">> 开始轮询离线任务队列,直到全部完成...")
|
||||
total_expected = len(export_times)
|
||||
|
||||
poll_deadline = (
|
||||
time.monotonic() + 300
|
||||
) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试
|
||||
while True:
|
||||
if time.monotonic() > poll_deadline:
|
||||
raise RuntimeError(
|
||||
"轮询导出任务超时(5 分钟未全部完成/匹配),疑似任务卡死"
|
||||
)
|
||||
task_rows = export_ws_frame.locator(
|
||||
".datagrid-view2 .datagrid-btable tbody tr.datagrid-row"
|
||||
)
|
||||
row_count = task_rows.count()
|
||||
|
||||
ready_indices = []
|
||||
processing_indices = []
|
||||
|
||||
# 单账号无并发:仅按创建时间容差(40s)认领本批任务,不再校验模块名称
|
||||
# (站点可能调整标题名,写死标题会导致匹配失败、任务一直查不到)。
|
||||
for idx in range(row_count):
|
||||
row = task_rows.nth(idx)
|
||||
status_name = row.locator("td[field='fileStatus']").inner_text().strip()
|
||||
create_time_str = (
|
||||
row.locator("td[field='createdTime']").inner_text().strip()
|
||||
)
|
||||
|
||||
try:
|
||||
row_time = datetime.strptime(create_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
matched = any(
|
||||
abs((row_time - et).total_seconds()) <= 40 for et in export_times
|
||||
)
|
||||
|
||||
if matched:
|
||||
if status_name == "导出完成":
|
||||
ready_indices.append(idx)
|
||||
else:
|
||||
processing_indices.append(idx)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total_found = len(ready_indices) + len(processing_indices)
|
||||
print(
|
||||
f" 📊 状态统计:期望 [{total_expected}],已入表 [{total_found}] (完成 [{len(ready_indices)}],生成中 [{len(processing_indices)}])"
|
||||
)
|
||||
|
||||
if total_found < total_expected or len(processing_indices) > 0:
|
||||
print(" ⏳ 队列未齐全,点击查询刷新...")
|
||||
export_ws_frame.locator(
|
||||
"#ydkyimport_basic_export_searchData1_ky_export_common"
|
||||
).click()
|
||||
page.wait_for_timeout(3000)
|
||||
else:
|
||||
print(">> 所有离线任务已就绪,开始依次下载...")
|
||||
break
|
||||
|
||||
downloaded_files = []
|
||||
for row_idx in ready_indices:
|
||||
try:
|
||||
target_row = export_ws_frame.locator(
|
||||
".datagrid-view2 .datagrid-btable tbody tr.datagrid-row"
|
||||
).nth(row_idx)
|
||||
time_flag = (
|
||||
target_row.locator("td[field='createdTime']").inner_text().strip()
|
||||
)
|
||||
print(f" 开始下载任务 [{time_flag}] ...")
|
||||
|
||||
with page.expect_download() as download_info:
|
||||
target_row.locator("td[field='extreFile'] a").get_by_text(
|
||||
"下载"
|
||||
).first.click()
|
||||
|
||||
download = download_info.value
|
||||
|
||||
safe_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
custom_filename = f"韵达_temp_{safe_timestamp}.xlsx"
|
||||
save_path = os.path.join(download_dir, custom_filename)
|
||||
|
||||
download.save_as(save_path)
|
||||
downloaded_files.append(save_path)
|
||||
print(f" 已下载: downloads/{custom_filename}")
|
||||
page.wait_for_timeout(500)
|
||||
except Exception as e:
|
||||
print(f" ❌ 下载失败: {e}")
|
||||
|
||||
# 汇总校验:下载成功数必须等于本批提交的任务数,否则判失败
|
||||
if len(downloaded_files) < total_expected:
|
||||
raise RuntimeError(
|
||||
f"仅成功下载 {len(downloaded_files)}/{total_expected} 个任务,数据不完整"
|
||||
)
|
||||
|
||||
print(">> 【导出服务】下载完成,正在关闭标签页...")
|
||||
try:
|
||||
page.locator(".tags-view-item", has_text="导出服务").locator(
|
||||
".el-icon-close"
|
||||
).click()
|
||||
print(" ✅ 【导出服务】标签页已关闭。")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if downloaded_files:
|
||||
print("\n>> 正在合并下载的数据...")
|
||||
all_dfs = []
|
||||
for file_path in downloaded_files:
|
||||
try:
|
||||
df = pd.read_excel(file_path, dtype=str)
|
||||
if not df.empty:
|
||||
all_dfs.append(df)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if all_dfs:
|
||||
combined_df = pd.concat(all_dfs, ignore_index=True)
|
||||
final_output = os.path.join(download_dir, final_filename)
|
||||
combined_df.to_excel(final_output, index=False)
|
||||
print(f"====================================================")
|
||||
print(f" 合并完成。")
|
||||
print(f" 📁 输出路径: {final_output}")
|
||||
print(f"====================================================")
|
||||
|
||||
for file_path in downloaded_files:
|
||||
os.remove(file_path)
|
||||
print(" 临时文件已清理。")
|
||||
679
inbound_verify/sites/zto.py
Normal file
679
inbound_verify/sites/zto.py
Normal file
@@ -0,0 +1,679 @@
|
||||
# sites/zto.py
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import yaml
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
|
||||
from inbound_verify.paths import DOWNLOAD_DIR, CONFIG_PATH
|
||||
from inbound_verify import state_store
|
||||
|
||||
|
||||
def with_retry(site_name, label, flow, reset, max_attempts=3):
|
||||
"""异常兜底:flow 失败 → 重置回初始态 → 重试,最多 max_attempts 次(含首次)。
|
||||
|
||||
每次失败都重置(含最终放弃那一次):既是重试前的清场,也保证最终放弃时环境干净。
|
||||
flow 为零参可调用;返回 False 视为失败,其余视为成功。
|
||||
返回 True=最终成功,False=重试耗尽放弃(供调度层判断任务成败)。
|
||||
"""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
ret = flow()
|
||||
if ret is False:
|
||||
raise RuntimeError("流程返回失败状态")
|
||||
if attempt > 1:
|
||||
print(f">> 【{site_name}-{label}】第 {attempt} 次尝试成功 ✅")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ 【{site_name}-{label}】第 {attempt}/{max_attempts} 次失败: {e}")
|
||||
print(f" → 重置【{site_name}】到初始态,清理环境 ...")
|
||||
try:
|
||||
reset()
|
||||
except Exception as re:
|
||||
print(f" ⚠️ 重置异常: {re}")
|
||||
if attempt < max_attempts:
|
||||
continue
|
||||
print(
|
||||
f"❌ 【{site_name}-{label}】已达最大尝试次数 {max_attempts},放弃(环境已清理)。"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# 站点首页 URL:异常兜底重置用,也供 main_router 的 SITES_CONFIG 引用(单一来源)
|
||||
HOME_URL = "https://ws.zto56.com/"
|
||||
|
||||
|
||||
def zto_reset(page):
|
||||
"""异常兜底:重置中通到初始态(跳首页 URL,丢弃当前页面状态,登录态保留)。"""
|
||||
page.goto(HOME_URL)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
|
||||
def _remove_if_exists(path):
|
||||
"""删除文件(若存在):流程开头清理上次的最终文件,避免无数据/失败时残留旧数据。"""
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _wait_and_get_frame(page, text_indicator, timeout_ms=20000):
|
||||
"""在主页面与所有 iframe 中查找包含指定文本的窗口"""
|
||||
start_time = datetime.now()
|
||||
while (datetime.now() - start_time).total_seconds() * 1000 < timeout_ms:
|
||||
try:
|
||||
if page.get_by_text(text_indicator).count() > 0:
|
||||
return page
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for frame in page.frames:
|
||||
try:
|
||||
if frame.get_by_text(text_indicator).count() > 0:
|
||||
return frame
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.wait_for_timeout(300)
|
||||
raise TimeoutError(f"超时:未找到包含 [{text_indicator}] 的窗口。")
|
||||
|
||||
|
||||
def _dom_click(locator):
|
||||
"""直接在元素上派发 mousedown+mouseup+click 事件,不走 Playwright 的坐标命中测试,
|
||||
避免被遮挡元素(如日期控件的 hover 提示气泡)拦截。事件直接发到目标元素并冒泡,
|
||||
兼容绑定 mousedown 或 click 的控件。"""
|
||||
locator.evaluate(
|
||||
"el => { const o = { bubbles: true, cancelable: true, view: window, button: 0 };"
|
||||
" el.dispatchEvent(new MouseEvent('mousedown', o));"
|
||||
" el.dispatchEvent(new MouseEvent('mouseup', o));"
|
||||
" el.dispatchEvent(new MouseEvent('click', o)); }"
|
||||
)
|
||||
|
||||
|
||||
def zto_smart_menu_click(page, menu_path):
|
||||
"""中通菜单导航"""
|
||||
print(f">> 正在导航: {' -> '.join(menu_path)}")
|
||||
for i in range(len(menu_path)):
|
||||
current_menu = menu_path[i]
|
||||
if i < len(menu_path) - 1:
|
||||
next_menu = menu_path[i + 1]
|
||||
next_locator = page.locator("span.menu-name", has_text=next_menu).first
|
||||
if not next_locator.is_visible():
|
||||
page.locator("span.menu-name", has_text=current_menu).first.click()
|
||||
page.wait_for_timeout(800)
|
||||
else:
|
||||
page.locator("span.menu-name", has_text=current_menu).first.click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
|
||||
def zto_expected_download(page):
|
||||
"""中通:应到货物数据下载(内部含异常兜底重试,路由层无感)。"""
|
||||
|
||||
return with_retry(
|
||||
"中通",
|
||||
"应到",
|
||||
lambda: zto_expected_download_impl(page),
|
||||
lambda: zto_reset(page),
|
||||
)
|
||||
|
||||
|
||||
def zto_expected_download_impl(page):
|
||||
"""中通:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。"""
|
||||
print("\n▶ 开始执行【中通 - 应到货物数据下载】任务...")
|
||||
|
||||
download_dir = DOWNLOAD_DIR
|
||||
if not os.path.exists(download_dir):
|
||||
os.makedirs(download_dir)
|
||||
|
||||
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
|
||||
_remove_if_exists(os.path.join(download_dir, "中通-应到货物数据.xlsx"))
|
||||
|
||||
export_times = []
|
||||
|
||||
try:
|
||||
# 1. 菜单导航
|
||||
zto_smart_menu_click(page, ["运营管理", "进站管理", "进站交接单查询"])
|
||||
|
||||
ewb_frame = page.frame_locator('iframe[src*="inEwbsListNoSearch"]')
|
||||
|
||||
print(">> 正在检测表格统计面板 (#inEwbCount)...")
|
||||
ewb_frame.locator("#inEwbCount").wait_for(state="attached", timeout=15000)
|
||||
|
||||
# 读取服务端日期偏移(0=今天,1=昨天…),单日:起止同日
|
||||
offset = state_store.get_offset("中通")
|
||||
|
||||
print(f">> 正在设定查询日期: 偏移 {offset}(0=今天)...")
|
||||
ewb_frame.locator("#beginDate").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
today_cell = ewb_frame.locator("td div.day.real-today").first
|
||||
today_cell.wait_for(state="visible")
|
||||
|
||||
today_time_str = today_cell.get_attribute("time")
|
||||
if today_time_str:
|
||||
today_time = int(today_time_str)
|
||||
target_time = today_time - offset * 86400000
|
||||
target_cell = ewb_frame.locator(f"td div.day[time='{target_time}']").first
|
||||
|
||||
if target_cell.is_visible():
|
||||
target_cell.click()
|
||||
page.wait_for_timeout(300)
|
||||
target_cell.click()
|
||||
else:
|
||||
print(" ⚠️ 偏移日期不在当前日历视窗内,自动降级为查询当天。")
|
||||
today_cell.click()
|
||||
page.wait_for_timeout(300)
|
||||
today_cell.click()
|
||||
else:
|
||||
today_cell.click()
|
||||
page.wait_for_timeout(300)
|
||||
today_cell.click()
|
||||
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 3. 触发查询并判断数据状态
|
||||
print(">> 正在点击【查询】按钮并等待数据响应...")
|
||||
old_count = ewb_frame.locator("#inEwbCount").inner_text().strip()
|
||||
ewb_frame.locator("#searchbtn").click()
|
||||
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
loading_mask = ewb_frame.locator(".mini-mask-loading", has_text="加载中")
|
||||
if loading_mask.is_visible():
|
||||
print(" ⏳ 检测到数据加载遮罩层,等待系统渲染...")
|
||||
loading_mask.wait_for(state="hidden", timeout=30000)
|
||||
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# 轮询判定统计面板更新
|
||||
wait_cycles = 0
|
||||
while wait_cycles < 20:
|
||||
current_count = ewb_frame.locator("#inEwbCount").inner_text().strip()
|
||||
if current_count != old_count:
|
||||
break
|
||||
page.wait_for_timeout(500)
|
||||
wait_cycles += 1
|
||||
|
||||
ticket_count_str = ewb_frame.locator("#inEwbCount").inner_text().strip()
|
||||
print(f" ✅ 数据已加载,进站实际票数: [{ticket_count_str}]")
|
||||
|
||||
# 数据分流
|
||||
if (
|
||||
ticket_count_str == "0"
|
||||
or not ticket_count_str.isdigit()
|
||||
or int(ticket_count_str) == 0
|
||||
):
|
||||
print(" >> 票数为 0,正在确认是否为空数据...")
|
||||
empty_flag = ewb_frame.locator("#datagrid1").get_by_text(
|
||||
"没有搜索到符合条件的数据记录"
|
||||
)
|
||||
if empty_flag.is_visible():
|
||||
print(" ⚠️ 确认为空数据,正在关闭当前标签页...")
|
||||
# 即使无数据也关闭已打开的标签页
|
||||
try:
|
||||
page.locator(".mini-tab", has_text="进站交接单查询").locator(
|
||||
".mini-tab-close"
|
||||
).click()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
else:
|
||||
print(" ⚠️ 票数为 0 但未出现空记录提示,页面状态异常,判失败。")
|
||||
try:
|
||||
page.locator(".mini-tab", has_text="进站交接单查询").locator(
|
||||
".mini-tab-close"
|
||||
).click()
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError("票数为 0 但未出现空记录提示,页面状态异常")
|
||||
else:
|
||||
print(" >> 票数校验通过,等待主表格渲染数据行...")
|
||||
ewb_frame.locator(
|
||||
"#datagrid1 .mini-grid-rows-view .mini-grid-row"
|
||||
).first.wait_for(state="visible", timeout=15000)
|
||||
|
||||
# 4. 提取主表记录并循环双击
|
||||
main_rows = ewb_frame.locator("#datagrid1 .mini-grid-rows-view .mini-grid-row")
|
||||
count = main_rows.count()
|
||||
print(f">> 共发现 {count} 个交接单需要导出。")
|
||||
|
||||
for i in range(count):
|
||||
print(f" ⏳ 正在处理第 {i+1}/{count} 个交接单...")
|
||||
row = ewb_frame.locator(
|
||||
"#datagrid1 .mini-grid-rows-view .mini-grid-row"
|
||||
).nth(i)
|
||||
|
||||
raw_text = row.locator("td").nth(3).inner_text()
|
||||
match = re.search(r"\d{18}", raw_text)
|
||||
handover_no = match.group(0) if match else raw_text.strip()
|
||||
print(f" -> 当前交接单号:{handover_no}")
|
||||
|
||||
row.dblclick()
|
||||
|
||||
ewb_frame.locator("#datagrid2").get_by_text("运单号").wait_for(
|
||||
state="visible"
|
||||
)
|
||||
ewb_frame.locator(
|
||||
"#datagrid2 .mini-grid-row", has_text=handover_no
|
||||
).first.wait_for(state="visible")
|
||||
|
||||
# 5. 执行导出流程
|
||||
ewb_frame.locator("#exportExcel").click()
|
||||
|
||||
page.locator(".mini-panel-title", has_text="导出选择列").wait_for(
|
||||
state="visible"
|
||||
)
|
||||
export_frame = page.frame_locator('iframe[src*="download"]')
|
||||
|
||||
export_frame.locator(".mini-button-text", has_text=">>").click()
|
||||
page.wait_for_timeout(300)
|
||||
export_frame.locator(".mini-button-text", has_text="确定").click()
|
||||
|
||||
print(" >> 正在查找【温馨提示】弹窗...")
|
||||
ctx_alert = _wait_and_get_frame(page, "温馨提示")
|
||||
ctx_alert.locator(
|
||||
".mini-messagebox-buttons .mini-button-text", has_text="确定"
|
||||
).click()
|
||||
print(" ✅ 已确认【温馨提示】弹窗。")
|
||||
|
||||
print(" >> 正在等待服务器建立后台离线任务...")
|
||||
# 提示「生成离线导出任务成功」出现在导出列 iframe(/comm/download) 中;
|
||||
# 提交完成后该 iframe 会被站点销毁,此时 wait_for 会抛 "Frame was detached"
|
||||
# ——这恰恰说明提示已随 iframe 消失、任务已建立,属正常,不视为失败。
|
||||
ctx_tips = _wait_and_get_frame(
|
||||
page, "生成离线导出任务成功", timeout_ms=10000
|
||||
)
|
||||
try:
|
||||
ctx_tips.locator(".mini-tips-success").wait_for(
|
||||
state="hidden", timeout=15000
|
||||
)
|
||||
except Exception as e:
|
||||
if "detached" in str(e).lower():
|
||||
print(" ℹ️ 提示框所在 iframe 已随提交关闭,任务已建立。")
|
||||
else:
|
||||
raise
|
||||
print(" ✅ 成功提示框已消失。")
|
||||
|
||||
export_times.append(datetime.now())
|
||||
|
||||
print(" >> 切换回【交接单信息】标签页...")
|
||||
ewb_frame.locator("#ewbsListNo").click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# ====================================================================
|
||||
# 完成所有交接单导出提交后,关闭当前标签页
|
||||
# ====================================================================
|
||||
print(">> 【进站交接单查询】已完成,正在关闭标签页...")
|
||||
try:
|
||||
page.locator(".mini-tab", has_text="进站交接单查询").locator(
|
||||
".mini-tab-close"
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
print(" ✅ 【进站交接单查询】标签页已关闭。")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 关闭【进站交接单查询】标签页时出错: {e}")
|
||||
|
||||
# 交由统一的轮询下载流程处理
|
||||
_zto_poll_and_download_tasks(
|
||||
page,
|
||||
export_times,
|
||||
download_dir,
|
||||
"中通-应到货物数据.xlsx",
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 任务执行过程中发生异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def zto_actual_download(page):
|
||||
"""中通:实到货物数据下载(内部含异常兜底重试,路由层无感)。"""
|
||||
|
||||
return with_retry(
|
||||
"中通",
|
||||
"实到",
|
||||
lambda: zto_actual_download_impl(page),
|
||||
lambda: zto_reset(page),
|
||||
)
|
||||
|
||||
|
||||
def zto_actual_download_impl(page):
|
||||
"""中通:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。"""
|
||||
print("\n▶ 开始执行【中通 - 实到货物数据下载】任务...")
|
||||
|
||||
download_dir = DOWNLOAD_DIR
|
||||
if not os.path.exists(download_dir):
|
||||
os.makedirs(download_dir)
|
||||
|
||||
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
|
||||
_remove_if_exists(os.path.join(download_dir, "中通-实到货物数据.xlsx"))
|
||||
|
||||
export_times = []
|
||||
|
||||
try:
|
||||
# 1. 菜单导航
|
||||
zto_smart_menu_click(page, ["运营管理", "扫描操作与监控", "到件扫描监控"])
|
||||
|
||||
arr_frame = page.frame_locator('iframe[src*="ArriveScan"]')
|
||||
|
||||
print(">> 正在检测主页面 (#daterange)...")
|
||||
arr_frame.locator("#daterange").wait_for(state="attached", timeout=15000)
|
||||
|
||||
# 2. 读取服务端日期偏移(0=今天,1=昨天…),单日:起止同日
|
||||
offset = state_store.get_offset("中通", "actual")
|
||||
|
||||
print(f">> 正在设定查询日期: 偏移 {offset}(0=今天)...")
|
||||
arr_frame.locator("#daterange").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
today_cell = arr_frame.locator("td div.day.real-today").first
|
||||
today_cell.wait_for(state="visible")
|
||||
|
||||
today_time_str = today_cell.get_attribute("time")
|
||||
if today_time_str:
|
||||
today_time = int(today_time_str)
|
||||
target_time = today_time - offset * 86400000
|
||||
target_cell = arr_frame.locator(f"td div.day[time='{target_time}']").first
|
||||
|
||||
# 日期格子用 _dom_click 直接派发事件:Playwright 的 .click() 会先 hover 格子,
|
||||
# 触发“范围长度”提示气泡(.date-range-length-tip)盖住格子,导致点击被判遮挡而超时。
|
||||
if target_cell.is_visible():
|
||||
_dom_click(target_cell)
|
||||
page.wait_for_timeout(300)
|
||||
_dom_click(target_cell)
|
||||
else:
|
||||
_dom_click(today_cell)
|
||||
page.wait_for_timeout(300)
|
||||
_dom_click(today_cell)
|
||||
else:
|
||||
_dom_click(today_cell)
|
||||
page.wait_for_timeout(300)
|
||||
_dom_click(today_cell)
|
||||
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 3. 设定单号类型
|
||||
print(">> 正在设定单号类型为【子单】...")
|
||||
arr_frame.locator('[id="bandEwbType$text"]').click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
arr_frame.locator(".mini-tree-nodeshow").filter(
|
||||
has_text=re.compile(r"^子单$")
|
||||
).locator(".mini-tree-checkbox").click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# 4. 触发查询并判断数据状态
|
||||
print(">> 正在点击【查询】按钮并等待数据响应...")
|
||||
arr_frame.locator("#searchbtn").click()
|
||||
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
loading_mask = arr_frame.locator(".mini-mask-loading", has_text="加载中")
|
||||
if loading_mask.is_visible():
|
||||
print(" ⏳ 检测到数据加载遮罩层,等待系统渲染...")
|
||||
loading_mask.wait_for(state="hidden", timeout=30000)
|
||||
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
empty_flag = arr_frame.locator("#datagrid1").get_by_text(
|
||||
"没有搜索到符合条件的数据记录"
|
||||
)
|
||||
if empty_flag.is_visible():
|
||||
print(" ⚠️ 当前查询范围内没有数据,终止并关闭标签页。")
|
||||
try:
|
||||
page.locator(".mini-tab", has_text="到件扫描监控").locator(
|
||||
".mini-tab-close"
|
||||
).click()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
arr_frame.locator("#page1").wait_for(state="visible", timeout=15000)
|
||||
print(" ✅ 数据已加载。(底部分页控件已就绪)")
|
||||
|
||||
# 5. 执行导出流程
|
||||
arr_frame.locator("#exportExcel").click()
|
||||
|
||||
page.locator(".mini-panel-title", has_text="导出选择列").wait_for(
|
||||
state="visible"
|
||||
)
|
||||
export_frame = page.frame_locator('iframe[src*="download"]')
|
||||
|
||||
export_frame.locator(".mini-button-text", has_text=">>").click()
|
||||
page.wait_for_timeout(300)
|
||||
export_frame.locator(".mini-button-text", has_text="确定").click()
|
||||
|
||||
print(" >> 正在查找【温馨提示】弹窗...")
|
||||
ctx_alert = _wait_and_get_frame(page, "温馨提示")
|
||||
ctx_alert.locator(
|
||||
".mini-messagebox-buttons .mini-button-text", has_text="确定"
|
||||
).click()
|
||||
print(" ✅ 已确认【温馨提示】弹窗。")
|
||||
|
||||
print(" >> 正在等待服务器建立后台离线任务...")
|
||||
# 提示「生成离线导出任务成功」出现在导出列 iframe(/comm/download) 中;
|
||||
# 提交完成后该 iframe 会被站点销毁,此时 wait_for 会抛 "Frame was detached"
|
||||
# ——这恰恰说明提示已随 iframe 消失、任务已建立,属正常,不视为失败。
|
||||
ctx_tips = _wait_and_get_frame(page, "生成离线导出任务成功", timeout_ms=10000)
|
||||
try:
|
||||
ctx_tips.locator(".mini-tips-success").wait_for(
|
||||
state="hidden", timeout=15000
|
||||
)
|
||||
except Exception as e:
|
||||
if "detached" in str(e).lower():
|
||||
print(" ℹ️ 提示框所在 iframe 已随提交关闭,任务已建立。")
|
||||
else:
|
||||
raise
|
||||
print(" ✅ 成功提示框已消失。")
|
||||
|
||||
export_times.append(datetime.now())
|
||||
|
||||
# ====================================================================
|
||||
# 完成实到数据导出提交后,关闭当前标签页
|
||||
# ====================================================================
|
||||
print(">> 【到件扫描监控】已完成,正在关闭标签页...")
|
||||
try:
|
||||
page.locator(".mini-tab", has_text="到件扫描监控").locator(
|
||||
".mini-tab-close"
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
print(" ✅ 【到件扫描监控】标签页已关闭。")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 关闭【到件扫描监控】标签页时出错: {e}")
|
||||
|
||||
# 交由统一的轮询下载流程处理
|
||||
_zto_poll_and_download_tasks(
|
||||
page,
|
||||
export_times,
|
||||
download_dir,
|
||||
"中通-实到货物数据.xlsx",
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 任务执行过程中发生异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _zto_poll_and_download_tasks(page, export_times, download_dir, final_filename):
|
||||
"""中通离线任务的轮询与下载"""
|
||||
print("\n>> 正在前往【导出任务管理】界面...")
|
||||
zto_smart_menu_click(page, ["系统配置", "导出任务管理"])
|
||||
|
||||
taskdone_frame = page.frame_locator('iframe[src*="taskdone"]')
|
||||
taskdone_frame.locator("#taskdoneDatagrid").get_by_text("任务标题").wait_for(
|
||||
state="visible"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
print(">> 列表已加载,开始匹配并检查任务状态...")
|
||||
total_expected = len(export_times)
|
||||
|
||||
poll_deadline = (
|
||||
time.monotonic() + 300
|
||||
) # 5 分钟上限:任务卡死/匹配不上时超时失败,交由上层重置重试
|
||||
last_total_found = -1
|
||||
stall_rounds = 0 # 连续无进展轮数:刷新后 total_found 不增长则累计,超阈值快速失败
|
||||
while True:
|
||||
if time.monotonic() > poll_deadline:
|
||||
raise RuntimeError(
|
||||
"轮询导出任务超时(5 分钟未全部完成/匹配),疑似任务卡死"
|
||||
)
|
||||
task_rows = taskdone_frame.locator(
|
||||
"#taskdoneDatagrid .mini-grid-rows-view .mini-grid-row"
|
||||
)
|
||||
row_count = task_rows.count()
|
||||
|
||||
ready_timestamps = set()
|
||||
processing_timestamps = set()
|
||||
|
||||
# 单账号无并发:仅按提交时间容差(40s)认领本批任务,不再校验任务标题
|
||||
# (站点可能调整标题名,写死标题会导致匹配失败、任务一直查不到)。
|
||||
for i in range(row_count):
|
||||
tds = task_rows.nth(i).locator("td")
|
||||
if tds.count() < 10:
|
||||
continue
|
||||
|
||||
submit_time_str = tds.nth(4).inner_text().strip()
|
||||
status_str = tds.nth(6).inner_text().strip()
|
||||
|
||||
try:
|
||||
row_time = datetime.strptime(submit_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
matched = any(
|
||||
abs((row_time - et).total_seconds()) <= 40 for et in export_times
|
||||
)
|
||||
|
||||
if matched:
|
||||
if status_str == "成功执行":
|
||||
ready_timestamps.add(submit_time_str)
|
||||
else:
|
||||
processing_timestamps.add(submit_time_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total_found = len(ready_timestamps) + len(processing_timestamps)
|
||||
print(
|
||||
f" 📊 状态统计:期望 [{total_expected}],已入表 [{total_found}] (就绪 [{len(ready_timestamps)}],处理中 [{len(processing_timestamps)}])"
|
||||
)
|
||||
|
||||
if total_found < total_expected or len(processing_timestamps) > 0:
|
||||
# 连续无进展即快速失败:避免页面被遮挡/任务卡住时干等到 5 分钟超时
|
||||
if total_found == last_total_found:
|
||||
stall_rounds += 1
|
||||
else:
|
||||
stall_rounds = 0
|
||||
last_total_found = total_found
|
||||
if stall_rounds >= 4:
|
||||
raise RuntimeError(
|
||||
f"连续 {stall_rounds} 轮刷新无进展(仍 {total_found}/{total_expected}),"
|
||||
"疑似页面被遮挡或任务异常,触发重试"
|
||||
)
|
||||
print(" ⏳ 任务尚未齐全或仍在生成,点击查询刷新...")
|
||||
# 查询按钮加短超时;失败则菜单刷新,两者都失败直接报错触发重试
|
||||
try:
|
||||
taskdone_frame.locator(
|
||||
".mini-button-text", has_text="查询"
|
||||
).first.click(timeout=8000)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 查询按钮不可用({e}),改用菜单刷新...")
|
||||
try:
|
||||
page.locator(
|
||||
"li.leaf span.menu-name", has_text="导出任务管理"
|
||||
).click(timeout=8000)
|
||||
except Exception as e2:
|
||||
raise RuntimeError(f"查询与菜单刷新均失败,疑似页面被遮挡: {e2}")
|
||||
|
||||
page.wait_for_timeout(3000)
|
||||
else:
|
||||
target_task_timestamps = list(ready_timestamps)
|
||||
print(">> 所有目标任务已生成,开始下载...")
|
||||
break
|
||||
|
||||
# 8. 下载逻辑
|
||||
downloaded_files = []
|
||||
for time_str in target_task_timestamps:
|
||||
try:
|
||||
target_row = (
|
||||
taskdone_frame.locator(
|
||||
"#taskdoneDatagrid .mini-grid-rows-view .mini-grid-row"
|
||||
)
|
||||
.filter(
|
||||
has=taskdone_frame.locator(
|
||||
f"td:nth-child(5):has-text('{time_str}')"
|
||||
)
|
||||
)
|
||||
.first
|
||||
)
|
||||
print(f" 开始下载任务 [{time_str}] ...")
|
||||
|
||||
checkbox = target_row.locator(".mini-grid-checkbox")
|
||||
if checkbox.is_visible():
|
||||
checkbox.click()
|
||||
print(" -> 已勾选当前记录的 Checkbox")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
with page.expect_download() as download_info:
|
||||
target_row.locator("td").nth(10).locator(".ui-btn-download").click()
|
||||
|
||||
download = download_info.value
|
||||
save_path = os.path.join(download_dir, download.suggested_filename)
|
||||
download.save_as(save_path)
|
||||
downloaded_files.append(save_path)
|
||||
print(f" 已下载: downloads/{download.suggested_filename}")
|
||||
|
||||
if checkbox.is_visible():
|
||||
checkbox.click()
|
||||
print(" -> 已取消勾选,继续下一条")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 下载任务 [{time_str}] 失败: {e}")
|
||||
|
||||
# 汇总校验:下载成功数必须等于本批提交的任务数,否则判失败
|
||||
# (防"部分/全失败却被判成功")
|
||||
if len(downloaded_files) < total_expected:
|
||||
raise RuntimeError(
|
||||
f"仅成功下载 {len(downloaded_files)}/{total_expected} 个任务,数据不完整"
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# 所有目标文件下载完成后,关闭“导出任务管理”标签页
|
||||
# ====================================================================
|
||||
print(">> 【导出任务管理】下载完成,正在关闭标签页...")
|
||||
try:
|
||||
page.locator(".mini-tab", has_text="导出任务管理").locator(
|
||||
".mini-tab-close"
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
print(" ✅ 【导出任务管理】标签页已关闭。")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 关闭【导出任务管理】标签页时出错: {e}")
|
||||
|
||||
# 9. 合并数据
|
||||
if downloaded_files:
|
||||
print("\n>> 正在合并下载的数据...")
|
||||
all_data_frames = []
|
||||
for file_path in downloaded_files:
|
||||
try:
|
||||
df = pd.read_excel(file_path, dtype=str)
|
||||
if not df.empty:
|
||||
all_data_frames.append(df)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if all_data_frames:
|
||||
combined_df = pd.concat(all_data_frames, ignore_index=True)
|
||||
final_output_path = os.path.join(download_dir, final_filename)
|
||||
combined_df.to_excel(final_output_path, index=False)
|
||||
print(f"====================================================")
|
||||
print(f" 合并完成。")
|
||||
print(f" 📁 输出路径: {final_output_path}")
|
||||
print(f"====================================================")
|
||||
|
||||
for file_path in downloaded_files:
|
||||
os.remove(file_path)
|
||||
print(" 临时文件已清理。")
|
||||
Reference in New Issue
Block a user