Files
chenshoulin 58850314cf 初始提交:QuickTeam 工单接口导入工具
用「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 个单测通过。
2026-11-05 19:14:25 +08:00

207 lines
7.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())