顺心 Ant Design 日期面板跨月导航:目标日期不在当前月视窗时,读面板头部 .ant-picker-year-btn/.ant-picker-month-btn 得当前年月,按差值点 .ant-picker-header-prev-btn/next-btn 翻到目标月再选格子。应到(车辆点到)与 实到(卸车扫描记录)两处设日期统一改走 _shunxin_pick_date。 同月(offset 当月)行为不变,仅在跨月时多走翻月;与中通 _zto_flip_to_target_month 思路对称,适配 Ant Design 面板。年*12+月 比较天然支持跨年。 Co-Authored-By: Claude <noreply@anthropic.com>
875 lines
37 KiB
Python
875 lines
37 KiB
Python
# 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:异常兜底重置用,也供 runtime 的 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_navigate_picker_to_target(page, date_str, max_flips=24):
|
||
"""顺心 Ant Design 单月面板跨月导航(车辆点到 / 卸车扫描记录 共用同一组件)。
|
||
|
||
读面板头部 .ant-picker-year-btn / .ant-picker-month-btn 得当前显示的年月,按与目标
|
||
年月的差值点 .ant-picker-header-prev-btn(上月)/ .ant-picker-header-next-btn(下月),
|
||
翻到目标月视窗。返回 True 表示当前视窗已是目标月(目标格子随后可见可点)。
|
||
调用前提:开始/结束时间输入已点开,.ant-picker-dropdown:visible 已就绪。
|
||
"""
|
||
try:
|
||
ty, tm = (int(x) for x in date_str.split("-")[:2])
|
||
except Exception:
|
||
return True # 解析不出就不翻,交由后续 cell.click 自行成败
|
||
drop = page.locator(".ant-picker-dropdown:visible")
|
||
for _ in range(max_flips):
|
||
try:
|
||
cur_y = int(
|
||
re.search(
|
||
r"\d+", drop.locator(".ant-picker-year-btn").first.inner_text()
|
||
).group()
|
||
)
|
||
cur_m = int(
|
||
re.search(
|
||
r"\d+", drop.locator(".ant-picker-month-btn").first.inner_text()
|
||
).group()
|
||
)
|
||
except Exception:
|
||
return False
|
||
cur = cur_y * 12 + (cur_m - 1)
|
||
tgt = ty * 12 + (tm - 1)
|
||
if cur == tgt:
|
||
return True
|
||
btn_sel = (
|
||
".ant-picker-header-prev-btn"
|
||
if tgt < cur
|
||
else ".ant-picker-header-next-btn"
|
||
)
|
||
drop.locator(btn_sel).first.click()
|
||
page.wait_for_timeout(300)
|
||
return False
|
||
|
||
|
||
def _shunxin_pick_date(page, date_str):
|
||
"""在已打开的顺心 Ant Design 日期浮层上选中指定日期格子(含跨月翻月)。
|
||
|
||
目标格子不在当前月视窗(跨月)时,先调 _shunxin_navigate_picker_to_target 翻到目标月,
|
||
再点格子;同月则直接点。与中通 _zto_flip_to_target_month 思路对称,适配 Ant Design 面板。
|
||
"""
|
||
cell = page.locator(f".ant-picker-dropdown:visible td[title='{date_str}']").first
|
||
if not cell.is_visible():
|
||
print(f" ℹ️ 目标日期 {date_str} 不在当前月视窗,正在翻月导航 ...")
|
||
if not _shunxin_navigate_picker_to_target(page, date_str):
|
||
raise RuntimeError(f"翻月后仍无法定位目标日期格子 {date_str}")
|
||
cell = page.locator(
|
||
f".ant-picker-dropdown:visible td[title='{date_str}']"
|
||
).first
|
||
cell.click()
|
||
|
||
|
||
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, foreground=True, force=False, date=None):
|
||
"""顺心:应到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。
|
||
|
||
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):
|
||
if foreground:
|
||
pg.bring_to_front()
|
||
print(f"\n========== 顺心 · 账号{idx}({tag})应到数据下载 ==========")
|
||
ok = with_retry(
|
||
f"顺心-{tag}",
|
||
"应到",
|
||
lambda p=pg, t=tag, f=force, d=date: shunxin_expected_download_impl(
|
||
p, out_tag=t, force=f, date=d
|
||
),
|
||
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="", force=False, date=None):
|
||
"""顺心:应到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。
|
||
|
||
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()
|
||
if date:
|
||
target = datetime.strptime(date, "%Y-%m-%d")
|
||
else:
|
||
target = today - timedelta(days=offset)
|
||
target_str = target.strftime("%Y-%m-%d")
|
||
start_date_str = target_str
|
||
today_str = target_str
|
||
src = f"指定 {date}" if date else f"偏移 {offset},0=今天"
|
||
print(f">> 正在设置查询日期: [{target_str}]({src})...")
|
||
|
||
# 分两步精准呼出和点击时间控件
|
||
print(" >> 设置起始时间...")
|
||
page.get_by_placeholder("开始时间").click()
|
||
page.wait_for_timeout(500)
|
||
_shunxin_pick_date(page, start_date_str)
|
||
page.wait_for_timeout(300)
|
||
|
||
print(" >> 设置截止时间...")
|
||
page.get_by_placeholder("结束时间").click()
|
||
page.wait_for_timeout(500)
|
||
_shunxin_pick_date(page, today_str)
|
||
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} 个班次需要导出。")
|
||
|
||
# 【去重】加载本站已落库交接单号;force=True 或查询失败时 existing=空集(不去重)。
|
||
# 两账号共享同一集合(班次号/交接单号跨归属地不重叠)。
|
||
if force:
|
||
existing = set()
|
||
print(">> [去重] 强制重下,跳过去重。")
|
||
else:
|
||
try:
|
||
from inbound_verify import store
|
||
|
||
existing = store.get_existing_handover_nos("顺心")
|
||
except Exception as _e:
|
||
existing = set()
|
||
print(f">> [去重] 加载已落库交接单号失败,本次不去重: {_e}")
|
||
|
||
for i in range(count):
|
||
print(f" ⏳ 正在处理第 {i+1}/{count} 个班次...")
|
||
|
||
waybill_btns.nth(i).click()
|
||
page.locator("label[title='运单查询']").wait_for(state="visible")
|
||
|
||
# 【方式1】运单列表界面已加载,读交接单号(RTS 开头)→ 已落库则退回列表跳过。
|
||
# 交接单号格式 RTS\d{3}WJ\d+(如 RTS023WJ374837),用 [A-Z0-9]+ 连续匹配整段。
|
||
# 读不到(DOM 变动/未渲染)则 handover_no 为空 → 不跳过(安全降级,继续导出)。
|
||
handover_no = ""
|
||
try:
|
||
_txt = page.locator("text=/RTS\\d+/").first.inner_text(timeout=3000)
|
||
_m = re.search(r"RTS[A-Z0-9]+", _txt)
|
||
if _m:
|
||
handover_no = _m.group(0)
|
||
except Exception:
|
||
pass
|
||
print(f" -> 运单列表交接单号:{handover_no or '(未读到,不去重)'}")
|
||
if handover_no and handover_no in existing:
|
||
print(f" ⏭️ 交接单号 {handover_no} 已落库,跳过提交导出。")
|
||
page.get_by_role("tab", name="车辆点到").click()
|
||
page.wait_for_timeout(500)
|
||
continue
|
||
|
||
# 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, "车辆点到")
|
||
|
||
# 【去重兜底】全部已落库/无数据 → 无导出任务,标签页已关,跳过下载轮询
|
||
if not export_times:
|
||
print(">> 本次无新班次需导出(全部已落库或无数据),结束。")
|
||
return True
|
||
|
||
# 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, foreground=True, force=False, date=None):
|
||
"""顺心:实到货物数据下载(双账号/双归属地,内部含异常兜底重试与数据融合)。
|
||
|
||
与 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):
|
||
if foreground:
|
||
pg.bring_to_front()
|
||
print(f"\n========== 顺心 · 账号{idx}({tag})实到数据下载 ==========")
|
||
ok = with_retry(
|
||
f"顺心-{tag}",
|
||
"实到",
|
||
lambda p=pg, t=tag, d=date: shunxin_actual_download_impl(
|
||
p, out_tag=t, date=d
|
||
),
|
||
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="", date=None):
|
||
"""顺心:实到货物数据下载(单次执行,无重试;供自动化测试探测原始结果用)。
|
||
|
||
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()
|
||
if date:
|
||
target = datetime.strptime(date, "%Y-%m-%d")
|
||
else:
|
||
target = today - timedelta(days=offset)
|
||
target_str = target.strftime("%Y-%m-%d")
|
||
start_date_str = target_str
|
||
today_str = target_str
|
||
src = f"指定 {date}" if date else f"偏移 {offset},0=今天"
|
||
print(f">> 正在设置查询日期: [{target_str}]({src})...")
|
||
|
||
# 分两步精准呼出和点击时间控件
|
||
print(" >> 设置起始时间...")
|
||
page.get_by_placeholder("开始时间").click()
|
||
page.wait_for_timeout(500)
|
||
_shunxin_pick_date(page, start_date_str)
|
||
page.wait_for_timeout(300)
|
||
|
||
print(" >> 设置截止时间...")
|
||
page.get_by_placeholder("结束时间").click()
|
||
page.wait_for_timeout(500)
|
||
_shunxin_pick_date(page, today_str)
|
||
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
|