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:
348
inbound_verify/cli/router.py
Normal file
348
inbound_verify/cli/router.py
Normal file
@@ -0,0 +1,348 @@
|
||||
# main_router.py
|
||||
#
|
||||
# 交互菜单模式入口(调试 / 人工操作)。核心 Playwright 管理、任务派发、心跳
|
||||
# 已抽到 runtime.py 共享;本文件只保留交互菜单与自动化测试。
|
||||
# 服务模式(常驻 + FastAPI 接收指令)见 server.py。
|
||||
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
import yaml
|
||||
|
||||
from inbound_verify.paths import CONFIG_PATH
|
||||
from inbound_verify.runtime import (
|
||||
APP_SITES,
|
||||
HEARTBEAT_INTERVAL,
|
||||
dispatch_task,
|
||||
launch_and_prepare,
|
||||
run_heartbeat,
|
||||
)
|
||||
|
||||
from inbound_verify import state_store
|
||||
|
||||
# 各站点模块(自动化测试 + 比对用;任务派发在 runtime)
|
||||
from inbound_verify.sites import shunxin, baishi, zto, yunda, anneng
|
||||
from inbound_verify import expected_undelivered
|
||||
|
||||
|
||||
def run_undelivered_compare():
|
||||
"""应到未到比对(全站点):调用 expected_undelivered,读 downloads/ 下的应到/实到
|
||||
数据,生成 output/应到未到数据.xlsx(汇总报表 + 各站明细)。"""
|
||||
print("\n▶ 开始执行【应到未到比对(全站点)】任务 ...")
|
||||
expected_undelivered.main()
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 自动化测试入口
|
||||
# ====================================================================
|
||||
# 每个(非百世)站点的交叉测试序列:覆盖两种下载流程之间的全部 4 种相邻转换,
|
||||
# 用于验证无论上一个流程把页面留在什么状态,下一个流程都能正常运行:
|
||||
# 应到->实到、实到->应到、应到->应到、实到->实到
|
||||
CROSS_TEST_SEQUENCE = ["expected", "actual", "expected", "expected", "actual", "actual"]
|
||||
|
||||
|
||||
def run_automation_test(pages_map):
|
||||
"""自动化测试入口:按交叉序列逐个跑通各站点的下载流程,结束后打印统计报告。
|
||||
|
||||
判定规则:流程函数返回 False 或抛出异常记为 FAIL,其余记为 PASS。
|
||||
"""
|
||||
# 站点 -> {流程键: (中文名, 流程函数)};百世为单流程,单独处理
|
||||
# 自动化测试刻意走各站点的 _impl(单次执行、无兜底重试),以便探测原始失败、
|
||||
# 不被模块内部"失败→重置→重试"机制掩盖。
|
||||
flow_table = {
|
||||
"顺心": {
|
||||
"expected": ("应到", shunxin.shunxin_expected_download_impl),
|
||||
"actual": ("实到", shunxin.shunxin_actual_download_impl),
|
||||
},
|
||||
"中通": {
|
||||
"expected": ("应到", zto.zto_expected_download_impl),
|
||||
"actual": ("实到", zto.zto_actual_download_impl),
|
||||
},
|
||||
"韵达": {
|
||||
"expected": ("应到", yunda.yunda_expected_download_impl),
|
||||
"actual": ("实到", yunda.yunda_actual_download_impl),
|
||||
},
|
||||
"安能": {
|
||||
"expected": ("应到", anneng.anneng_expected_download_impl),
|
||||
"actual": ("实到", anneng.anneng_actual_download_impl),
|
||||
},
|
||||
}
|
||||
|
||||
# 构建测试计划:[(站点, 流程中文名, 流程函数, 绑定page, 归属标签), ...]
|
||||
# 顺心为双账号:两个 page 各自读归属地后跑一遍交叉序列;其余站点单 page。
|
||||
plan = []
|
||||
for site_name, flows in flow_table.items():
|
||||
if site_name == "顺心":
|
||||
if "顺心" not in pages_map:
|
||||
continue
|
||||
for sx_idx, sx_page in enumerate(pages_map["顺心"], start=1):
|
||||
try:
|
||||
tag = shunxin.shunxin_belonging(sx_page)
|
||||
except Exception:
|
||||
tag = f"账号{sx_idx}" # 读不到归属地时用序号占位,不阻断测试
|
||||
for flow_key in CROSS_TEST_SEQUENCE:
|
||||
label, func = flows[flow_key]
|
||||
plan.append((f"顺心·{tag}", label, func, sx_page, tag))
|
||||
continue
|
||||
if site_name not in pages_map:
|
||||
continue
|
||||
bound_page = pages_map[site_name]
|
||||
for flow_key in CROSS_TEST_SEQUENCE:
|
||||
label, func = flows[flow_key]
|
||||
plan.append((site_name, label, func, bound_page, ""))
|
||||
# 百世:单流程,跑一次即可
|
||||
if "百世" in pages_map:
|
||||
plan.append(
|
||||
(
|
||||
"百世",
|
||||
"应到未到",
|
||||
baishi.baishi_download_undelivered_data_impl,
|
||||
pages_map["百世"],
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
if not plan:
|
||||
print("\n⚠️ 当前没有已就绪的站点,无法执行自动化测试。")
|
||||
return
|
||||
|
||||
total = len(plan)
|
||||
print("\n====================================================")
|
||||
print(f"自动化测试开始,共 {total} 个步骤。")
|
||||
print("(双流程站点按应到/实到交叉序列执行,覆盖全部相邻转换)")
|
||||
print("====================================================")
|
||||
|
||||
results = [] # [(站点, 流程, 状态, 耗时秒, 错误信息)]
|
||||
for idx, (site_name, label, func, bound_page, out_tag) in enumerate(plan, start=1):
|
||||
print("\n----------------------------------------------------")
|
||||
print(f"[步骤 {idx}/{total}] 站点【{site_name}】流程【{label}】")
|
||||
print("----------------------------------------------------")
|
||||
start = time.time()
|
||||
status = "PASS"
|
||||
err = ""
|
||||
try:
|
||||
if site_name in APP_SITES:
|
||||
# 安能:Electron 应用,无 Playwright page,函数不收 page 参数
|
||||
ret = func()
|
||||
else:
|
||||
bound_page.bring_to_front()
|
||||
# 顺心 _impl 带 out_tag(归属地);其余站点 _impl 仅收 page
|
||||
ret = func(bound_page, out_tag=out_tag) if out_tag else func(bound_page)
|
||||
if ret is False:
|
||||
status = "FAIL"
|
||||
err = "流程返回失败状态"
|
||||
except Exception as e:
|
||||
status = "FAIL"
|
||||
err = str(e)
|
||||
elapsed = time.time() - start
|
||||
results.append((site_name, label, status, elapsed, err))
|
||||
print(f">> 步骤结果: {status} (耗时 {elapsed:.1f}s)")
|
||||
|
||||
_print_test_report(results)
|
||||
|
||||
|
||||
def _print_test_report(results):
|
||||
"""打印自动化测试统计报告。"""
|
||||
passed = sum(1 for r in results if r[2] == "PASS")
|
||||
failed = len(results) - passed
|
||||
|
||||
print("\n====================================================")
|
||||
print("自动化测试统计报告")
|
||||
print("====================================================")
|
||||
for i, (site_name, label, status, elapsed, err) in enumerate(results, start=1):
|
||||
mark = "✅" if status == "PASS" else "❌"
|
||||
print(
|
||||
f" {i:>2}. {mark} {status} {site_name} - {label} (耗时 {elapsed:.1f}s)"
|
||||
)
|
||||
if err:
|
||||
note = err if len(err) <= 60 else err[:57] + "..."
|
||||
print(f" 说明: {note}")
|
||||
print("----------------------------------------------------")
|
||||
print(f" 合计 {len(results)} 步:通过 {passed},失败 {failed}")
|
||||
if failed == 0:
|
||||
print(" ✅ 全部流程跑通。")
|
||||
else:
|
||||
print(" ❌ 存在失败流程,请结合上方说明与运行日志排查。")
|
||||
print("====================================================")
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 交互菜单模式
|
||||
# ====================================================================
|
||||
|
||||
|
||||
def _read_debug_config():
|
||||
"""读 config.yaml 的 debug 段,返回 (debug_mode, debug_target)。"""
|
||||
debug_mode = False
|
||||
debug_target = ""
|
||||
try:
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
debug_mode = (config.get("debug", {}) or {}).get("enabled", False)
|
||||
debug_target = (config.get("debug", {}) or {}).get("target_site", "")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取 config.yaml 异常,将使用全量模式启动: {e}")
|
||||
return debug_mode, debug_target
|
||||
|
||||
|
||||
# 菜单编号 → 任务规格(dispatch_task 消费)
|
||||
CHOICE_TO_TASK = {
|
||||
"1": {"site": "顺心", "kind": "expected"},
|
||||
"2": {"site": "顺心", "kind": "actual"},
|
||||
"3": {"site": "百世", "kind": "undelivered"},
|
||||
"4": {"site": "中通", "kind": "expected"},
|
||||
"5": {"site": "中通", "kind": "actual"},
|
||||
"6": {"site": "韵达", "kind": "expected"},
|
||||
"7": {"site": "韵达", "kind": "actual"},
|
||||
"10": {"site": "安能", "kind": "expected"},
|
||||
"11": {"site": "安能", "kind": "actual"},
|
||||
"9": {"site": "__compare__", "kind": "compare"},
|
||||
}
|
||||
|
||||
|
||||
def _interactive_menu_loop(ctx):
|
||||
"""交互菜单循环:input 后台线程 + _await_command + dispatch_task + 心跳 + 状态盘。
|
||||
|
||||
所有 page 操作经 runtime(主线程),满足 Playwright sync 线程安全。
|
||||
"""
|
||||
pages_map = ctx.pages_map
|
||||
sites_to_watch = ctx.sites_to_watch
|
||||
|
||||
def is_site_ready(site_name):
|
||||
if site_name not in pages_map:
|
||||
print(f"\n🚫 站点 [{site_name}] 未加载(当前为调试模式),已跳过。")
|
||||
return False
|
||||
return True
|
||||
|
||||
last_heartbeat = 0.0
|
||||
command_queue = queue.Queue()
|
||||
|
||||
def _await_command():
|
||||
nonlocal last_heartbeat
|
||||
while True:
|
||||
try:
|
||||
return command_queue.get(timeout=0.5)
|
||||
except queue.Empty:
|
||||
if time.monotonic() - last_heartbeat >= HEARTBEAT_INTERVAL:
|
||||
run_heartbeat(ctx)
|
||||
last_heartbeat = time.monotonic()
|
||||
|
||||
def _print_status_board():
|
||||
print("\n====================== 站点状态盘 ======================")
|
||||
status = state_store.get_all_status()
|
||||
if not status:
|
||||
print(" (暂无状态记录)")
|
||||
print("======================================================")
|
||||
return
|
||||
login_text = {
|
||||
state_store.LOGIN_IN: "✅ 已登录",
|
||||
state_store.LOGIN_OUT: "❌ 未登录",
|
||||
state_store.LOGIN_UNKNOWN: "❔ 未知",
|
||||
}
|
||||
for site_name in sites_to_watch:
|
||||
s = status.get(site_name)
|
||||
if not s:
|
||||
continue
|
||||
login_mark = login_text.get(s["login_state"], s["login_state"])
|
||||
exp = f"应到{'✅' if s['expected_ready'] else '—'} {s['expected_generated_at'] or '无'}"
|
||||
act = f"实到{'✅' if s['actual_ready'] else '—'} {s['actual_generated_at'] or '无'}"
|
||||
print(
|
||||
f" 【{site_name}】 {login_mark} | {exp} | {act} "
|
||||
f"| 探测于 {s['login_checked_at']}"
|
||||
)
|
||||
print("======================================================")
|
||||
|
||||
def _input_loop():
|
||||
while True:
|
||||
try:
|
||||
command_queue.put(input())
|
||||
except EOFError:
|
||||
return
|
||||
|
||||
threading.Thread(target=_input_loop, daemon=True).start()
|
||||
|
||||
while True:
|
||||
print("\n====================================================")
|
||||
print(" 物流数据下载主菜单 ")
|
||||
if ctx.debug_mode:
|
||||
print(f" [ 调试模式,仅加载: {ctx.debug_target} ]")
|
||||
print("====================================================")
|
||||
print(" 模块一:【顺心】数据处理流")
|
||||
print(" [1] 执行 - 应到货物数据下载")
|
||||
print(" [2] 执行 - 实到货物数据下载")
|
||||
print("-" * 52)
|
||||
print(" 模块二:【百世】数据处理流")
|
||||
print(" [3] 执行 - 一键提取应到未到异常数据")
|
||||
print("-" * 52)
|
||||
print(" 模块三:【中通】数据处理流")
|
||||
print(" [4] 执行 - 应到货物数据下载")
|
||||
print(" [5] 执行 - 实到货物数据下载")
|
||||
print("-" * 52)
|
||||
print(" 模块四:【韵达】数据处理流")
|
||||
print(" [6] 执行 - 应到货物数据下载")
|
||||
print(" [7] 执行 - 实到货物数据下载")
|
||||
print("-" * 52)
|
||||
print(" 模块五:【安能】数据处理流(Electron 应用)")
|
||||
print(" [10] 执行 - 应到货物数据下载(运单信息)")
|
||||
print(" [11] 执行 - 实到货物数据下载(网点到件扫描)")
|
||||
print("-" * 52)
|
||||
print(" 自动化测试")
|
||||
print(" [8] 执行 - 全站点下载流程自动化测试 (交叉跑通校验)")
|
||||
print("-" * 52)
|
||||
print(" 全局离线数据处理")
|
||||
print(" [9] 执行 - 应到未到比对(全站点汇总,输出 output/应到未到数据.xlsx)")
|
||||
print("-" * 52)
|
||||
print(" 站点状态")
|
||||
print(" [12] 查看 - 各站登录态 / 数据就绪状态")
|
||||
print("-" * 52)
|
||||
print(" [0] 退出系统")
|
||||
print("====================================================")
|
||||
|
||||
print("请输入任务编号并回车: ", end="", flush=True)
|
||||
choice = _await_command()
|
||||
|
||||
try:
|
||||
if choice in CHOICE_TO_TASK:
|
||||
task = CHOICE_TO_TASK[choice]
|
||||
site = task["site"]
|
||||
if site == "__compare__" or is_site_ready(site):
|
||||
status, error = dispatch_task(ctx, task)
|
||||
if status == state_store.TASK_FAILED:
|
||||
print(f"❌ 任务失败: {error}")
|
||||
elif choice == "8":
|
||||
run_automation_test(pages_map)
|
||||
elif choice == "12":
|
||||
_print_status_board()
|
||||
elif choice == "0":
|
||||
break
|
||||
elif choice.strip() != "":
|
||||
print("\n⚠️ 无效输入,请查证后回车。")
|
||||
except Exception as e:
|
||||
print(f"❌ 任务调度异常: {e}")
|
||||
|
||||
|
||||
def run_multi_site_daemon():
|
||||
"""多站点自动化主控流程(交互菜单模式)。
|
||||
|
||||
启动 → 等待各站登录就绪 → 进入交互菜单;退出时关闭浏览器与安能。
|
||||
"""
|
||||
debug_mode, debug_target = _read_debug_config()
|
||||
ctx = launch_and_prepare(debug_mode, debug_target)
|
||||
try:
|
||||
_interactive_menu_loop(ctx)
|
||||
finally:
|
||||
print("\n正在关闭浏览器并退出...")
|
||||
ctx.stop()
|
||||
print("程序已退出。")
|
||||
|
||||
|
||||
def main():
|
||||
"""交互菜单模式入口。"""
|
||||
run_multi_site_daemon()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
300
inbound_verify/cli/server.py
Normal file
300
inbound_verify/cli/server.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# server.py
|
||||
#
|
||||
# 服务模式入口:FastAPI(主线程,uvicorn/asyncio)+ Playwright worker(独立线程)。
|
||||
# 客户端经 HTTP 触发任务、查状态、下载数据;worker 串行执行任务并跑心跳。
|
||||
#
|
||||
# 线程模型(关键):
|
||||
# - 主线程:uvicorn + FastAPI。路由【绝不】访问 Playwright 对象,只经
|
||||
# task_queue(投递任务)+ state_store(查状态/任务)+ 文件系统(下载数据)。
|
||||
# - worker 线程:runtime.launch_and_prepare(sync_playwright 在此)+ 任务循环,
|
||||
# 独占所有 page 操作;与主线程仅经 Queue + SQLite 通信。
|
||||
# 违反"路由不碰 Playwright"会崩(sync 对象跨线程访问)。
|
||||
#
|
||||
# 运行:python server.py (默认监听 0.0.0.0:8000)
|
||||
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, Optional
|
||||
|
||||
import uvicorn
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from inbound_verify.paths import DOWNLOAD_DIR, OUTPUT_DIR
|
||||
from inbound_verify import state_store
|
||||
from inbound_verify.runtime import (
|
||||
HEARTBEAT_INTERVAL,
|
||||
TASK_HANDLERS,
|
||||
dispatch_task,
|
||||
launch_and_prepare,
|
||||
run_heartbeat,
|
||||
)
|
||||
|
||||
# 全部站点;百世固定下载当天,不可配置偏移
|
||||
ALL_SITES = ["顺心", "百世", "中通", "韵达", "安能"]
|
||||
CONFIGURABLE_SITES = {"顺心", "中通", "韵达", "安能"}
|
||||
|
||||
# 任务队列:元素 (task_id, task_spec)。主线程投递,worker 消费。
|
||||
task_queue: "queue.Queue" = queue.Queue()
|
||||
# worker 运行状态(主线程只读,worker 写)
|
||||
worker_state = {
|
||||
"ctx": None,
|
||||
"stop": False,
|
||||
"thread": None,
|
||||
"ready": False, # launch_and_prepare 完成(各站就绪,可接任务)
|
||||
"error": None, # worker 启动失败原因
|
||||
}
|
||||
|
||||
# 每日定时下载调度器(进程内;job 只往 task_queue 投任务,不碰 Playwright)
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
|
||||
|
||||
def _worker_loop():
|
||||
"""worker 线程:启动 Playwright + 等就绪 + 任务循环(执行任务 + 心跳)。"""
|
||||
try:
|
||||
ctx = launch_and_prepare()
|
||||
worker_state["ctx"] = ctx
|
||||
worker_state["ready"] = True
|
||||
# 【P1-2 重启自愈】worker 就绪后清理上轮遗留的 pending/running 僵尸任务
|
||||
cleaned = state_store.fail_stale_tasks()
|
||||
if cleaned:
|
||||
print(
|
||||
f">> [worker] 自愈:清理 {cleaned} 条遗留任务(pending/running → failed)"
|
||||
)
|
||||
print(">> [worker] 各站就绪,开始接收任务 ...")
|
||||
except Exception as e:
|
||||
worker_state["error"] = str(e)
|
||||
print(f"❌ [worker] 启动失败: {e}")
|
||||
return
|
||||
|
||||
last_heartbeat = 0.0
|
||||
while not worker_state["stop"]:
|
||||
try:
|
||||
task_id, task_spec = task_queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
# 空闲时跑心跳
|
||||
if time.monotonic() - last_heartbeat >= HEARTBEAT_INTERVAL:
|
||||
run_heartbeat(ctx)
|
||||
last_heartbeat = time.monotonic()
|
||||
continue
|
||||
|
||||
state_store.update_task(task_id, state_store.TASK_RUNNING)
|
||||
print(f">> [worker] 执行任务 #{task_id}: {task_spec}")
|
||||
status, error = dispatch_task(ctx, task_spec)
|
||||
state_store.update_task(task_id, status, error)
|
||||
print(f">> [worker] 任务 #{task_id} 完成: {status} {error or ''}")
|
||||
|
||||
try:
|
||||
ctx.stop()
|
||||
except Exception:
|
||||
pass
|
||||
worker_state["ready"] = False
|
||||
print(">> [worker] 已退出。")
|
||||
|
||||
|
||||
def _enqueue_undelivered(site):
|
||||
"""定时 job:把该站 undelivered 任务投到队列(worker 串行处理;本线程不碰 Playwright)。"""
|
||||
try:
|
||||
tid = state_store.create_task(site, "undelivered")
|
||||
task_queue.put((tid, {"site": site, "kind": "undelivered"}))
|
||||
print(f">> [定时] 投递 {site}/undelivered 任务 #{tid}")
|
||||
except Exception as e:
|
||||
print(f">> [定时] 投递 {site} 失败: {e}")
|
||||
|
||||
|
||||
def _reschedule_site(site):
|
||||
"""按持久化配置(重新)注册或取消该站的每日定时 job。"""
|
||||
job_id = f"site_{site}"
|
||||
try:
|
||||
scheduler.remove_job(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
cfg = state_store.get_all_config().get(site)
|
||||
if not cfg or not cfg.get("schedule_enabled") or not cfg.get("schedule_time"):
|
||||
return
|
||||
try:
|
||||
hh, mm = cfg["schedule_time"].split(":")
|
||||
scheduler.add_job(
|
||||
_enqueue_undelivered,
|
||||
CronTrigger(hour=int(hh), minute=int(mm)),
|
||||
args=[site],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
print(f">> [定时] 已注册 {site} 每日 {cfg['schedule_time']} 下载")
|
||||
except Exception as e:
|
||||
print(f">> [定时] 注册 {site} 失败: {e}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app):
|
||||
"""服务启停:起 worker 线程 / 通知 worker 停。"""
|
||||
state_store.init_db() # 先建表/迁移状态库,确保早于 worker 就绪的 /api/status 可用
|
||||
for site in ALL_SITES: # 按持久化配置注册各站每日定时 job
|
||||
_reschedule_site(site)
|
||||
scheduler.start()
|
||||
print(">> [定时] 调度器已启动")
|
||||
t = threading.Thread(target=_worker_loop, daemon=True)
|
||||
worker_state["thread"] = t
|
||||
t.start()
|
||||
yield
|
||||
scheduler.shutdown(wait=False)
|
||||
print(">> [定时] 调度器已停止")
|
||||
worker_state["stop"] = True
|
||||
t.join(timeout=10)
|
||||
|
||||
|
||||
app = FastAPI(title="InboundVerify 服务端", lifespan=lifespan)
|
||||
|
||||
|
||||
class TaskRequest(BaseModel):
|
||||
site: str
|
||||
kind: str
|
||||
|
||||
|
||||
@app.post("/tasks")
|
||||
def create_task(req: TaskRequest):
|
||||
"""提交任务 {site, kind} → 入队,返回 task_id。"""
|
||||
# 【P0】后端未就绪时直接拒绝,避免任务在 worker 启动前入队卡死
|
||||
if not worker_state["ready"]:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="后端尚未就绪,请等待各站点登录完成后再操作"
|
||||
)
|
||||
if (req.site, req.kind) not in TASK_HANDLERS:
|
||||
raise HTTPException(status_code=400, detail=f"无效任务: {req.site}/{req.kind}")
|
||||
task_id = state_store.create_task(req.site, req.kind)
|
||||
task_queue.put((task_id, {"site": req.site, "kind": req.kind}))
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@app.get("/tasks/{task_id}")
|
||||
def get_task(task_id: int):
|
||||
t = state_store.get_task(task_id)
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return t
|
||||
|
||||
|
||||
@app.get("/tasks")
|
||||
def list_tasks(limit: int = 20):
|
||||
return state_store.list_tasks(limit)
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
def get_status():
|
||||
"""各站登录态 + 数据态(前端状态盘用),另含 worker 就绪状态。"""
|
||||
return {
|
||||
"worker_ready": worker_state["ready"],
|
||||
"worker_error": worker_state["error"],
|
||||
"sites": state_store.get_all_status(),
|
||||
}
|
||||
|
||||
|
||||
class ConfigRequest(BaseModel):
|
||||
expected_offset: Optional[int] = None
|
||||
actual_offset: Optional[int] = None
|
||||
schedule_enabled: Optional[bool] = None
|
||||
schedule_time: Optional[str] = None
|
||||
settings: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
def _default_cfg():
|
||||
return {
|
||||
"expected_offset": 0,
|
||||
"actual_offset": 0,
|
||||
"schedule_enabled": False,
|
||||
"schedule_time": "",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/config")
|
||||
def get_config():
|
||||
"""各站配置:应到/实到偏移 + 每日定时(百世偏移恒 0)。"""
|
||||
cfg = state_store.get_all_config()
|
||||
return {site: cfg.get(site, _default_cfg()) for site in ALL_SITES}
|
||||
|
||||
|
||||
@app.put("/config/{site}")
|
||||
def set_config(site: str, req: ConfigRequest):
|
||||
if site not in ALL_SITES:
|
||||
raise HTTPException(status_code=400, detail=f"未知站点: {site}")
|
||||
cur = state_store.get_all_config().get(site, _default_cfg())
|
||||
# 应到/实到偏移(百世锁定当天)
|
||||
for kind, val in (
|
||||
("expected", req.expected_offset),
|
||||
("actual", req.actual_offset),
|
||||
):
|
||||
if val is not None:
|
||||
if site == "百世":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="百世固定下载当天,不可配置偏移"
|
||||
)
|
||||
state_store.set_offset(site, kind, val)
|
||||
# 每日定时(所有站可配,含百世)
|
||||
if req.schedule_enabled is not None or req.schedule_time is not None:
|
||||
enabled = (
|
||||
req.schedule_enabled
|
||||
if req.schedule_enabled is not None
|
||||
else cur["schedule_enabled"]
|
||||
)
|
||||
time_str = (
|
||||
req.schedule_time if req.schedule_time is not None else cur["schedule_time"]
|
||||
)
|
||||
state_store.set_schedule(site, enabled, time_str)
|
||||
_reschedule_site(site)
|
||||
# 站点专属配置(密码/账号/路径…)
|
||||
if req.settings:
|
||||
for k, v in req.settings.items():
|
||||
state_store.set_setting(site, k, v)
|
||||
cfg = state_store.get_all_config().get(site, _default_cfg())
|
||||
cfg["settings"] = state_store.get_site_settings(site)
|
||||
return cfg
|
||||
|
||||
|
||||
@app.get("/config/{site}/settings")
|
||||
def get_site_settings_api(site: str):
|
||||
"""单站专属配置(密码/账号/路径…;不进 5s 轮询)。"""
|
||||
if site not in ALL_SITES:
|
||||
raise HTTPException(status_code=400, detail=f"未知站点: {site}")
|
||||
return state_store.get_site_settings(site)
|
||||
|
||||
|
||||
REPORT_FILE = "应到未到数据.xlsx"
|
||||
|
||||
|
||||
@app.get("/report")
|
||||
def download_report():
|
||||
"""下载 output/应到未到数据.xlsx(跑比对生成;未生成 404)。"""
|
||||
path = os.path.join(OUTPUT_DIR, REPORT_FILE)
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="报告尚未生成,请先跑比对")
|
||||
return FileResponse(path, filename=REPORT_FILE)
|
||||
|
||||
|
||||
@app.get("/data/{filename}")
|
||||
def download_data(filename: str):
|
||||
"""下载 downloads/ 下的数据文件(防路径穿越)。"""
|
||||
if not filename or "/" in filename or "\\" in filename or ".." in filename:
|
||||
raise HTTPException(status_code=400, detail="非法文件名")
|
||||
path = os.path.join(DOWNLOAD_DIR, filename)
|
||||
# 双重校验:解析后绝对路径仍在 DOWNLOAD_DIR 内
|
||||
if not os.path.abspath(path).startswith(os.path.abspath(DOWNLOAD_DIR) + os.sep):
|
||||
raise HTTPException(status_code=400, detail="非法路径")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
return FileResponse(path, filename=filename)
|
||||
|
||||
|
||||
def main():
|
||||
"""服务模式入口。传字符串导入路径(规范写法;不开 reload/workers 时进程内 import,行为等价)。"""
|
||||
uvicorn.run("inbound_verify.cli.server:app", host="0.0.0.0", port=8000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
692
inbound_verify/expected_undelivered.py
Normal file
692
inbound_verify/expected_undelivered.py
Normal file
@@ -0,0 +1,692 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
应到未到数据比对(重构版)
|
||||
|
||||
目的:对中通 / 顺心 / 韵达 / 安能 四个站点,比对各自的「应到货物数据」与
|
||||
「实到货物数据」,找出应到却未到的运单,汇总到 output/应到未到数据.xlsx。
|
||||
(百世为站点直供未到明细,不参与 4 站比对;其应到/实到基数取自「扫描综合查询」应扫/已扫,见 process_baishi。)
|
||||
|
||||
核心口径(四站点统一,重构后):
|
||||
1. 应到件数 = 应到表「交接件数」之和(按运单号去重 keep-first)。
|
||||
—— 录单件数 只是该单号的总录单量,实际只有“交接件数”会真正到站,
|
||||
故应到必须按交接件数统计,不能用录单件数。
|
||||
2. 实到件数 = 实到表「单号」的去重数量(直接数,不再由“应到−未到”倒推)。
|
||||
—— 每扫描一件,系统生成该件的单号(一个单号=一件);后缀含总数/顺序号,
|
||||
但计数时无视后缀,仅对单号去重即得实到件数。
|
||||
3. 未到件数 = max(0, 应到件数 − 实到件数)。
|
||||
4. 未到明细(downloads/<站>-未到数据.xlsx)仅列“短少”运单(实到 < 应到),
|
||||
每行:交接单号 | 运单号 | 总件数(=应到/交接件数) | 已到单号1 | 已到单号2 | …。
|
||||
—— 实到扫描的顺序号是乱序的,缺件的“顺序号”无法反推,故不再编造子单号;
|
||||
改为把该运单“实际扫到的单号”依次填到后续单元格,便于核对到了哪几件。
|
||||
|
||||
各站实到单号列 / 运单基号:
|
||||
中通:单号列=运单号(复合串 H+运单号+总数+顺序),基号=v[:-8]
|
||||
顺心:单号列=子单号,基号=运单号
|
||||
韵达:单号列=子单号,基号=主单号
|
||||
安能:单号列=扫描单号,基号=所属单号
|
||||
|
||||
目录约定:
|
||||
源数据放在脚本同级目录的 downloads/ 下;结果写入 output/(不存在则自动创建)。
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.chart import BarChart, Reference
|
||||
from openpyxl.worksheet.page import PageMargins
|
||||
from openpyxl.worksheet.properties import PageSetupProperties
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
DOWNLOADS = os.path.join(BASE, "downloads")
|
||||
OUTPUT = os.path.join(BASE, "output")
|
||||
OUTFILE = os.path.join(OUTPUT, "应到未到数据.xlsx")
|
||||
|
||||
# 汇总报表覆盖的全部站点(4 站在前、百世在末;汇总页图表只取 4 站)
|
||||
ALL_REPORT_SITES = ["顺心", "中通", "韵达", "安能", "百世"]
|
||||
# 4 站单站未到明细文件名(百世未到文件由站点直接产出,名为 BAISHI_FILE)
|
||||
SITE_UNDELIVERED_FILE = "{name}-未到数据.xlsx"
|
||||
BAISHI_FILE = "百世-应到未到货物数据.xlsx"
|
||||
BAISHI_COLUMNS = ["类型", "子单号", "运单号", "最新扫描记录"]
|
||||
|
||||
|
||||
# ============================ 比对逻辑 ============================
|
||||
|
||||
|
||||
def arrived_pieces_zhongtong(df):
|
||||
"""中通:实到「运单号」为复合串(H + 运单号(12) + 总数(4) + 顺序(4))。
|
||||
基号 = v[:-8](与应到表运单号对齐),单件 = 整串(每串即一件)。"""
|
||||
res = defaultdict(set)
|
||||
for v in df["运单号"]:
|
||||
v = str(v).strip()
|
||||
if len(v) > 8 and v[-4:].isdigit():
|
||||
res[v[:-8]].add(v) # 以完整复合串作为“已到单号”存入
|
||||
return res
|
||||
|
||||
|
||||
def arrived_pieces_by_cols(wb_col, piece_col):
|
||||
"""顺心 / 韵达 / 安能:按干净运单列分组,单件 = 子单号 / 扫描单号。
|
||||
wb_col:实到表中与应到运单号对齐的干净列
|
||||
(顺心=运单号 / 韵达=主单号 / 安能=所属单号)
|
||||
piece_col:实到表中每件货物的单号列(子单号 / 扫描单号)"""
|
||||
|
||||
def parse(df):
|
||||
res = defaultdict(set)
|
||||
for m, s in zip(df[wb_col], df[piece_col]):
|
||||
m, s = str(m).strip(), str(s).strip()
|
||||
if m and s:
|
||||
res[m].add(s)
|
||||
return res
|
||||
|
||||
return parse
|
||||
|
||||
|
||||
STATIONS = [
|
||||
{
|
||||
"name": "中通",
|
||||
"exp": "中通-应到货物数据.xlsx",
|
||||
"act": "中通-实到货物数据.xlsx",
|
||||
"exp_qty": "交接件数", # 应到件数口径:交接件数(非录单件数)
|
||||
"exp_wb": "运单号", # 应到表运单号列(兼作去重键)
|
||||
"exp_jd": "交接单号", # 未到数据需展示的交接单号
|
||||
"arrived_pieces": arrived_pieces_zhongtong,
|
||||
"columns": ["交接单号", "运单号", "总件数"],
|
||||
},
|
||||
{
|
||||
"name": "顺心",
|
||||
"exp": "顺心-应到货物数据.xlsx",
|
||||
"act": "顺心-实到货物数据.xlsx",
|
||||
"exp_qty": "交接件数",
|
||||
"exp_wb": "运单号",
|
||||
"exp_jd": "交接单号",
|
||||
"arrived_pieces": arrived_pieces_by_cols("运单号", "子单号"),
|
||||
"columns": ["交接单号", "运单号", "总件数"],
|
||||
},
|
||||
{
|
||||
"name": "韵达",
|
||||
"exp": "韵达-应到货物数据.xlsx",
|
||||
"act": "韵达-实到货物数据.xlsx",
|
||||
"exp_qty": "交接件数",
|
||||
"exp_wb": "运单号",
|
||||
"exp_jd": "交接单号",
|
||||
"arrived_pieces": arrived_pieces_by_cols("主单号", "子单号"),
|
||||
"columns": ["交接单号", "运单号", "总件数"],
|
||||
},
|
||||
{
|
||||
"name": "安能",
|
||||
"exp": "安能-应到货物数据.xlsx",
|
||||
"act": "安能-实到货物数据.xlsx",
|
||||
"exp_qty": "交接件数",
|
||||
"exp_wb": "运单号",
|
||||
"exp_jd": "交接单号",
|
||||
"arrived_pieces": arrived_pieces_by_cols("所属单号", "扫描单号"),
|
||||
"columns": ["交接单号", "运单号", "总件数"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _site_cfg(name):
|
||||
"""按名称取 4 站配置(百世不在 STATIONS,返回 None)。"""
|
||||
return next((c for c in STATIONS if c["name"] == name), None)
|
||||
|
||||
|
||||
def process(name):
|
||||
"""4 站单站比对(重构版):返回 (列名list, 明细行list[dict], 统计dict)。
|
||||
源文件缺失或非 4 站返回 None。
|
||||
新口径:应到=交接件数;实到=直接数单号去重;未到=应到−实到;
|
||||
未到明细行仅含「交接单号|运单号|总件数|+已到单号…」,不再编造子单号。"""
|
||||
cfg = _site_cfg(name)
|
||||
if cfg is None:
|
||||
return None
|
||||
exp_path = os.path.join(DOWNLOADS, cfg["exp"])
|
||||
act_path = os.path.join(DOWNLOADS, cfg["act"])
|
||||
if not os.path.exists(exp_path) or not os.path.exists(act_path):
|
||||
print(f"[跳过] {cfg['name']}:downloads 下缺少 {cfg['exp']} 或 {cfg['act']}")
|
||||
return None
|
||||
|
||||
df_exp = pd.read_excel(exp_path, dtype=str).fillna("")
|
||||
df_act = pd.read_excel(act_path, dtype=str).fillna("")
|
||||
|
||||
# 同一运单可能有多条交接记录,按运单号去重、保留首条
|
||||
dup = int(df_exp[cfg["exp_wb"]].duplicated().sum())
|
||||
df_exp = df_exp.drop_duplicates(subset=[cfg["exp_wb"]], keep="first")
|
||||
|
||||
# 应到件数(新口径)= 交接件数 之和;记录 运单 -> (交接单号, 应到件数)
|
||||
exp_by_wb = {}
|
||||
exp_pieces = 0
|
||||
for _, r in df_exp.iterrows():
|
||||
wb = str(r[cfg["exp_wb"]]).strip()
|
||||
if not wb:
|
||||
continue
|
||||
try:
|
||||
n = int(float(r[cfg["exp_qty"]]))
|
||||
except (TypeError, ValueError, KeyError):
|
||||
n = 0
|
||||
if n <= 0:
|
||||
continue
|
||||
exp_pieces += n
|
||||
if wb not in exp_by_wb:
|
||||
exp_by_wb[wb] = {
|
||||
"jd": str(r.get(cfg["exp_jd"], "")).strip(),
|
||||
"n": n,
|
||||
}
|
||||
|
||||
# 实到件数(新口径)= 实到表单号去重数量(分组 运单->已到单号集合)
|
||||
arrived = cfg["arrived_pieces"](df_act)
|
||||
act_pieces = sum(len(s) for s in arrived.values()) # 全局去重单号数
|
||||
|
||||
# 未到:逐运单比较,列出实际已到的单号(顺序号乱序,无法反推缺件序号)
|
||||
rows = []
|
||||
full_miss = part_miss = 0
|
||||
max_arrived = 0
|
||||
for wb, info in exp_by_wb.items():
|
||||
n = info["n"]
|
||||
arrived_set = arrived.get(wb, set())
|
||||
arrived_cnt = len(arrived_set)
|
||||
if arrived_cnt >= n:
|
||||
continue # 足额或溢到,不进未到表
|
||||
if arrived_cnt == 0:
|
||||
full_miss += 1
|
||||
else:
|
||||
part_miss += 1
|
||||
max_arrived = max(max_arrived, arrived_cnt)
|
||||
row = {
|
||||
cfg["exp_jd"]: info["jd"],
|
||||
cfg["exp_wb"]: wb,
|
||||
"总件数": n,
|
||||
}
|
||||
for i, piece in enumerate(sorted(arrived_set, key=lambda x: str(x))):
|
||||
row[f"已到单号{i+1}"] = piece
|
||||
rows.append(row)
|
||||
|
||||
# 动态列:基础 3 列 + 已到单号1..max_arrived
|
||||
columns = list(cfg["columns"]) + [f"已到单号{i+1}" for i in range(max_arrived)]
|
||||
|
||||
stats = {
|
||||
"运单数": len(exp_by_wb),
|
||||
"应到件": exp_pieces,
|
||||
"已到件": act_pieces,
|
||||
"未到件": max(0, exp_pieces - act_pieces),
|
||||
"涉及运单": full_miss + part_miss,
|
||||
"完全未到": full_miss,
|
||||
"部分未到": part_miss,
|
||||
"重复运单": dup,
|
||||
}
|
||||
return columns, rows, stats
|
||||
|
||||
|
||||
# ============================ 样式常量 ============================
|
||||
|
||||
FONT = "微软雅黑"
|
||||
NAVY = "1F3864" # 标题栏
|
||||
BLUE = "305496" # 表头
|
||||
LIGHTBLUE = "D6DCE5" # 合计行
|
||||
CARD_BG = "F2F6FC" # 指标卡底
|
||||
RED = "C00000" # 未到
|
||||
GREEN = "548235" # 已到
|
||||
GRAY = "808080"
|
||||
ZEBRA = "F4F7FC"
|
||||
LINE = "D9D9D9"
|
||||
TILE = "BFBFBF"
|
||||
|
||||
THIN = Side(style="thin", color=LINE)
|
||||
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
|
||||
|
||||
|
||||
def heat(rate):
|
||||
"""未到率热力底色:绿(低) / 黄(中) / 红(高)。"""
|
||||
if rate >= 0.50:
|
||||
return "FFC7CE"
|
||||
if rate >= 0.15:
|
||||
return "FFEB9C"
|
||||
return "C6EFCE"
|
||||
|
||||
|
||||
# ============================ 写明细表 ============================
|
||||
|
||||
HEADER_FILL = PatternFill("solid", fgColor=BLUE)
|
||||
HEADER_FONT = Font(name=FONT, bold=True, color="FFFFFF", size=11)
|
||||
BODY_FONT = Font(name=FONT, size=10)
|
||||
|
||||
|
||||
def write_station(ws, columns, rows):
|
||||
ws.sheet_view.showGridLines = False
|
||||
ws.append(columns)
|
||||
for c in range(1, len(columns) + 1):
|
||||
cell = ws.cell(row=1, column=c)
|
||||
cell.fill = HEADER_FILL
|
||||
cell.font = HEADER_FONT
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
cell.border = BORDER
|
||||
for row in rows:
|
||||
ws.append([row.get(c, "") for c in columns])
|
||||
for r in range(2, ws.max_row + 1):
|
||||
for c, col in enumerate(columns, start=1):
|
||||
cell = ws.cell(row=r, column=c)
|
||||
cell.font = BODY_FONT
|
||||
cell.border = BORDER
|
||||
if col == "总件数":
|
||||
cell.number_format = "#,##0"
|
||||
cell.alignment = Alignment(horizontal="right", vertical="center")
|
||||
else:
|
||||
cell.number_format = "@" # 文本,避免长单号被转科学计数
|
||||
for c, col in enumerate(columns, start=1):
|
||||
body = [len(str(row.get(col, ""))) for row in rows] if rows else []
|
||||
width = min(max([len(str(col))] + body) + 4, 36)
|
||||
ws.column_dimensions[ws.cell(row=1, column=c).column_letter].width = max(
|
||||
width, 12
|
||||
)
|
||||
ws.freeze_panes = "A2"
|
||||
ws.page_setup.orientation = "landscape"
|
||||
ws.page_setup.fitToWidth = 1
|
||||
ws.page_setup.fitToHeight = 0
|
||||
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True)
|
||||
ws.print_title_rows = "1:1"
|
||||
|
||||
|
||||
# ============================ 单站 / 全量产出 ============================
|
||||
|
||||
|
||||
def process_baishi():
|
||||
"""百世:读站点直供的未到明细,返回 (columns, rows, stats);文件缺失返回 None。
|
||||
百世文件本身即未到结果(无应到/已到基数),统计只能给出未到件数。"""
|
||||
path = os.path.join(DOWNLOADS, BAISHI_FILE)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
df = pd.read_excel(path, dtype=str).fillna("")
|
||||
rows = df.to_dict("records")
|
||||
wb_count = df["运单号"].nunique() if "运单号" in df.columns else len(rows)
|
||||
|
||||
# 应到/实到基数取自「扫描综合查询」应扫/已扫(到/接件扫描→当日),
|
||||
# 由 baishi_download_undelivered_data_impl 在同次导航里抓取并落 site_settings。
|
||||
# 未抓取过则 get_setting 返回 "" → 视为无基数(报表显示「—」)。
|
||||
from inbound_verify import (
|
||||
state_store,
|
||||
) # 与 _read_business_dates 一致:比对模块纯离线,懒加载
|
||||
|
||||
def _to_int(v):
|
||||
v = (v or "").strip().replace(",", "")
|
||||
try:
|
||||
return int(float(v)) if v not in ("", "-") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
exp_n = _to_int(state_store.get_setting("百世", "scan_expected_pieces"))
|
||||
arr_n = _to_int(state_store.get_setting("百世", "scan_arrived_pieces"))
|
||||
|
||||
stats = {
|
||||
"运单数": wb_count,
|
||||
"应到件": exp_n,
|
||||
"已到件": arr_n,
|
||||
"未到件": len(rows),
|
||||
"涉及运单": wb_count,
|
||||
"完全未到": None,
|
||||
"部分未到": None,
|
||||
"重复运单": 0,
|
||||
}
|
||||
return (BAISHI_COLUMNS, rows, stats)
|
||||
|
||||
|
||||
def write_site_file(name):
|
||||
"""4 站:把该站未到明细写到 downloads/<站>-未到数据.xlsx。
|
||||
应到/实到缺(process 返回 None)→ 删旧文件、返回 False;成功返回 True。"""
|
||||
path = os.path.join(DOWNLOADS, SITE_UNDELIVERED_FILE.format(name=name))
|
||||
out = process(name)
|
||||
if out is None:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return False
|
||||
columns, rows, _stats = out
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
ws = wb.create_sheet(name)
|
||||
write_station(ws, columns, rows)
|
||||
wb.save(path)
|
||||
return True
|
||||
|
||||
|
||||
def _read_business_dates(include):
|
||||
"""从状态库读各站业务日期(dispatch 下载成功时快照写入),供报告「数据日期」列。
|
||||
4 站取 expected_business_date(报告按应到口径);百世取 undelivered_business_date。
|
||||
从未下过的站返回空串(诚实留空,不反推)。"""
|
||||
from inbound_verify import state_store # lazy import:比对模块本身保持纯离线
|
||||
|
||||
status = state_store.get_all_status()
|
||||
dates = {}
|
||||
for name in include:
|
||||
s = status.get(name, {})
|
||||
if name == "百世":
|
||||
dates[name] = s.get("undelivered_business_date", "")
|
||||
else:
|
||||
dates[name] = s.get("expected_business_date", "")
|
||||
return dates
|
||||
|
||||
|
||||
def build_full_report(include, dates=None):
|
||||
"""生成全站汇总报表 output/应到未到数据.xlsx。
|
||||
include: 本次成功的站点集合;未成功站点在汇总里保留行、无数据(不影响他站)。
|
||||
返回 {站点: 未到件或None} 供日志。"""
|
||||
os.makedirs(OUTPUT, exist_ok=True)
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
summary_ws = wb.create_sheet("汇总报表") # 首页占位
|
||||
|
||||
summary = [] # (name, stats_or_None),顺序:4 站 + 百世
|
||||
for name in ALL_REPORT_SITES:
|
||||
if name == "百世":
|
||||
out = process_baishi() if "百世" in include else None
|
||||
columns = BAISHI_COLUMNS
|
||||
else:
|
||||
out = process(name) if name in include else None
|
||||
cfg = _site_cfg(name)
|
||||
columns = cfg["columns"] if cfg else []
|
||||
stats = out[2] if out is not None else None
|
||||
rows = out[1] if out is not None else []
|
||||
summary.append((name, stats))
|
||||
ws = wb.create_sheet(name)
|
||||
write_station(ws, columns, rows)
|
||||
|
||||
build_summary(
|
||||
summary_ws,
|
||||
summary,
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
dates=dates or {},
|
||||
)
|
||||
wb.save(OUTFILE)
|
||||
return {n: (s["未到件"] if s else None) for (n, s) in summary}
|
||||
|
||||
|
||||
# ============================ 写汇总报表 ============================
|
||||
|
||||
|
||||
def build_summary(ws, results, generated_at, dates=None):
|
||||
dates = dates or {}
|
||||
center = Alignment(horizontal="center", vertical="center")
|
||||
left = Alignment(horizontal="left", vertical="center", indent=1)
|
||||
|
||||
# 合计/KPI 只算 4 站中本次成功的(百世无应到基数、失败站无数据,均不计入)
|
||||
four = [(n, s) for (n, s) in results if n != "百世"]
|
||||
ok = [s for _, s in four if s]
|
||||
t_wb = sum(s["运单数"] for s in ok)
|
||||
t_exp = sum(s["应到件"] for s in ok)
|
||||
t_arr = sum(s["已到件"] for s in ok)
|
||||
t_miss = sum(s["未到件"] for s in ok)
|
||||
t_full = sum(s["完全未到"] for s in ok)
|
||||
t_part = sum(s["部分未到"] for s in ok)
|
||||
rate = (t_miss / t_exp) if t_exp else 0
|
||||
|
||||
ws.sheet_view.showGridLines = False
|
||||
ws.column_dimensions["A"].width = 2.5
|
||||
# 列宽按「4 个 KPI 卡等宽」设计:B+C = D+E+F = G+H = I+J = 26
|
||||
for col, w in {
|
||||
"B": 12,
|
||||
"C": 14,
|
||||
"D": 9,
|
||||
"E": 9,
|
||||
"F": 8,
|
||||
"G": 12,
|
||||
"H": 14,
|
||||
"I": 13,
|
||||
"J": 13,
|
||||
}.items():
|
||||
ws.column_dimensions[col].width = w
|
||||
ws.row_dimensions[1].height = 6
|
||||
|
||||
# —— 标题栏 ——
|
||||
ws.merge_cells("B2:J2")
|
||||
t = ws["B2"]
|
||||
t.value = "应到未到比对 · 汇总报表"
|
||||
t.fill = PatternFill("solid", fgColor=NAVY)
|
||||
t.font = Font(name=FONT, bold=True, size=18, color="FFFFFF")
|
||||
t.alignment = center
|
||||
for row in ws["B2:J2"]:
|
||||
for c in row:
|
||||
c.fill = PatternFill("solid", fgColor=NAVY)
|
||||
ws.row_dimensions[2].height = 34
|
||||
ws.merge_cells("B3:J3")
|
||||
sub = ws["B3"]
|
||||
sub.value = f"数据快照 · 生成于 {generated_at}"
|
||||
sub.font = Font(name=FONT, size=10, color=GRAY)
|
||||
sub.alignment = Alignment(horizontal="right", vertical="center")
|
||||
ws.row_dimensions[3].height = 18
|
||||
|
||||
# —— KPI 指标卡 ——
|
||||
cards = [
|
||||
("应到总件数", t_exp, NAVY, "#,##0"),
|
||||
("已到总件数", t_arr, GREEN, "#,##0"),
|
||||
("未到总件数", t_miss, RED, "#,##0"),
|
||||
("总体未到率", rate, RED, "0.0%"),
|
||||
]
|
||||
# 2-3-2-2 分布填满 B-J(9 列),配合上方列宽使 4 卡视觉等宽
|
||||
spans = [
|
||||
("B5:C5", "B6:C6"),
|
||||
("D5:F5", "D6:F6"),
|
||||
("G5:H5", "G6:H6"),
|
||||
("I5:J5", "I6:J6"),
|
||||
]
|
||||
card_bg = PatternFill("solid", fgColor=CARD_BG)
|
||||
thin = Side(style="thin", color=TILE)
|
||||
for (lab, val, acc, fmt), (lrng, vrng) in zip(cards, spans):
|
||||
ws.merge_cells(lrng)
|
||||
ws.merge_cells(vrng)
|
||||
acctop = Side(style="medium", color=acc)
|
||||
for row in ws[lrng]:
|
||||
for c in row:
|
||||
c.fill = card_bg
|
||||
c.font = Font(name=FONT, size=10, color=GRAY)
|
||||
c.alignment = center
|
||||
c.border = Border(left=thin, right=thin, top=acctop, bottom=thin)
|
||||
for row in ws[vrng]:
|
||||
for c in row:
|
||||
c.fill = card_bg
|
||||
c.font = Font(name=FONT, bold=True, size=20, color=acc)
|
||||
c.alignment = center
|
||||
c.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
ws[lrng.split(":")[0]].value = lab
|
||||
vc = ws[vrng.split(":")[0]]
|
||||
vc.value = val
|
||||
vc.number_format = fmt
|
||||
ws.row_dimensions[5].height = 18
|
||||
ws.row_dimensions[6].height = 38
|
||||
ws.row_dimensions[7].height = 8
|
||||
|
||||
# —— 小节标题 ——
|
||||
ws.merge_cells("B8:J8")
|
||||
sec = ws["B8"]
|
||||
sec.value = "各站点明细统计"
|
||||
sec.font = Font(name=FONT, bold=True, size=12, color=NAVY)
|
||||
sec.alignment = Alignment(horizontal="left", vertical="center")
|
||||
for row in ws["B8:J8"]:
|
||||
for c in row:
|
||||
c.border = Border(bottom=Side(style="medium", color=BLUE))
|
||||
ws.row_dimensions[8].height = 22
|
||||
|
||||
# —— 统计表头 ——
|
||||
headers = [
|
||||
"站点",
|
||||
"应到运单数",
|
||||
"应到件数",
|
||||
"已到件数",
|
||||
"未到件数",
|
||||
"未到率",
|
||||
"完全未到运单",
|
||||
"部分未到运单",
|
||||
"数据日期",
|
||||
]
|
||||
head_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
for i, h in enumerate(headers):
|
||||
col = chr(ord("B") + i)
|
||||
cell = ws[f"{col}9"]
|
||||
cell.value = h
|
||||
cell.fill = HEADER_FILL
|
||||
cell.font = HEADER_FONT
|
||||
cell.alignment = head_align
|
||||
cell.border = BORDER
|
||||
ws.row_dimensions[9].height = 30
|
||||
|
||||
# —— 各站数据行(4 站 + 百世)——
|
||||
r = 10
|
||||
for idx, (name, s) in enumerate(results):
|
||||
is_baishi = name == "百世"
|
||||
srate = 0
|
||||
if s is None:
|
||||
vals = [f"{name}(无数据)", 0, 0, 0, 0, 0, 0, 0]
|
||||
elif is_baishi:
|
||||
# 百世:未到明细已知;若已抓取应到/实到基数(扫描综合查询应扫/已扫)则填真实值
|
||||
if s["应到件"] is not None and s["已到件"] is not None:
|
||||
srate = (s["未到件"] / s["应到件"]) if s["应到件"] else 0
|
||||
vals = [
|
||||
name,
|
||||
s["运单数"],
|
||||
s["应到件"],
|
||||
s["已到件"],
|
||||
s["未到件"],
|
||||
srate,
|
||||
"—",
|
||||
"—",
|
||||
]
|
||||
else:
|
||||
vals = [name, s["运单数"], "—", "—", s["未到件"], "—", "—", "—"]
|
||||
else:
|
||||
srate = (s["未到件"] / s["应到件"]) if s["应到件"] else 0
|
||||
vals = [
|
||||
name,
|
||||
s["运单数"],
|
||||
s["应到件"],
|
||||
s["已到件"],
|
||||
s["未到件"],
|
||||
srate,
|
||||
s["完全未到"],
|
||||
s["部分未到"],
|
||||
]
|
||||
vals.append(dates.get(name, "")) # 末列:该站业务日期
|
||||
for i, v in enumerate(vals):
|
||||
col = chr(ord("B") + i)
|
||||
cell = ws[f"{col}{r}"]
|
||||
cell.value = v
|
||||
cell.font = BODY_FONT
|
||||
cell.border = BORDER
|
||||
cell.alignment = left if i == 0 else center
|
||||
if s is None:
|
||||
cell.fill = PatternFill("solid", fgColor="EFEFEF")
|
||||
elif not is_baishi and idx % 2 == 1 and i != 5:
|
||||
cell.fill = PatternFill("solid", fgColor=ZEBRA)
|
||||
if isinstance(v, (int, float)):
|
||||
cell.number_format = "0.0%" if i == 5 else "#,##0"
|
||||
if (
|
||||
i == 5
|
||||
and s is not None
|
||||
and isinstance(v, (int, float))
|
||||
and not isinstance(v, bool)
|
||||
):
|
||||
cell.fill = PatternFill("solid", fgColor=heat(srate))
|
||||
ws.row_dimensions[r].height = 19
|
||||
r += 1
|
||||
|
||||
# —— 合计行 ——
|
||||
tot_fill = PatternFill("solid", fgColor=LIGHTBLUE)
|
||||
tot_font = Font(name=FONT, bold=True, size=10)
|
||||
totals = ["合计", t_wb, t_exp, t_arr, t_miss, rate, t_full, t_part, ""]
|
||||
for i, v in enumerate(totals):
|
||||
col = chr(ord("B") + i)
|
||||
cell = ws[f"{col}{r}"]
|
||||
cell.value = v
|
||||
cell.fill = tot_fill
|
||||
cell.font = tot_font
|
||||
cell.border = BORDER
|
||||
cell.alignment = left if i == 0 else center
|
||||
if i in (1, 2, 3, 4, 6, 7):
|
||||
cell.number_format = "#,##0"
|
||||
if i == 5:
|
||||
cell.number_format = "0.0%"
|
||||
ws.row_dimensions[r].height = 20
|
||||
last_data_row = 9 + len(four) # 图表只取 4 站(百世无应到/已到基数,不绘图)
|
||||
chart_anchor = r + 2
|
||||
|
||||
# —— 堆叠柱状图:各站已到 / 未到 ——
|
||||
chart = BarChart()
|
||||
chart.type = "col"
|
||||
chart.grouping = "stacked"
|
||||
chart.overlap = 100
|
||||
chart.title = "各站点到货构成(已到 / 未到 件数)"
|
||||
data = Reference(
|
||||
ws, min_col=5, max_col=6, min_row=9, max_row=last_data_row
|
||||
) # E已到 F未到
|
||||
chart.add_data(data, titles_from_data=True)
|
||||
cats = Reference(ws, min_col=2, min_row=10, max_row=last_data_row)
|
||||
chart.set_categories(cats)
|
||||
chart.series[0].graphicalProperties.solidFill = GREEN
|
||||
chart.series[1].graphicalProperties.solidFill = RED
|
||||
chart.y_axis.title = "件数"
|
||||
chart.x_axis.delete = False
|
||||
chart.y_axis.delete = False
|
||||
chart.legend.position = "b"
|
||||
chart.legend.overlay = False # 不覆盖绘图区:图例独占底部一行,与 X 轴站点名错开
|
||||
chart.height = 9
|
||||
chart.width = 20
|
||||
ws.add_chart(chart, f"B{chart_anchor}")
|
||||
|
||||
# —— 口径说明 ——
|
||||
note_row = chart_anchor + 19
|
||||
notes = [
|
||||
"指标口径:未到率 = 未到件数 ÷ 应到件数;完全未到运单 = 整单零到货;部分未到运单 = 部分到货、部分缺件。",
|
||||
"合计 / 图表仅含 4 站(顺心/中通/韵达/安能,应到−实到口径);百世应到/实到取自「扫描综合查询」应扫/已扫(到/接件扫描→当日),已填入百世行,但为保持 4 站口径一致、不计入合计与图表。",
|
||||
"本次下载失败的站点标注为(无数据)并计 0,不影响其余站点统计。",
|
||||
"明细见各站点工作表;未到明细仅列短少运单,并列出该运单实际扫到的单号(已到单号1…),缺件不再编造子单号。",
|
||||
"数据日期:各站本次纳入数据对应的业务日期(=应到数据下载日 − 日期偏移;韵达偏移 1 为前一日);合计为多站混合、不标注。",
|
||||
]
|
||||
for k, text in enumerate(notes):
|
||||
rr = note_row + k
|
||||
ws.merge_cells(f"B{rr}:J{rr}")
|
||||
cell = ws[f"B{rr}"]
|
||||
cell.value = text
|
||||
cell.font = Font(name=FONT, size=9, color=GRAY)
|
||||
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
||||
|
||||
ws.page_setup.orientation = "landscape"
|
||||
ws.page_setup.fitToWidth = 1
|
||||
ws.page_setup.fitToHeight = 0
|
||||
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True)
|
||||
ws.page_margins = PageMargins(left=0.4, right=0.4, top=0.5, bottom=0.5)
|
||||
ws.print_area = f"A1:J{note_row + 1}"
|
||||
|
||||
|
||||
# ============================ 主流程 ============================
|
||||
|
||||
|
||||
def main():
|
||||
"""菜单 [9] / 离线入口:用 downloads/ 下现有文件生成全站汇总报告(有文件的站即纳入)。"""
|
||||
print("应到未到比对(全站汇总)")
|
||||
print("-" * 56)
|
||||
include = set()
|
||||
for name in ALL_REPORT_SITES:
|
||||
if name == "百世":
|
||||
if os.path.exists(os.path.join(DOWNLOADS, BAISHI_FILE)):
|
||||
include.add(name)
|
||||
else:
|
||||
cfg = _site_cfg(name)
|
||||
if (
|
||||
cfg
|
||||
and os.path.exists(os.path.join(DOWNLOADS, cfg["exp"]))
|
||||
and os.path.exists(os.path.join(DOWNLOADS, cfg["act"]))
|
||||
):
|
||||
include.add(name)
|
||||
if not include:
|
||||
print("未处理任何站点:请确认 downloads/ 下存在源数据文件。")
|
||||
return
|
||||
dates = _read_business_dates(include)
|
||||
undel = build_full_report(include, dates=dates)
|
||||
print("-" * 56)
|
||||
for name in ALL_REPORT_SITES:
|
||||
if name in include:
|
||||
print(f"{name}:未到 {undel.get(name)} 件")
|
||||
else:
|
||||
print(f"{name}:无数据,跳过")
|
||||
print(f"已输出:{OUTFILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
inbound_verify/paths.py
Normal file
20
inbound_verify/paths.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# paths.py
|
||||
# 统一的路径锚点:所有路径都以本项目所在目录为基准,避免依赖运行时的工作目录(cwd)。
|
||||
# 这样无论从哪个目录启动脚本(IDE / 命令行 / 计划任务 / 双击),
|
||||
# 下载目录与配置文件都能稳定定位,不会出现“文件落到别处”或“读不到密码”的隐蔽故障。
|
||||
|
||||
import os
|
||||
|
||||
# 项目根目录(以本文件所在位置为基准,与从哪个目录启动脚本无关)
|
||||
# __file__ = <root>/inbound_verify/paths.py → 上两级 = 项目根
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 统一的下载 / 输出目录
|
||||
DOWNLOAD_DIR = os.path.join(BASE_DIR, "downloads")
|
||||
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||
|
||||
# 统一的配置文件路径(注意:config.yaml 需与本项目脚本放在同一目录下)
|
||||
CONFIG_PATH = os.path.join(BASE_DIR, "config.yaml")
|
||||
|
||||
# 状态存储(SQLite,阶段0:心跳 / 登录态 / 数据态持久化,重启不丢)
|
||||
STATE_DB_PATH = os.path.join(BASE_DIR, "state", "state.db")
|
||||
563
inbound_verify/runtime.py
Normal file
563
inbound_verify/runtime.py
Normal file
@@ -0,0 +1,563 @@
|
||||
# runtime.py
|
||||
# 阶段1:服务化共享核心。把"启动浏览器 + 各站就绪 + 弹窗 + 心跳初值"
|
||||
# (launch_and_prepare)、"执行一条任务"(dispatch_task)、"一轮心跳"(run_heartbeat)
|
||||
# 抽出来,供
|
||||
# - main_router.py(交互菜单模式:调试 / 人工操作)
|
||||
# - server.py(FastAPI 服务模式:常驻 + 接收 API 指令)
|
||||
# 共同复用,避免两处重复维护。
|
||||
#
|
||||
# 线程模型:launch_and_prepare 内 sync_playwright().start() 必须在"持有 Playwright 的
|
||||
# 线程"调用(交互模式=主线程;服务模式=worker 线程)。该线程独占所有 page 操作;
|
||||
# 其他线程(如 FastAPI 路由)只能经 task_queue + state_store 与之通信,绝不跨线程
|
||||
# 访问 page。
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import yaml
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from inbound_verify.paths import DOWNLOAD_DIR, CONFIG_PATH
|
||||
|
||||
from inbound_verify import state_store
|
||||
from inbound_verify.sites import shunxin, baishi, zto, yunda, anneng
|
||||
from inbound_verify import expected_undelivered # dispatch 的 compare 任务用
|
||||
|
||||
# 各网页站点首页 URL(单一来源,取自各站点模块 HOME_URL)
|
||||
SITES_CONFIG = {
|
||||
"顺心": shunxin.HOME_URL,
|
||||
"百世": baishi.HOME_URL,
|
||||
"中通": zto.HOME_URL,
|
||||
"韵达": yunda.HOME_URL,
|
||||
}
|
||||
|
||||
# 站点就绪特征:登录成功进入工作台后的标志性控件
|
||||
READY_SELECTORS = {
|
||||
"顺心": 'h1:has-text("盟商门户网")',
|
||||
"百世": 'h1[title="百世快运"]',
|
||||
"中通": '.logo:has-text("网点版")',
|
||||
"韵达": '.el-menu-item:has-text("首页")',
|
||||
}
|
||||
|
||||
# 安能是 Electron 桌面应用(不是 Playwright 网页),单独启动
|
||||
APP_SITES = {"安能"}
|
||||
|
||||
# 心跳间隔(秒)
|
||||
HEARTBEAT_INTERVAL = 30
|
||||
|
||||
# 各站最终数据文件名(探测"数据是否已跑出来");百世为单流程
|
||||
DATA_FILENAMES = {
|
||||
"顺心": {
|
||||
"expected": "顺心-应到货物数据.xlsx",
|
||||
"actual": "顺心-实到货物数据.xlsx",
|
||||
"undelivered": "顺心-未到数据.xlsx",
|
||||
},
|
||||
"中通": {
|
||||
"expected": "中通-应到货物数据.xlsx",
|
||||
"actual": "中通-实到货物数据.xlsx",
|
||||
"undelivered": "中通-未到数据.xlsx",
|
||||
},
|
||||
"韵达": {
|
||||
"expected": "韵达-应到货物数据.xlsx",
|
||||
"actual": "韵达-实到货物数据.xlsx",
|
||||
"undelivered": "韵达-未到数据.xlsx",
|
||||
},
|
||||
"安能": {
|
||||
"expected": "安能-应到货物数据.xlsx",
|
||||
"actual": "安能-实到货物数据.xlsx",
|
||||
"undelivered": "安能-未到数据.xlsx",
|
||||
},
|
||||
"百世": {"expected": "", "actual": "", "undelivered": "百世-应到未到货物数据.xlsx"},
|
||||
}
|
||||
|
||||
|
||||
# ============================ 安能启动(CDP)============================
|
||||
|
||||
|
||||
def _find_free_port():
|
||||
"""让操作系统分配一个空闲端口,避免固定端口冲突。"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_cdp_up(port, timeout=60.0):
|
||||
"""轮询直到 CDP 调试端口就绪(应用启动需要时间)。"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"http://localhost:{port}/json/version", timeout=2
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def launch_anneng(app_path):
|
||||
"""以调试模式启动安能 Electron 应用(自动选取空闲端口),返回子进程对象。"""
|
||||
# 【环境兼容】WorkBuddy 等 shell 会注入 NODE_OPTIONS(含 --use-system-ca),
|
||||
# Electron 内置 Node 拒绝该 flag 导致安能启动即退出(rc=9)。
|
||||
# 拉起前从子进程环境里摘掉 NODE_OPTIONS。
|
||||
anneng_env = os.environ.copy()
|
||||
anneng_env.pop("NODE_OPTIONS", None)
|
||||
port = _find_free_port()
|
||||
print(f">> 以调试模式启动【安能】应用(端口 {port}):{app_path}")
|
||||
proc = subprocess.Popen(
|
||||
[app_path, f"--remote-debugging-port={port}"], env=anneng_env
|
||||
)
|
||||
anneng.set_cdp_port(port)
|
||||
if not _wait_cdp_up(port):
|
||||
raise RuntimeError(
|
||||
f"安能应用调试端口 {port} 未就绪——可能应用已在运行(单实例),"
|
||||
"请先关闭已有的安能窗口再试"
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
# ============================ 探测函数 ============================
|
||||
|
||||
|
||||
def probe_site_login(site_name, pages_map):
|
||||
"""探测单站是否登录(复用就绪判据)。任何异常一律返回 False。
|
||||
|
||||
只在 Playwright 所属线程调用。
|
||||
"""
|
||||
try:
|
||||
if site_name not in pages_map:
|
||||
return False
|
||||
if site_name == "安能":
|
||||
return anneng.anneng_ready()
|
||||
if site_name == "顺心":
|
||||
return all(
|
||||
pg.locator(READY_SELECTORS["顺心"]).is_visible(timeout=500)
|
||||
for pg in pages_map["顺心"]
|
||||
)
|
||||
return (
|
||||
pages_map[site_name]
|
||||
.locator(READY_SELECTORS[site_name])
|
||||
.is_visible(timeout=500)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def probe_data_file(site_name, kind):
|
||||
"""探测单站应到/实到数据文件是否存在且为今天。返回 (is_today, mtime_str)。"""
|
||||
fname = DATA_FILENAMES.get(site_name, {}).get(kind, "")
|
||||
if not fname:
|
||||
return (False, "")
|
||||
path = os.path.join(DOWNLOAD_DIR, fname)
|
||||
if not os.path.exists(path):
|
||||
return (False, "")
|
||||
dt = datetime.fromtimestamp(os.path.getmtime(path))
|
||||
is_today = dt.date() == datetime.now().date()
|
||||
return (is_today, dt.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
|
||||
# ============================ 运行上下文 ============================
|
||||
|
||||
|
||||
class RuntimeContext:
|
||||
"""launch_and_prepare 的返回值,持有 Playwright 运行所需对象。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pw,
|
||||
browser,
|
||||
pages_map,
|
||||
ready_status,
|
||||
anneng_proc,
|
||||
sites_to_watch,
|
||||
debug_mode,
|
||||
debug_target,
|
||||
):
|
||||
self.pw = pw
|
||||
self.browser = browser
|
||||
self.pages_map = pages_map
|
||||
self.ready_status = ready_status
|
||||
self.anneng_proc = anneng_proc
|
||||
self.sites_to_watch = sites_to_watch
|
||||
self.debug_mode = debug_mode
|
||||
self.debug_target = debug_target
|
||||
|
||||
def stop(self):
|
||||
"""关闭 browser + 安能 + Playwright。退出时调用。"""
|
||||
try:
|
||||
self.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self.anneng_proc is not None:
|
||||
try:
|
||||
self.anneng_proc.terminate()
|
||||
print("已关闭安能应用。")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============================ 启动 + 就绪 + 弹窗 ============================
|
||||
|
||||
|
||||
def seed_legacy_config():
|
||||
"""一次性:把 config.yaml 里的站点配置(百世密码 / 韵达账密 / 安能 exe 路径)
|
||||
灌入 state.db。已存在的值不覆盖(前端改过的不动)。"""
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
return
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取 config.yaml 做 seed 失败: {e}")
|
||||
return
|
||||
items = [
|
||||
("百世", "password", (cfg.get("baishi", {}) or {}).get("password", "")),
|
||||
("韵达", "username", (cfg.get("yunda", {}) or {}).get("username", "")),
|
||||
("韵达", "password", (cfg.get("yunda", {}) or {}).get("password", "")),
|
||||
("安能", "app_path", (cfg.get("anneng", {}) or {}).get("app_path", "")),
|
||||
]
|
||||
seeded = []
|
||||
for site, key, val in items:
|
||||
if val and not state_store.get_setting(site, key):
|
||||
state_store.set_setting(site, key, str(val))
|
||||
seeded.append(f"{site}.{key}")
|
||||
if seeded:
|
||||
print(f">> [seed] 从 config.yaml 灌入站点配置: {', '.join(seeded)}")
|
||||
|
||||
|
||||
def launch_and_prepare(debug_mode=False, debug_target=""):
|
||||
"""启动 Playwright + 各站 page + 就绪轮询 + 弹窗清理 + 心跳初值,返回 RuntimeContext。
|
||||
|
||||
必须在"持有 Playwright 的线程"调用(交互模式主线程 / 服务模式 worker 线程)。
|
||||
阻塞至所有站点登录就绪才返回。
|
||||
"""
|
||||
# 0. 状态库建表/迁移 + 从 config.yaml 灌入站点配置(须在 reset_login_states 等之前)
|
||||
state_store.init_db()
|
||||
seed_legacy_config()
|
||||
|
||||
# 1. 读 debug 配置(config.yaml;服务模式也生效)+ 安能路径(state.db,seed 已灌入)
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
_cfg = yaml.safe_load(f) or {}
|
||||
_dbg = _cfg.get("debug", {}) or {}
|
||||
if _dbg.get("enabled"):
|
||||
debug_mode = True
|
||||
debug_target = str(_dbg.get("target_site", "") or "")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取 config.yaml(debug) 失败: {e}")
|
||||
anneng_app_path = state_store.get_setting("安能", "app_path")
|
||||
|
||||
# 2. 确定要挂载的网页站点 + 安能标记
|
||||
anneng_active = False
|
||||
if debug_mode:
|
||||
if debug_target in SITES_CONFIG:
|
||||
print(f"\n🛠️ 【调试模式】仅加载目标站点: [{debug_target}]")
|
||||
active_sites = {debug_target: SITES_CONFIG[debug_target]}
|
||||
elif debug_target == "安能":
|
||||
print(f"\n🛠️ 【调试模式】仅加载目标站点: [安能]")
|
||||
active_sites = {}
|
||||
anneng_active = True
|
||||
else:
|
||||
active_sites = dict(SITES_CONFIG)
|
||||
anneng_active = True
|
||||
else:
|
||||
active_sites = dict(SITES_CONFIG)
|
||||
anneng_active = True
|
||||
|
||||
if anneng_active and not anneng_app_path:
|
||||
print("⚠️ 已启用安能但 config.yaml 未配置 anneng.app_path,将跳过安能。")
|
||||
anneng_active = False
|
||||
|
||||
# 2.5 重置各站登录态为 unknown:登录态是会话级的,避免显示上一会话残留的陈旧登录态。
|
||||
# 数据态(文件就绪)会话无关、保留不动;心跳就绪后会重新探测写真实值。
|
||||
reset_sites = list(active_sites.keys())
|
||||
if anneng_active:
|
||||
reset_sites.append("安能")
|
||||
state_store.reset_login_states(reset_sites)
|
||||
|
||||
# 3. 启动 Playwright(不用 with,改 .start(),由 RuntimeContext.stop() 收尾)
|
||||
pw = sync_playwright().start()
|
||||
# 调试模式临时开启 CDP 端口,便于外部 Playwright(如 Playwright CLI 技能)
|
||||
# 通过 connectOverCDP 挂载到已登录的页面读取内容。仅调试模式生效,不影响服务模式。
|
||||
_launch_args = ["--remote-debugging-port=9223"] if debug_mode else []
|
||||
browser = pw.chromium.launch(headless=False, args=_launch_args)
|
||||
context = browser.new_context(viewport={"width": 1920, "height": 1080})
|
||||
|
||||
pages_map = {}
|
||||
print("\n====================================================")
|
||||
print("【启动】正在打开各站点页面...")
|
||||
print("====================================================")
|
||||
for site_name, url in active_sites.items():
|
||||
if site_name == "顺心":
|
||||
# 顺心:两个归属地账号在同一窗口各开一个标签页
|
||||
sx_pages = []
|
||||
for acct in range(1, 3):
|
||||
print(f">> 正在启动【顺心】账号{acct}标签页: {url}")
|
||||
sx_page = context.new_page()
|
||||
sx_page.goto(url)
|
||||
sx_pages.append(sx_page)
|
||||
pages_map["顺心"] = sx_pages
|
||||
else:
|
||||
print(f">> 正在启动【{site_name}】页面: {url}")
|
||||
page = context.new_page()
|
||||
page.goto(url)
|
||||
pages_map[site_name] = page
|
||||
|
||||
# 4. 安能 Electron
|
||||
anneng_proc = None
|
||||
if anneng_active:
|
||||
try:
|
||||
anneng_proc = launch_anneng(anneng_app_path)
|
||||
pages_map["安能"] = True # 哨兵:已启动(无 Playwright page)
|
||||
except Exception as e:
|
||||
print(f"⚠️ 启动安能应用失败,已跳过安能:{e}")
|
||||
anneng_active = False
|
||||
|
||||
# 5. 韵达前置自动登录
|
||||
print("\n====================================================")
|
||||
print("【登录检测】正在准备各站点登录...")
|
||||
print("====================================================")
|
||||
if "韵达" in pages_map:
|
||||
try:
|
||||
pages_map["韵达"].bring_to_front()
|
||||
yunda.yunda_login(pages_map["韵达"])
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 韵达前置自动登录模块发生波动: {e}")
|
||||
|
||||
# 6. 就绪轮询(复用 probe_site_login)
|
||||
ready_status = {site: False for site in active_sites.keys()}
|
||||
if anneng_active:
|
||||
ready_status["安能"] = False
|
||||
print("\n>> 正在轮询各站点就绪状态 (自动登录或手动登录均可)...")
|
||||
while not all(ready_status.values()):
|
||||
for site_name in list(ready_status.keys()):
|
||||
if ready_status[site_name]:
|
||||
continue
|
||||
if probe_site_login(site_name, pages_map):
|
||||
ready_status[site_name] = True
|
||||
print(f" ✅ 【{site_name}】已检测到主页,登录就绪。")
|
||||
pending = [s for s, r in ready_status.items() if not r]
|
||||
if pending:
|
||||
print(
|
||||
f" ⏳ 等待以下站点完成登录: [{', '.join(pending)}] ... "
|
||||
"(请在浏览器/应用中操作)"
|
||||
)
|
||||
time.sleep(3)
|
||||
|
||||
# 7. 初始弹窗清理
|
||||
print("\n====================================================")
|
||||
print("【准备】所有站点已就绪,正在清理初始弹窗...")
|
||||
print("====================================================")
|
||||
_dismiss_initial_popups(pages_map)
|
||||
for s in ("中通", "韵达", "安能"):
|
||||
if s in pages_map:
|
||||
print(f" ✅ 【{s}】已就绪。")
|
||||
|
||||
# 8. 心跳初值(就绪轮询刚通过 → 各站视为已登录;init_db 已在启动时完成)
|
||||
sites_to_watch = list(ready_status.keys())
|
||||
for _site in sites_to_watch:
|
||||
state_store.set_login_state(_site, True)
|
||||
|
||||
return RuntimeContext(
|
||||
pw,
|
||||
browser,
|
||||
pages_map,
|
||||
ready_status,
|
||||
anneng_proc,
|
||||
sites_to_watch,
|
||||
debug_mode,
|
||||
debug_target,
|
||||
)
|
||||
|
||||
|
||||
def _dismiss_initial_popups(pages_map):
|
||||
"""顺心 / 百世 / 韵达 初始弹窗清理。"""
|
||||
if "顺心" in pages_map:
|
||||
for acct, sx_page in enumerate(pages_map["顺心"], start=1):
|
||||
try:
|
||||
sx_page.bring_to_front()
|
||||
print(f">> 正在处理【顺心】账号{acct}弹窗与遮罩...")
|
||||
sx_page.locator("a").nth(4).click(timeout=2000)
|
||||
sx_page.wait_for_timeout(500)
|
||||
sx_page.get_by_role("button", name="Close").click(timeout=2000)
|
||||
sx_page.wait_for_timeout(500)
|
||||
sx_page.get_by_role("button", name="不再询问").click(timeout=2000)
|
||||
print(f" ✅ 【顺心】账号{acct}初始弹窗处理完成。")
|
||||
except Exception:
|
||||
pass
|
||||
if "百世" in pages_map:
|
||||
try:
|
||||
bs_page = pages_map["百世"]
|
||||
bs_page.bring_to_front()
|
||||
print(">> 正在处理【百世】初始弹窗(阅读消息 / 配置检查 / 优惠券广告)...")
|
||||
# 委托给 baishi 的专用清理:优惠券广告是全屏居中 modal,
|
||||
# 关闭键为 .ant-modal-close(纯图标无文字),必须点它才能真正关掉。
|
||||
baishi.dismiss_baishi_popups(bs_page)
|
||||
print(" ✅ 【百世】初始弹窗处理完成。")
|
||||
except Exception:
|
||||
pass
|
||||
if "韵达" in pages_map:
|
||||
try:
|
||||
yd_page = pages_map["韵达"]
|
||||
yd_page.bring_to_front()
|
||||
print(">> 正在检查【韵达】音频设备授权提示...")
|
||||
yunda.dismiss_audio_prompt(yd_page)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============================ 任务派发 ============================
|
||||
|
||||
|
||||
def _web_handler(site, download_func):
|
||||
"""构造网页站任务 handler:单 page 先 bring_to_front 再 download;顺心(list) 直接传。"""
|
||||
|
||||
def handler(ctx):
|
||||
pg = ctx.pages_map[site]
|
||||
if not isinstance(pg, list):
|
||||
pg.bring_to_front()
|
||||
return download_func(pg)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _site_undelivered_handler(site):
|
||||
"""4 站未到:下应到+实到 → 比对写 downloads/<站>-未到数据.xlsx。
|
||||
任一下载失败 → 清掉旧未到文件、返回 False(前端不展示陈旧未到)。"""
|
||||
|
||||
def handler(ctx):
|
||||
# 各站下载入口约定返回 True/False;顺心历史返回 None(视为成功,与 dispatch 一致)
|
||||
exp_ok = TASK_HANDLERS[(site, "expected")](ctx) is not False
|
||||
act_ok = (
|
||||
(TASK_HANDLERS[(site, "actual")](ctx) is not False) if exp_ok else False
|
||||
)
|
||||
if exp_ok and act_ok:
|
||||
return expected_undelivered.write_site_file(site)
|
||||
stale = os.path.join(
|
||||
DOWNLOAD_DIR, expected_undelivered.SITE_UNDELIVERED_FILE.format(name=site)
|
||||
)
|
||||
if os.path.exists(stale):
|
||||
os.remove(stale)
|
||||
return False
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
# 「跑比对」= 纯离线比对(用 downloads/ 现有文件生成全站汇总;下载交由各站定时/手动)。
|
||||
|
||||
|
||||
TASK_HANDLERS = {
|
||||
("顺心", "expected"): _web_handler("顺心", shunxin.shunxin_expected_download),
|
||||
("顺心", "actual"): _web_handler("顺心", shunxin.shunxin_actual_download),
|
||||
("顺心", "undelivered"): _site_undelivered_handler("顺心"),
|
||||
("百世", "undelivered"): _web_handler(
|
||||
"百世", baishi.baishi_download_undelivered_data
|
||||
),
|
||||
("中通", "expected"): _web_handler("中通", zto.zto_expected_download),
|
||||
("中通", "actual"): _web_handler("中通", zto.zto_actual_download),
|
||||
("中通", "undelivered"): _site_undelivered_handler("中通"),
|
||||
("韵达", "expected"): _web_handler("韵达", yunda.yunda_expected_download),
|
||||
("韵达", "actual"): _web_handler("韵达", yunda.yunda_actual_download),
|
||||
("韵达", "undelivered"): _site_undelivered_handler("韵达"),
|
||||
("安能", "expected"): lambda ctx: anneng.anneng_expected_download(),
|
||||
("安能", "actual"): lambda ctx: anneng.anneng_actual_download(),
|
||||
("安能", "undelivered"): _site_undelivered_handler("安能"),
|
||||
("__compare__", "compare"): lambda ctx: (expected_undelivered.main() or True),
|
||||
}
|
||||
|
||||
|
||||
def _record_business_date(site, kind):
|
||||
"""下载成功后,把本次数据的业务日期快照写进状态库(供前端/报告显示「是哪天的数据」)。
|
||||
业务日期 = 下载当天 − 该数据对应的日期偏移。__compare__ 无数据概念,跳过。
|
||||
|
||||
kind → 写入:
|
||||
expected/actual:各写自己一列(偏移各取其列)。
|
||||
undelivered:百世直供(恒 0)写 undelivered;4 站未到由 _site_undelivered_handler
|
||||
内部连带下了 expected+actual(不经 dispatch,无业务日期写入),故此处一并补写
|
||||
expected/actual/undelivered 三列——actual 用 actual 偏移、未到跟随 expected 偏移。
|
||||
顺带置 ready=True,让前端不必等心跳即可反映下载成功;写入失败仅告警、不影响任务判定。"""
|
||||
if site == "__compare__":
|
||||
return
|
||||
today = datetime.now().date()
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _write(k, off):
|
||||
biz = (today - timedelta(days=off)).strftime("%Y-%m-%d")
|
||||
try:
|
||||
state_store.set_data_state(
|
||||
site, k, ready=True, generated_at=now, business_date=biz
|
||||
)
|
||||
except Exception as e:
|
||||
print(f">> [状态] 写业务日期失败 {site}/{k}: {e}")
|
||||
|
||||
if kind == "expected":
|
||||
_write("expected", state_store.get_offset(site, "expected"))
|
||||
elif kind == "actual":
|
||||
_write("actual", state_store.get_offset(site, "actual"))
|
||||
elif site == "百世":
|
||||
_write("undelivered", 0)
|
||||
else: # 4 站 undelivered:连带补写 expected/actual/undelivered 三列
|
||||
_write("expected", state_store.get_offset(site, "expected"))
|
||||
_write("actual", state_store.get_offset(site, "actual"))
|
||||
_write("undelivered", state_store.get_offset(site, "expected"))
|
||||
|
||||
|
||||
def dispatch_task(ctx, task_spec):
|
||||
"""执行一条任务。task_spec = {"site", "kind"}。返回 (status, error)。
|
||||
|
||||
掉登录的站点直接判 failed(呼应"掉登录则该站任务全停")。
|
||||
只在 Playwright 所属线程调用。
|
||||
"""
|
||||
site = task_spec.get("site")
|
||||
kind = task_spec.get("kind")
|
||||
if site != "__compare__":
|
||||
if site not in ctx.pages_map:
|
||||
return (state_store.TASK_FAILED, f"站点 {site} 未加载")
|
||||
if not probe_site_login(site, ctx.pages_map):
|
||||
return (
|
||||
state_store.TASK_FAILED,
|
||||
f"站点 {site} 未登录,已跳过(需人工重登)",
|
||||
)
|
||||
handler = TASK_HANDLERS.get((site, kind))
|
||||
if handler is None:
|
||||
return (state_store.TASK_FAILED, f"未知任务: {site}/{kind}")
|
||||
try:
|
||||
ret = handler(ctx)
|
||||
if ret is False:
|
||||
return (state_store.TASK_FAILED, "任务执行失败(重试耗尽)")
|
||||
_record_business_date(site, kind)
|
||||
return (state_store.TASK_SUCCESS, None)
|
||||
except Exception as e:
|
||||
return (state_store.TASK_FAILED, str(e))
|
||||
|
||||
|
||||
# ============================ 心跳 ============================
|
||||
|
||||
|
||||
def run_heartbeat(ctx):
|
||||
"""一轮心跳:探测各站登录态 + 数据文件,写状态库;登录态变化时提示。
|
||||
|
||||
只在 Playwright 所属线程调用。
|
||||
"""
|
||||
prev = state_store.get_all_status()
|
||||
for site_name in ctx.sites_to_watch:
|
||||
logged_in = probe_site_login(site_name, ctx.pages_map)
|
||||
prev_login = prev.get(site_name, {}).get("login_state")
|
||||
state_store.set_login_state(site_name, logged_in)
|
||||
now_login = state_store.LOGIN_IN if logged_in else state_store.LOGIN_OUT
|
||||
if prev_login and prev_login not in (now_login, state_store.LOGIN_UNKNOWN):
|
||||
print(f"\n ⚠️【{site_name}】登录态变化: {prev_login} → {now_login}")
|
||||
for kind in ("expected", "actual", "undelivered"):
|
||||
ready, gen_at = probe_data_file(site_name, kind)
|
||||
state_store.set_data_state(site_name, kind, ready, gen_at)
|
||||
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(" 临时文件已清理。")
|
||||
441
inbound_verify/state_store.py
Normal file
441
inbound_verify/state_store.py
Normal file
@@ -0,0 +1,441 @@
|
||||
# state_store.py
|
||||
# 阶段0:站点状态持久化(SQLite)。记录各站登录态 + 应到/实到数据文件生成时间,
|
||||
# 供后台心跳刷新、菜单状态盘展示,以及未来 FastAPI / Web 前端读取。
|
||||
# 重启不丢——程序重启后状态从本库恢复(登录态会随心跳重新探测校正)。
|
||||
#
|
||||
# 设计:纯 Python(sqlite3 标准库,无新依赖),每次读写开短连接,主线程使用。
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
from inbound_verify.paths import STATE_DB_PATH
|
||||
|
||||
# 登录态枚举
|
||||
LOGIN_UNKNOWN = "unknown" # 尚未探测过
|
||||
LOGIN_IN = "logged_in"
|
||||
LOGIN_OUT = "logged_out"
|
||||
|
||||
# 任务状态枚举(task_history.status)
|
||||
TASK_PENDING = "pending" # 已入队,待执行
|
||||
TASK_RUNNING = "running" # 正在执行
|
||||
TASK_SUCCESS = "success" # 成功(有数据)
|
||||
TASK_NO_DATA = "no_data" # 成功但本站本次无数据
|
||||
TASK_FAILED = "failed" # 失败(重试耗尽 / 未登录 / 异常)
|
||||
|
||||
|
||||
def _now():
|
||||
"""本地时间的字符串(到秒),用于时间戳列。"""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def init_db():
|
||||
"""建库建表(幂等)。确保 state 目录存在。"""
|
||||
os.makedirs(os.path.dirname(STATE_DB_PATH), exist_ok=True)
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS site_status (
|
||||
site TEXT PRIMARY KEY,
|
||||
login_state TEXT,
|
||||
login_checked_at TEXT,
|
||||
expected_ready INTEGER,
|
||||
expected_generated_at TEXT,
|
||||
actual_ready INTEGER,
|
||||
actual_generated_at TEXT,
|
||||
undelivered_ready INTEGER,
|
||||
undelivered_generated_at TEXT,
|
||||
expected_business_date TEXT,
|
||||
actual_business_date TEXT,
|
||||
undelivered_business_date TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
# 旧库迁移:补 undelivered 两列(新库已含;重复添加抛 OperationalError,忽略)
|
||||
for _col, _typedef in [
|
||||
("undelivered_ready", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("undelivered_generated_at", "TEXT NOT NULL DEFAULT ''"),
|
||||
("expected_business_date", "TEXT NOT NULL DEFAULT ''"),
|
||||
("actual_business_date", "TEXT NOT NULL DEFAULT ''"),
|
||||
("undelivered_business_date", "TEXT NOT NULL DEFAULT ''"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE site_status ADD COLUMN {_col} {_typedef}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS task_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site TEXT,
|
||||
kind TEXT,
|
||||
status TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
error TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS site_config (
|
||||
site TEXT PRIMARY KEY,
|
||||
expected_offset INTEGER NOT NULL DEFAULT 0,
|
||||
actual_offset INTEGER NOT NULL DEFAULT 0,
|
||||
schedule_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
schedule_time TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
# 旧库迁移:补 schedule 两列 + expected/actual 偏移(新库已含;重复添加抛错忽略)
|
||||
for _col, _typedef in [
|
||||
("schedule_enabled", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("schedule_time", "TEXT NOT NULL DEFAULT ''"),
|
||||
("expected_offset", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("actual_offset", "INTEGER NOT NULL DEFAULT 0"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE site_config ADD COLUMN {_col} {_typedef}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# 旧库若有 date_offset 列,把值搬到 expected/actual(一次性;新库无此列则跳过)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE site_config SET expected_offset=date_offset, actual_offset=date_offset "
|
||||
"WHERE expected_offset=0 AND date_offset IS NOT NULL AND date_offset>0"
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS site_settings (
|
||||
site TEXT,
|
||||
key TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY (site, key)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _upsert(conn, site, **fields):
|
||||
"""更新(或插入)单站:保留未传字段,刷新 updated_at。"""
|
||||
row = conn.execute(
|
||||
"SELECT login_state, login_checked_at, expected_ready, expected_generated_at, "
|
||||
"expected_business_date, actual_ready, actual_generated_at, actual_business_date, "
|
||||
"undelivered_ready, undelivered_generated_at, undelivered_business_date "
|
||||
"FROM site_status WHERE site = ?",
|
||||
(site,),
|
||||
).fetchone()
|
||||
cur = {
|
||||
"login_state": LOGIN_UNKNOWN,
|
||||
"login_checked_at": "",
|
||||
"expected_ready": 0,
|
||||
"expected_generated_at": "",
|
||||
"expected_business_date": "",
|
||||
"actual_ready": 0,
|
||||
"actual_generated_at": "",
|
||||
"actual_business_date": "",
|
||||
"undelivered_ready": 0,
|
||||
"undelivered_generated_at": "",
|
||||
"undelivered_business_date": "",
|
||||
}
|
||||
if row:
|
||||
(
|
||||
cur["login_state"],
|
||||
cur["login_checked_at"],
|
||||
cur["expected_ready"],
|
||||
cur["expected_generated_at"],
|
||||
cur["expected_business_date"],
|
||||
cur["actual_ready"],
|
||||
cur["actual_generated_at"],
|
||||
cur["actual_business_date"],
|
||||
cur["undelivered_ready"],
|
||||
cur["undelivered_generated_at"],
|
||||
cur["undelivered_business_date"],
|
||||
) = row
|
||||
cur.update(fields)
|
||||
cur["updated_at"] = _now()
|
||||
|
||||
values = (
|
||||
site,
|
||||
cur["login_state"],
|
||||
cur["login_checked_at"],
|
||||
cur["expected_ready"],
|
||||
cur["expected_generated_at"],
|
||||
cur["expected_business_date"],
|
||||
cur["actual_ready"],
|
||||
cur["actual_generated_at"],
|
||||
cur["actual_business_date"],
|
||||
cur["undelivered_ready"],
|
||||
cur["undelivered_generated_at"],
|
||||
cur["undelivered_business_date"],
|
||||
cur["updated_at"],
|
||||
)
|
||||
if row:
|
||||
conn.execute(
|
||||
"UPDATE site_status SET login_state=?, login_checked_at=?, expected_ready=?, "
|
||||
"expected_generated_at=?, expected_business_date=?, actual_ready=?, "
|
||||
"actual_generated_at=?, actual_business_date=?, undelivered_ready=?, "
|
||||
"undelivered_generated_at=?, undelivered_business_date=?, updated_at=? "
|
||||
"WHERE site=?",
|
||||
values[1:] + (site,),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO site_status (site, login_state, login_checked_at, expected_ready, "
|
||||
"expected_generated_at, expected_business_date, actual_ready, actual_generated_at, "
|
||||
"actual_business_date, undelivered_ready, undelivered_generated_at, "
|
||||
"undelivered_business_date, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def set_login_state(site, logged_in):
|
||||
"""更新单站登录态。logged_in: bool。"""
|
||||
state = LOGIN_IN if logged_in else LOGIN_OUT
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
_upsert(conn, site, login_state=state, login_checked_at=_now())
|
||||
|
||||
|
||||
def reset_login_states(sites):
|
||||
"""启动时把给定站点的登录态重置为 unknown(避免显示上一会话的陈旧登录态)。
|
||||
登录态是会话级的;数据态(文件就绪)会话无关、保留不动,心跳就绪后会重新探测。"""
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
for site in sites:
|
||||
_upsert(conn, site, login_state=LOGIN_UNKNOWN, login_checked_at="")
|
||||
|
||||
|
||||
def set_data_state(site, kind, ready, generated_at, business_date=None):
|
||||
"""更新单站数据态。kind: 'expected'/'actual'/'undelivered';ready: bool;
|
||||
generated_at: str。business_date: str 或 None——None 时保留原值(心跳不覆盖下载快照)。"""
|
||||
fields = {
|
||||
f"{kind}_ready": 1 if ready else 0,
|
||||
f"{kind}_generated_at": generated_at or "",
|
||||
}
|
||||
if business_date is not None:
|
||||
fields[f"{kind}_business_date"] = business_date or ""
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
_upsert(conn, site, **fields)
|
||||
|
||||
|
||||
def get_all_status():
|
||||
"""返回 {site: {各字段}};库不存在则返回 {}。"""
|
||||
if not os.path.exists(STATE_DB_PATH):
|
||||
return {}
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT site, login_state, login_checked_at, expected_ready, "
|
||||
"expected_generated_at, expected_business_date, actual_ready, "
|
||||
"actual_generated_at, actual_business_date, undelivered_ready, "
|
||||
"undelivered_generated_at, undelivered_business_date, updated_at "
|
||||
"FROM site_status"
|
||||
).fetchall()
|
||||
return {
|
||||
r[0]: {
|
||||
"login_state": r[1],
|
||||
"login_checked_at": r[2],
|
||||
"expected_ready": bool(r[3]),
|
||||
"expected_generated_at": r[4],
|
||||
"expected_business_date": r[5],
|
||||
"actual_ready": bool(r[6]),
|
||||
"actual_generated_at": r[7],
|
||||
"actual_business_date": r[8],
|
||||
"undelivered_ready": bool(r[9]),
|
||||
"undelivered_generated_at": r[10],
|
||||
"undelivered_business_date": r[11],
|
||||
"updated_at": r[12],
|
||||
}
|
||||
for r in rows
|
||||
}
|
||||
|
||||
|
||||
# ============================ 下载日期偏移(site_config)============================
|
||||
|
||||
MAX_DATE_OFFSET = 30 # 0=今天,最大回溯 30 天
|
||||
|
||||
|
||||
def get_offset(site, kind="expected"):
|
||||
"""读取单站下载日期偏移(kind: 'expected'/'actual';0=今天,1=昨天…);未配置返回 0。"""
|
||||
col = "expected_offset" if kind == "expected" else "actual_offset"
|
||||
if not os.path.exists(STATE_DB_PATH):
|
||||
return 0
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
row = conn.execute(
|
||||
f"SELECT {col} FROM site_config WHERE site=?", (site,)
|
||||
).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def set_offset(site, kind, offset):
|
||||
"""设置单站下载日期偏移(kind: 'expected'/'actual'),钳制到 [0, MAX_DATE_OFFSET]。"""
|
||||
col = "expected_offset" if kind == "expected" else "actual_offset"
|
||||
offset = max(0, min(MAX_DATE_OFFSET, int(offset)))
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO site_config (site, {col}, updated_at) VALUES (?, ?, ?) "
|
||||
f"ON CONFLICT(site) DO UPDATE SET {col}=excluded.{col}, "
|
||||
f"updated_at=excluded.updated_at",
|
||||
(site, offset, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
return offset
|
||||
|
||||
|
||||
def set_schedule(site, enabled, time_str):
|
||||
"""设置单站每日定时下载(enabled: bool;time_str: 'HH:MM' 或 '')。"""
|
||||
enabled_int = 1 if enabled else 0
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO site_config (site, schedule_enabled, schedule_time, updated_at) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(site) DO UPDATE SET "
|
||||
"schedule_enabled=excluded.schedule_enabled, "
|
||||
"schedule_time=excluded.schedule_time, updated_at=excluded.updated_at",
|
||||
(site, enabled_int, time_str or "", _now()),
|
||||
)
|
||||
conn.commit()
|
||||
return bool(enabled_int), (time_str or "")
|
||||
|
||||
|
||||
def get_all_config():
|
||||
"""返回 {site: {expected_offset, actual_offset, schedule_enabled, schedule_time}}。"""
|
||||
if not os.path.exists(STATE_DB_PATH):
|
||||
return {}
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT site, expected_offset, actual_offset, schedule_enabled, schedule_time "
|
||||
"FROM site_config"
|
||||
).fetchall()
|
||||
return {
|
||||
r[0]: {
|
||||
"expected_offset": int(r[1]),
|
||||
"actual_offset": int(r[2]),
|
||||
"schedule_enabled": bool(r[3]),
|
||||
"schedule_time": r[4] or "",
|
||||
}
|
||||
for r in rows
|
||||
}
|
||||
|
||||
|
||||
# ============================ 站点键值配置(site_settings)============================
|
||||
# 各站专属配置(百世密码、韵达账密、安能 exe 路径…),由前端配置弹窗设置。
|
||||
|
||||
|
||||
def get_setting(site, key):
|
||||
"""读取单站某个配置值;未设置返回 ''。"""
|
||||
if not os.path.exists(STATE_DB_PATH):
|
||||
return ""
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM site_settings WHERE site=? AND key=?", (site, key)
|
||||
).fetchone()
|
||||
return row[0] if row else ""
|
||||
|
||||
|
||||
def set_setting(site, key, value):
|
||||
"""设置单站某个配置值(upsert)。"""
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO site_settings (site, key, value) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(site, key) DO UPDATE SET value=excluded.value",
|
||||
(site, key, value if value is not None else ""),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_site_settings(site):
|
||||
"""返回单站全部配置 {key: value}。"""
|
||||
if not os.path.exists(STATE_DB_PATH):
|
||||
return {}
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key, value FROM site_settings WHERE site=?", (site,)
|
||||
).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
# ============================ 任务历史 ============================
|
||||
|
||||
|
||||
def create_task(site, kind):
|
||||
"""新建一条 pending 任务,返回其 id。"""
|
||||
now = _now()
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO task_history (site, kind, status, started_at, finished_at, error) "
|
||||
"VALUES (?, ?, ?, ?, '', '')",
|
||||
(site, kind, TASK_PENDING, now),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_task(task_id, status, error=None):
|
||||
"""更新任务状态。终态(success/no_data/failed)写入 finished_at。"""
|
||||
finished = _now() if status in (TASK_SUCCESS, TASK_NO_DATA, TASK_FAILED) else ""
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
if finished:
|
||||
conn.execute(
|
||||
"UPDATE task_history SET status=?, finished_at=?, error=? WHERE id=?",
|
||||
(status, finished, error or "", task_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE task_history SET status=?, error=? WHERE id=?",
|
||||
(status, error or "", task_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_task(task_id):
|
||||
"""返回单条任务 dict,不存在返回 None。"""
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, site, kind, status, started_at, finished_at, error "
|
||||
"FROM task_history WHERE id=?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"site": row[1],
|
||||
"kind": row[2],
|
||||
"status": row[3],
|
||||
"started_at": row[4],
|
||||
"finished_at": row[5],
|
||||
"error": row[6],
|
||||
}
|
||||
|
||||
|
||||
def list_tasks(limit=20):
|
||||
"""返回最近 limit 条任务(按 id 倒序)。"""
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, site, kind, status, started_at, finished_at, error "
|
||||
"FROM task_history ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"site": r[1],
|
||||
"kind": r[2],
|
||||
"status": r[3],
|
||||
"started_at": r[4],
|
||||
"finished_at": r[5],
|
||||
"error": r[6],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def fail_stale_tasks(reason: str = "服务重启,上轮未完成任务,请手动重跑") -> int:
|
||||
"""worker 启动时调用:把遗留的 pending/running 任务标记为 failed,实现重启自愈。
|
||||
返回被清理的任务数量。"""
|
||||
with sqlite3.connect(STATE_DB_PATH) as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE task_history SET status=?, finished_at=?, error=? "
|
||||
"WHERE status IN (?, ?)",
|
||||
(TASK_FAILED, _now(), reason, TASK_PENDING, TASK_RUNNING),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
400
inbound_verify/store.py
Normal file
400
inbound_verify/store.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
db_store.py — 到货核销数据持久化(PostgreSQL)
|
||||
|
||||
职责:把 downloads/ 下各站点下载的应到 / 实到 / 未到 Excel 解析后,幂等写入 PostgreSQL。
|
||||
与下载流程解耦:本模块只读 downloads/ 现有文件入库,不关心谁触发下载、下载了几次。
|
||||
|
||||
设计要点:
|
||||
- 三张表:expected_record(运单级)/ actual_record(扫描件级)
|
||||
/ undelivered_record(百世站点直供未到明细,子单级)
|
||||
- 每行 = 统一核心列 + raw JSONB(站点原始全列,key=原列名,一字段不丢)
|
||||
- 幂等:业务唯一键 UPSERT,重复下载天然合并、零冗余
|
||||
- 单号一律按文本读写(dtype=str),防长数字被科学计数 / 精度丢失
|
||||
|
||||
命令行:
|
||||
python db_store.py createdb 创建数据库(幂等)
|
||||
python db_store.py init 建表(幂等 CREATE TABLE IF NOT EXISTS)
|
||||
python db_store.py ingest [site] 入库全站或单站(幂等 UPSERT)
|
||||
python db_store.py all createdb → init → 全站 ingest 一条龙
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
|
||||
import pandas as pd
|
||||
import psycopg
|
||||
import yaml
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from inbound_verify.paths import BASE_DIR, CONFIG_PATH, DOWNLOAD_DIR
|
||||
|
||||
from inbound_verify import (
|
||||
expected_undelivered as eu,
|
||||
) # 复用站点 / 文件名 / 列映射 / 基号口径(单一来源)
|
||||
|
||||
SCHEMA_PATH = os.path.join(BASE_DIR, "schema.sql")
|
||||
|
||||
# 有应到 / 实到的 4 站(百世只有站点直供的未到明细,单独处理)
|
||||
ALL_SITES = ["顺心", "中通", "韵达", "安能"]
|
||||
|
||||
|
||||
# ============================== 配置 / 连接 ==============================
|
||||
|
||||
|
||||
def _load_pg_config():
|
||||
"""从 config.yaml 读 postgres 段;缺失项给默认。"""
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
raise FileNotFoundError(
|
||||
f"未找到配置文件 {CONFIG_PATH}(请参考 config.example.yaml 创建 config.yaml)"
|
||||
)
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
pg = cfg.get("postgres") or {}
|
||||
return {
|
||||
"host": pg.get("host", "127.0.0.1"),
|
||||
"port": int(pg.get("port", 5432)),
|
||||
"user": pg.get("user", "postgres"),
|
||||
"password": pg.get("password", ""),
|
||||
"dbname": pg.get("dbname", "CQHXDB"),
|
||||
"schema": pg.get("schema", "inbound_verify"),
|
||||
}
|
||||
|
||||
|
||||
def _connect(dbname):
|
||||
"""用关键字参数连接(避开 conninfo 对密码特殊字符的解析)。
|
||||
options 设 search_path 到专用 schema,使无 schema 限定的表名解析到该 schema。"""
|
||||
c = _load_pg_config()
|
||||
return psycopg.connect(
|
||||
host=c["host"],
|
||||
port=c["port"],
|
||||
dbname=dbname,
|
||||
user=c["user"],
|
||||
password=c["password"],
|
||||
options=f"-c search_path={c['schema']}",
|
||||
)
|
||||
|
||||
|
||||
# ============================== 建库 / 建表 ==============================
|
||||
|
||||
|
||||
def create_database():
|
||||
"""连接维护库 postgres,创建目标数据库(幂等)。"""
|
||||
c = _load_pg_config()
|
||||
target = c["dbname"]
|
||||
with _connect("postgres") as conn: # autocommit:CREATE DATABASE 不能在事务里
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (target,))
|
||||
if cur.fetchone():
|
||||
print(f">> [db] 数据库 {target} 已存在,跳过创建")
|
||||
return
|
||||
cur.execute(f'CREATE DATABASE "{target}"')
|
||||
print(f">> [db] 已创建数据库 {target}")
|
||||
|
||||
|
||||
def init_schema():
|
||||
"""在目标库执行 schema.sql(幂等)。"""
|
||||
with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
|
||||
sql = f.read()
|
||||
with _connect(_load_pg_config()["dbname"]) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
conn.commit()
|
||||
print(">> [db] 表结构已就绪")
|
||||
|
||||
|
||||
# ============================== 解析辅助 ==============================
|
||||
|
||||
# 实到单号列 / 基号列映射(口径取自 expected_undelivered):
|
||||
# piece = 实到表里「每件」的单号列(扫描单号 / 子单号 / 复合串)
|
||||
# waybill = 与应到运单号对齐的干净列(中通无干净列,由 piece 复合串 v[:-8] 推导)
|
||||
# scan_time = 扫描时间列(缺失则不填,原始值仍在 raw)
|
||||
# scan_site = 扫描网点列
|
||||
ACTUAL_COLMAP = {
|
||||
"中通": {
|
||||
"piece": "运单号",
|
||||
"waybill": None,
|
||||
"scan_time": "扫描时间",
|
||||
"scan_site": "扫描网点",
|
||||
},
|
||||
"顺心": {
|
||||
"piece": "子单号",
|
||||
"waybill": "运单号",
|
||||
"scan_time": "操作时间",
|
||||
"scan_site": "操作网点",
|
||||
},
|
||||
"韵达": {
|
||||
"piece": "子单号",
|
||||
"waybill": "主单号",
|
||||
"scan_time": "扫描时间",
|
||||
"scan_site": "扫描站点",
|
||||
},
|
||||
"安能": {
|
||||
"piece": "扫描单号",
|
||||
"waybill": "所属单号",
|
||||
"scan_time": "扫描时间",
|
||||
"scan_site": "扫描网点",
|
||||
},
|
||||
}
|
||||
|
||||
_TIME_FMTS = (
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M",
|
||||
"%Y/%m/%d %H:%M:%S",
|
||||
"%Y/%m/%d %H:%M",
|
||||
"%Y-%m-%d",
|
||||
"%Y/%m/%d",
|
||||
)
|
||||
|
||||
|
||||
def _to_int(v):
|
||||
"""尽力把单元格转 int(件数);空 / 非数返回 None。"""
|
||||
s = str(v).strip().replace(",", "")
|
||||
if s in ("", "-", "nan", "None"):
|
||||
return None
|
||||
try:
|
||||
return int(float(s))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_time(v):
|
||||
"""尽力解析多种时间格式为 datetime;失败返回 None(原始值在 raw 里)。"""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, datetime):
|
||||
return v
|
||||
s = str(v).strip()
|
||||
if not s or s in ("nan", "NaT"):
|
||||
return None
|
||||
for fmt in _TIME_FMTS:
|
||||
try:
|
||||
return datetime.strptime(s, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try: # 兜底:交给 pandas 推断
|
||||
return pd.to_datetime(s).to_pydatetime()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(v).strip(), "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _raw_row(row):
|
||||
"""把一行原始记录转成 JSONB 兼容 dict(key=原列名;丢空值,保留全部有值字段)。"""
|
||||
out = {}
|
||||
for k, v in row.items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, float) and pd.isna(v):
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s == "":
|
||||
continue
|
||||
if isinstance(v, (datetime, date)):
|
||||
out[str(k)] = v.isoformat()
|
||||
else:
|
||||
out[str(k)] = v
|
||||
return out
|
||||
|
||||
|
||||
def _read_business_dates():
|
||||
"""从状态库读各站本次业务日期(与报告口径一致;读不到返回空 dict)。"""
|
||||
try:
|
||||
return eu._read_business_dates(ALL_SITES + ["百世"]) or {}
|
||||
except Exception as e:
|
||||
print(f">> [warn] 读取业务日期失败(不影响入库): {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# ============================== UPSERT SQL ==============================
|
||||
|
||||
_SQL_EXPECTED = """
|
||||
INSERT INTO expected_record
|
||||
(site, waybill_no, handover_no, handover_pieces, order_pieces, business_date, raw)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT (site, waybill_no) DO UPDATE SET
|
||||
handover_no = EXCLUDED.handover_no,
|
||||
handover_pieces = EXCLUDED.handover_pieces,
|
||||
order_pieces = EXCLUDED.order_pieces,
|
||||
business_date = COALESCE(EXCLUDED.business_date, expected_record.business_date),
|
||||
raw = EXCLUDED.raw,
|
||||
ingested_at = now()
|
||||
"""
|
||||
|
||||
_SQL_ACTUAL = """
|
||||
INSERT INTO actual_record
|
||||
(site, waybill_no, piece_no, scan_time, scan_site, raw)
|
||||
VALUES (%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT (site, piece_no) DO UPDATE SET
|
||||
waybill_no = COALESCE(EXCLUDED.waybill_no, actual_record.waybill_no),
|
||||
scan_time = EXCLUDED.scan_time,
|
||||
scan_site = EXCLUDED.scan_site,
|
||||
raw = EXCLUDED.raw,
|
||||
ingested_at = now()
|
||||
"""
|
||||
|
||||
_SQL_UNDELIVERED = """
|
||||
INSERT INTO undelivered_record
|
||||
(site, waybill_no, piece_no, biz_type, last_scan, raw)
|
||||
VALUES (%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT (site, piece_no) DO UPDATE SET
|
||||
waybill_no = COALESCE(EXCLUDED.waybill_no, undelivered_record.waybill_no),
|
||||
biz_type = EXCLUDED.biz_type,
|
||||
last_scan = EXCLUDED.last_scan,
|
||||
raw = EXCLUDED.raw,
|
||||
ingested_at = now()
|
||||
"""
|
||||
|
||||
|
||||
# ============================== 入库 ==============================
|
||||
|
||||
|
||||
def _ingest_expected(cur, site, business_date):
|
||||
"""入库单站应到(运单级,按 waybill_no 去重 keep-first 后 UPSERT)。"""
|
||||
cfg = eu._site_cfg(site)
|
||||
path = os.path.join(DOWNLOAD_DIR, cfg["exp"])
|
||||
if not os.path.exists(path):
|
||||
print(f" [跳过] {site} 应到:文件不存在 {cfg['exp']}")
|
||||
return 0
|
||||
df = pd.read_excel(path, dtype=str).fillna("")
|
||||
df = df.drop_duplicates(subset=[cfg["exp_wb"]], keep="first")
|
||||
biz = _parse_date(business_date)
|
||||
rows = []
|
||||
for r in df.to_dict("records"):
|
||||
wb = str(r.get(cfg["exp_wb"], "")).strip()
|
||||
if not wb:
|
||||
continue
|
||||
rows.append(
|
||||
(
|
||||
site,
|
||||
wb,
|
||||
str(r.get(cfg["exp_jd"], "")).strip() or None,
|
||||
_to_int(r.get(cfg["exp_qty"])),
|
||||
_to_int(r.get("录单件数")),
|
||||
biz,
|
||||
Jsonb(_raw_row(r)),
|
||||
)
|
||||
)
|
||||
if rows:
|
||||
cur.executemany(_SQL_EXPECTED, rows)
|
||||
print(f" [应到] {site}:{len(rows)} 条运单")
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ingest_actual(cur, site):
|
||||
"""入库单站实到(扫描件级,按 piece_no UPSERT)。"""
|
||||
cfg = eu._site_cfg(site)
|
||||
path = os.path.join(DOWNLOAD_DIR, cfg["act"])
|
||||
if not os.path.exists(path):
|
||||
print(f" [跳过] {site} 实到:文件不存在 {cfg['act']}")
|
||||
return 0
|
||||
cm = ACTUAL_COLMAP[site]
|
||||
df = pd.read_excel(path, dtype=str).fillna("")
|
||||
if site == "韵达":
|
||||
# 韵达业务清洗:抛弃「交接单号」为空的行(派件/签收等其他扫描无交接单号),
|
||||
# 再按子单号去重(一件多扫只留一条;清洗后子单号已天然唯一,drop 为保险)。
|
||||
df = df[df["交接单号"].astype(str).str.strip() != ""]
|
||||
df = df.drop_duplicates(subset=[cm["piece"]], keep="last")
|
||||
rows = []
|
||||
for r in df.to_dict("records"):
|
||||
piece = str(r.get(cm["piece"], "")).strip()
|
||||
if not piece:
|
||||
continue
|
||||
if site == "中通": # 复合串 H+运单号(12)+总数(4)+顺序(4):基号 = v[:-8]
|
||||
waybill = piece[:-8] if (len(piece) > 8 and piece[-4:].isdigit()) else piece
|
||||
else:
|
||||
waybill = str(r.get(cm["waybill"], "")).strip() or None
|
||||
rows.append(
|
||||
(
|
||||
site,
|
||||
waybill,
|
||||
piece,
|
||||
_parse_time(r.get(cm["scan_time"])),
|
||||
str(r.get(cm["scan_site"], "")).strip() or None,
|
||||
Jsonb(_raw_row(r)),
|
||||
)
|
||||
)
|
||||
if rows:
|
||||
cur.executemany(_SQL_ACTUAL, rows)
|
||||
print(f" [实到] {site}:{len(rows)} 条扫描")
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ingest_undelivered_baishi(cur):
|
||||
"""入库百世应到未到明细(子单级,按 (site, piece_no) UPSERT)。"""
|
||||
path = os.path.join(DOWNLOAD_DIR, eu.BAISHI_FILE)
|
||||
if not os.path.exists(path):
|
||||
print(f" [跳过] 百世 未到:文件不存在 {eu.BAISHI_FILE}")
|
||||
return 0
|
||||
df = pd.read_excel(path, dtype=str).fillna("")
|
||||
rows = []
|
||||
for r in df.to_dict("records"):
|
||||
rows.append(
|
||||
(
|
||||
"百世",
|
||||
str(r.get("运单号", "")).strip() or None,
|
||||
str(r.get("子单号", "")).strip() or None,
|
||||
str(r.get("类型", "")).strip() or None,
|
||||
str(r.get("最新扫描记录", "")).strip() or None,
|
||||
Jsonb(_raw_row(r)),
|
||||
)
|
||||
)
|
||||
if rows:
|
||||
cur.executemany(_SQL_UNDELIVERED, rows)
|
||||
print(f" [未到] 百世:{len(rows)} 条")
|
||||
return len(rows)
|
||||
|
||||
|
||||
def ingest(site=None):
|
||||
"""入库:指定 site 则单站(百世只入未到),否则全站。返回总条数。"""
|
||||
dates = _read_business_dates()
|
||||
total = 0
|
||||
with _connect(_load_pg_config()["dbname"]) as conn:
|
||||
with conn.cursor() as cur:
|
||||
sites = ALL_SITES if site in (None, "百世") else [site]
|
||||
for s in sites:
|
||||
if s not in ALL_SITES:
|
||||
print(f" [跳过] 不支持应到/实到的站点: {s}")
|
||||
continue
|
||||
total += _ingest_expected(cur, s, dates.get(s))
|
||||
total += _ingest_actual(cur, s)
|
||||
if site in (None, "百世"):
|
||||
total += _ingest_undelivered_baishi(cur)
|
||||
conn.commit()
|
||||
print(f">> [ingest] 完成,共 {total} 条")
|
||||
return total
|
||||
|
||||
|
||||
# ============================== 命令行 ==============================
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "all"
|
||||
site = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
if cmd == "createdb":
|
||||
create_database()
|
||||
elif cmd == "init":
|
||||
init_schema()
|
||||
elif cmd == "ingest":
|
||||
ingest(site)
|
||||
elif cmd == "all":
|
||||
create_database()
|
||||
init_schema()
|
||||
ingest()
|
||||
else:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user