初始提交: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 个单测通过。
This commit is contained in:
chenshoulin
2026-11-05 19:14:25 +08:00
commit 58850314cf
32 changed files with 4590 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
"""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())