用「WCF + 通用存储过程网关」接口替代 Selenium(D://Learning//Python//工单自动化)导入工单: 单 Sprint 12 个项目 175s 跑完(旧方案 25~35 分钟),失败可断点续跑且不重复建单。 - 分层:transport(HTTP/协议)/ api(存储过程语义)/ domain(纯规则)/ workflow(阶段编排)/ store(SQLite 台账)/ report / CLI - 幂等:台账驱动断点续跑;dry-run 与跳过行不污染进度;--rollback-on-fail 清理半成品 - 命令:plan / apply / report / status,真实写入需 --yes - tools:只读探针、存储过程源码导出(sp_helptext)、导入诊断 - docs:调研与设计文档(含联调踩到的 5 个坑)+ 6 个关键存储过程源码 实测:Sprint 2026 Sprint 10 的 12 个项目全部完成建单→导入→确认→推送 TFS, 工单 225134–225145,35 个功能模块,重跑 plan 全部 skip-pushed。69 个单测通过。
207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
"""CLI:plan / apply / report / status。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import secrets
|
||
import sys
|
||
import time
|
||
|
||
from . import __version__
|
||
from .config import DEFAULT_CONFIG, Config
|
||
from .errors import QtError
|
||
from .report import RunLog, write_report
|
||
from .store import Store
|
||
from .workflow import Importer, RunOptions
|
||
|
||
|
||
def new_run_id() -> str:
|
||
return time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
|
||
|
||
|
||
def build_importer(args) -> tuple[Importer, RunLog, Store, str]:
|
||
cfg = Config.load(args.config)
|
||
overrides = {"sprint": args.sprint, "work_dir": args.folder, "team_name": args.team}
|
||
for key, value in overrides.items():
|
||
if value:
|
||
setattr(cfg, key, value)
|
||
if not cfg.sprint:
|
||
raise SystemExit("配置缺少 sprint(或传 --sprint)")
|
||
if not cfg.work_dir:
|
||
raise SystemExit("配置缺少 work_dir(或传 --dir)")
|
||
if getattr(args, "workers", None):
|
||
cfg.workers = args.workers
|
||
|
||
run_id = args.run_id or new_run_id()
|
||
store = Store(cfg.db_path)
|
||
log = RunLog(run_id, verbose=not args.quiet)
|
||
store.start_run(run_id, cfg.sprint, cfg.work_dir, dry_run=args.dry_run)
|
||
options = RunOptions(
|
||
adopt=args.adopt,
|
||
skip_tfs=args.skip_tfs,
|
||
rollback_on_fail=args.rollback_on_fail,
|
||
limit=args.limit,
|
||
projects=args.project or [],
|
||
force=args.force,
|
||
dry_run=args.dry_run,
|
||
)
|
||
importer = Importer(cfg, store, run_id, log, options)
|
||
return importer, log, store, run_id
|
||
|
||
|
||
def cmd_plan(args) -> int:
|
||
importer, log, store, run_id = build_importer(args)
|
||
try:
|
||
rows = importer.plan()
|
||
finally:
|
||
pass
|
||
|
||
width = max([len(row.project_name) for row in rows] + [8]) + 2
|
||
print(f"\nSprint:{importer.cfg.sprint} 目录:{importer.cfg.work_dir}")
|
||
print(f"{'项目'.ljust(width)}{'动作':<12}说明")
|
||
print("-" * (width + 60))
|
||
for row in rows:
|
||
print(f"{row.project_name.ljust(width)}{str(row.action):<12}{row.reason}")
|
||
print("-" * (width + 60))
|
||
|
||
counts: dict[str, int] = {}
|
||
for row in rows:
|
||
counts[str(row.action)] = counts.get(str(row.action), 0) + 1
|
||
print("合计 " + ",".join(f"{k} {v}" for k, v in sorted(counts.items())))
|
||
|
||
if args.json:
|
||
payload = [
|
||
{
|
||
"project": row.project_name,
|
||
"file": row.file_path,
|
||
"action": str(row.action),
|
||
"reason": row.reason,
|
||
"worksheet_id": row.worksheet.worksheet_id if row.worksheet else None,
|
||
}
|
||
for row in rows
|
||
]
|
||
path = args.json if isinstance(args.json, str) else f"reports/{run_id}/plan.json"
|
||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||
with open(path, "w", encoding="utf-8") as handle:
|
||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||
print(f"计划已写入 {path}")
|
||
|
||
log.close()
|
||
store.finish_run(run_id, "PLANNED")
|
||
store.close()
|
||
return 0
|
||
|
||
|
||
def cmd_apply(args) -> int:
|
||
if not args.dry_run and not args.yes:
|
||
print("这会真实写入 QuickTeam(建单/导入/确认/推送)。确认后加 --yes 重跑;"
|
||
"只想看会做什么请用 --dry-run。", file=sys.stderr)
|
||
return 3
|
||
|
||
importer, log, store, run_id = build_importer(args)
|
||
started = time.time()
|
||
status = "DONE"
|
||
try:
|
||
results = importer.apply()
|
||
failed = [r for r in results if r.stage.startswith("FAILED")]
|
||
if failed:
|
||
status = "PARTIAL"
|
||
print(f"\n执行完成:{len(results)} 个项目,失败 {len(failed)},"
|
||
f"耗时 {time.time() - started:.1f}s")
|
||
except QtError as error:
|
||
print(f"[中止] {error}", file=sys.stderr)
|
||
status = "ABORTED"
|
||
return 2
|
||
finally:
|
||
log.close()
|
||
store.finish_run(run_id, status)
|
||
md, csv = write_report(run_id, store)
|
||
print(f"报告:{md} / {csv}")
|
||
store.close()
|
||
return 0
|
||
|
||
|
||
def cmd_report(args) -> int:
|
||
cfg = Config.load(args.config, need_password=False)
|
||
store = Store(cfg.db_path)
|
||
run_id = args.run_id or store.last_run()
|
||
if not run_id:
|
||
print("没有可用的运行记录", file=sys.stderr)
|
||
return 1
|
||
md, csv = write_report(run_id, store)
|
||
print(f"已生成:{md}\n {csv}")
|
||
store.close()
|
||
return 0
|
||
|
||
|
||
def cmd_status(args) -> int:
|
||
cfg = Config.load(args.config, need_password=False)
|
||
store = Store(cfg.db_path)
|
||
run_id = args.run_id or store.last_run()
|
||
if not run_id:
|
||
print("没有可用的运行记录", file=sys.stderr)
|
||
return 1
|
||
jobs = store.jobs(run_id)
|
||
print(f"run={run_id} 项目 {len(jobs)} 个")
|
||
for job in jobs:
|
||
flag = "!" if job["error"] else " "
|
||
print(f"{flag} {job['project_name']:<28} {job['action']:<12} {job['stage']:<20} "
|
||
f"ws={job['worksheet_id'] or '-'}")
|
||
store.close()
|
||
return 0
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(prog="qtimport", description="QuickTeam 工单接口导入")
|
||
parser.add_argument("--version", action="version", version=f"qtimport {__version__}")
|
||
sub = parser.add_subparsers(dest="command", required=True)
|
||
|
||
def common(p: argparse.ArgumentParser) -> None:
|
||
p.add_argument("--config", default=DEFAULT_CONFIG, help="配置文件路径")
|
||
p.add_argument("--sprint", help="覆盖配置中的 Sprint 名称")
|
||
p.add_argument("--dir", dest="folder", help="覆盖配置中的 xlsx 目录")
|
||
p.add_argument("--team", help="覆盖配置中的团队名称")
|
||
p.add_argument("--run-id", help="复用已有 run_id(续跑)")
|
||
p.add_argument("--adopt", action="store_true", help="接管无台账记录的既有工单")
|
||
p.add_argument("--skip-tfs", action="store_true", help="不推送 TFS")
|
||
p.add_argument("--limit", type=int, help="最多执行几个项目")
|
||
p.add_argument("--project", action="append", help="只处理指定项目(可多次)")
|
||
p.add_argument("--force", action="store_true", help="跳过 Excel 模板预检")
|
||
p.add_argument("--dry-run", action="store_true", help="演练:不产生任何服务端写入")
|
||
p.add_argument("--quiet", action="store_true", help="只输出结果")
|
||
|
||
p_plan = sub.add_parser("plan", help="只读预演:列出每个项目会做什么")
|
||
common(p_plan)
|
||
p_plan.add_argument("--json", nargs="?", const=True, help="把计划写成 JSON")
|
||
p_plan.set_defaults(func=cmd_plan, rollback_on_fail=False)
|
||
|
||
p_apply = sub.add_parser("apply", help="执行导入")
|
||
common(p_apply)
|
||
p_apply.add_argument("--yes", action="store_true", help="确认真实写入")
|
||
p_apply.add_argument("--rollback-on-fail", action="store_true", help="失败时删除本次新建的半成品工单")
|
||
p_apply.add_argument("--workers", type=int, help="覆盖并发数(默认取配置)")
|
||
p_apply.set_defaults(func=cmd_apply, json=False)
|
||
|
||
p_report = sub.add_parser("report", help="生成报告")
|
||
p_report.add_argument("--config", default=DEFAULT_CONFIG)
|
||
p_report.add_argument("--run-id")
|
||
p_report.set_defaults(func=cmd_report, quiet=False)
|
||
|
||
p_status = sub.add_parser("status", help="查看最近一次运行")
|
||
p_status.add_argument("--config", default=DEFAULT_CONFIG)
|
||
p_status.add_argument("--run-id")
|
||
p_status.set_defaults(func=cmd_status, quiet=False)
|
||
|
||
args = parser.parse_args(argv)
|
||
try:
|
||
return args.func(args)
|
||
except QtError as error:
|
||
print(f"[ERROR] {error}", file=sys.stderr)
|
||
return 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|