初始提交: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
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""QuickTeam 接口探针(只读)。
用途:在不动任何业务数据的前提下,验证「通过接口驱动 QuickTeam」的可行性,
并输出一份「本 Sprint 各项目工单现状」清单(plan 阶段的雏形)。
只调用以下只读接口:
usp_Authentication / usp_GetTeamsByUserId / usp_GetProjectsByTeamId
usp_GetWeeklyWorkSheetsByProjectId / usp_GetWorkSheetById / usp_GetProjectUsersByProjectId
可选(--upload-test)额外验证文件上传通道,会在服务器 WorkSheetImport 目录留一个临时文件,
不触发任何解析或写库动作。
用法:
python tools/api_probe.py --config config/config.json
python tools/api_probe.py --config config/config.json --sprint "2026 Sprint 10" --dir "D:/Work/QuickTeam/2026 Sprint 10"
python tools/api_probe.py --config config/config.json --upload-test
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------------------
# 传输层:WCF + 通用存储过程网关
# ---------------------------------------------------------------------------
class QtError(RuntimeError):
"""接口/业务错误。"""
@dataclass
class QtClient:
base_url: str
timeout: int = 60
retries: int = 2
# WCF 服务路径(来自前端 app.js 的 a078 配置模块)
common_url: str = "/Services/CommonService.svc/"
file_url: str = "/Services/FileOperationService.svc/"
# -- 底层 ---------------------------------------------------------------
def _post(self, path: str, payload: Any = None) -> str:
url = self.base_url.rstrip("/") + path
data = b"" if payload is None else json.dumps(payload).encode("utf-8")
last: Exception | None = None
for attempt in range(self.retries + 1):
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return resp.read().decode("utf-8-sig", "replace")
except urllib.error.HTTPError as exc:
raise QtError(self._extract_error(exc)) from None
except (urllib.error.URLError, TimeoutError) as exc: # 网络层可重试
last = exc
if attempt < self.retries:
time.sleep(1.5 * (attempt + 1))
continue
raise QtError(f"网络不可达:{exc}") from None
raise QtError(str(last))
@staticmethod
def _extract_error(exc: urllib.error.HTTPError) -> str:
body = exc.read().decode("utf-8-sig", "replace")
# WCF 返回 HTML 错误页,真正的错误在 "The exception message is '...'"
match = re.search(r"exception message is '(.*?)'.?\s*See server logs", body, re.S)
if match:
return f"HTTP {exc.code}: {match.group(1).strip()}"
plain = re.sub(r"<[^>]+>", " ", body)
plain = re.sub(r"\s+", " ", plain).strip()
return f"HTTP {exc.code}: {plain[-300:]}"
@staticmethod
def _decode(raw: str) -> Any:
"""WCF 返回值常是「JSON 字符串里再套一层 JSON」,最多解三层。"""
value: Any = raw.strip()
for _ in range(3):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
break
return value
def _exec(self, conn: str, sp: str, params: list[tuple[str, Any, str]]) -> Any:
"""调用存储过程。params: [(Name, Value, DBType), ...]"""
inner = {
"SPName": sp,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": conn,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
return self._decode(self._post(self.common_url + "ExecTable", payload))
def _exec_result(self, conn: str, sp: str, params: list[tuple[str, Any, str]]) -> Any:
"""同 _exec,但走 ExecResult(标量/返回值语义,如 Result、Column1)。"""
inner = {
"SPName": sp,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": conn,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
return self._decode(self._post(self.common_url + "ExecResult", payload))
# -- 业务语义 -----------------------------------------------------------
def login(self, username: str, password: str) -> dict:
digest = hashlib.md5(password.encode("utf-8")).hexdigest()
rows = self._exec(
"UserCenter",
"dbo.usp_Authentication",
[("UserName", username, "string"), ("Password", digest, "string")],
)
if not rows:
raise QtError("登录失败:账号或密码不匹配")
return rows[0]
def teams(self, user_id: str) -> list[dict]:
return self._exec("QuickTeam", "QuickTeam.usp_GetTeamsByUserId", [("UserId", user_id, "string")]) or []
def projects(self, team_id: str) -> list[dict]:
return self._exec(
"QuickTeam",
"QuickTeam.usp_GetProjectsByTeamId",
[("TeamId", team_id, "string"), ("IsTemplate", "1", "string")],
) or []
def worksheets(self, project_id: str, start: str, end: str) -> list[dict]:
return self._exec(
"QuickTeam",
"QuickTeam.usp_GetWeeklyWorkSheetsByProjectId",
[("ProjectId", project_id, "string"), ("Monday", start, "string"), ("Sunday", end, "string")],
) or []
def worksheet(self, worksheet_id: int) -> dict | None:
rows = self._exec("QuickTeam", "QuickTeam.usp_GetWorkSheetById", [("WorkSheetId", worksheet_id, "int")])
return rows[0] if rows else None
def project_users(self, project_id: str) -> list[dict]:
return self._exec(
"QuickTeam", "QuickTeam.usp_GetProjectUsersByProjectId", [("ProjectId", project_id, "string")]
) or []
# -- 文件通道 -----------------------------------------------------------
def upload(self, local_path: str, folder: str = "WorkSheetImport") -> str:
"""上传文件,返回服务器端绝对路径(BatchImportFeature 的 filePath)。"""
name = f"{int(time.time() * 1000)}{os.path.splitext(local_path)[1]}"
url = (
f"{self.base_url.rstrip('/')}{self.file_url}FileUpload"
f"?fileName={urllib.parse.quote(name)}&fileFolder={urllib.parse.quote(folder)}"
)
boundary = "----qtprobe" + hashlib.md5(name.encode()).hexdigest()
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
).encode("utf-8")
body += open(local_path, "rb").read()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
req = urllib.request.Request(
url, data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
data = self._decode(resp.read().decode("utf-8-sig", "replace"))
except urllib.error.HTTPError as exc:
raise QtError(self._extract_error(exc)) from None
path = (data or {}).get("FileUploadResult")
if not path:
raise QtError(f"上传未返回 FileUploadResult:{data!r}")
return path
# ---------------------------------------------------------------------------
# 报表
# ---------------------------------------------------------------------------
def ms_to_date(value: Any) -> str:
"""QuickTeam 日期为 /Date(1785081600000)/ 形式。"""
if not value:
return ""
match = re.search(r"\((\d+)", str(value))
if not match:
return str(value)
return time.strftime("%Y-%m-%d", time.localtime(int(match.group(1)) / 1000))
def list_xlsx(folder: str) -> list[str]:
if not os.path.isdir(folder):
raise SystemExit(f"目录不存在:{folder}")
names = []
for entry in sorted(os.listdir(folder)):
path = os.path.join(folder, entry)
if os.path.isfile(path) and entry.lower().endswith(".xlsx") and not entry.startswith("~$"):
names.append(path)
return names
def normalize(name: str) -> str:
return re.sub(r"\s+", "", name).replace("-", "-").replace("—", "-")
@dataclass
class Row:
project: str
file: str
project_id: str = ""
existing: int = 0
has_sprint: bool = False
worksheet_ids: list[int] = field(default_factory=list)
note: str = ""
def main() -> int:
parser = argparse.ArgumentParser(description="QuickTeam 接口探针(只读)")
parser.add_argument("--config", default="config/config.json", help="配置文件路径")
parser.add_argument("--sprint", help="覆盖配置中的 sprint 名称(SystemName)")
parser.add_argument("--dir", dest="folder", help="覆盖配置中的 xlsx 目录")
parser.add_argument("--team", help="覆盖配置中的团队名称")
parser.add_argument("--upload-test", action="store_true", help="额外验证上传通道(会留一个临时文件)")
parser.add_argument("--json-out", help="把结果另存为 JSON")
args = parser.parse_args()
if not os.path.exists(args.config):
raise SystemExit(f"缺少配置文件:{args.config}(可从 config/config.example.json 复制)")
with open(args.config, encoding="utf-8-sig") as handle:
cfg = json.load(handle)
sprint = args.sprint or cfg["sprint"]
folder = args.folder or cfg["work_dir"]
team_name = args.team or cfg.get("team_name", "当前项目")
start, end = cfg.get("start_time", ""), cfg.get("end_time", "")
if not start or not end:
today = time.strftime("%Y-%m-%d")
start, end = "2020-01-01", today
client = QtClient(cfg["base_url"], timeout=int(cfg.get("timeout", 60)))
t0 = time.time()
user = client.login(cfg["username"], cfg["password"])
t_login = time.time() - t0
print(f"[登录] {user['RealName']} <{user['UserName']}> UserId={user['GuidId']} {t_login:.2f}s")
t1 = time.time()
teams = client.teams(user["GuidId"])
team = next((x for x in teams if x.get("TeamName") == team_name), None)
if not team:
raise SystemExit(f"未找到团队「{team_name}」,可选:{[x.get('TeamName') for x in teams]}")
t_teams = time.time() - t1
t2 = time.time()
projects = client.projects(team["TeamId"])
t_projects = time.time() - t2
print(f"[团队] {team['TeamName']} ({team['TeamId']}) {t_teams:.2f}s")
print(f"[项目] 共 {len(projects)} 个 {t_projects:.2f}s")
by_name = {normalize(p["ProjectName"]): p for p in projects}
rows: list[Row] = []
t3 = time.time()
for path in list_xlsx(folder):
stem = os.path.splitext(os.path.basename(path))[0]
row = Row(project=stem, file=os.path.basename(path))
hit = by_name.get(normalize(stem))
if not hit:
row.note = "项目名在 QuickTeam 中不存在"
rows.append(row)
continue
row.project_id = hit["ProjectId"]
sheets = client.worksheets(row.project_id, start, end)
row.existing = len(sheets)
for sheet in sheets:
detail = client.worksheet(sheet["WorkSheetId"]) or {}
row.worksheet_ids.append(sheet["WorkSheetId"])
if detail.get("SystemName") == sprint:
row.has_sprint = True
rows.append(row)
t_scan = time.time() - t3
width = max([len(r.project) for r in rows] + [8]) + 2
print()
print(f"{'项目'.ljust(width)} {'工单数':>5} {'本Sprint':>8} 状态")
print("-" * (width + 34))
for row in rows:
if row.note:
state = f"!! {row.note}"
elif row.has_sprint:
state = "已存在(应跳过或续跑)"
else:
state = "待创建"
print(f"{row.project.ljust(width)} {row.existing:>5} {'是' if row.has_sprint else '否':>8} {state}")
todo = sum(1 for r in rows if not r.note and not r.has_sprint)
missing = sum(1 for r in rows if r.note)
print("-" * (width + 34))
print(f"合计 {len(rows)} 个文件:待创建 {todo},已存在 {len(rows) - todo - missing},异常 {missing}")
print(f"[耗时] 扫描 {t_scan:.2f}s 平均 {t_scan / max(len(rows), 1):.2f}s/项目")
if args.upload_test:
sample = next((p for p in list_xlsx(folder)), None)
if sample:
t4 = time.time()
remote = client.upload(sample)
print(f"\n[上传] {os.path.basename(sample)} -> {remote} {time.time() - t4:.2f}s(已上传但不解析)")
if args.json_out:
os.makedirs(os.path.dirname(os.path.abspath(args.json_out)), exist_ok=True)
with open(args.json_out, "w", encoding="utf-8") as handle:
json.dump(
{
"sprint": sprint,
"team": team["TeamName"],
"team_id": team["TeamId"],
"user": {"RealName": user["RealName"], "GuidId": user["GuidId"]},
"rows": [row.__dict__ for row in rows],
"timing": {
"login": round(t_login, 3),
"teams": round(t_teams, 3),
"projects": round(t_projects, 3),
"scan": round(t_scan, 3),
},
},
handle,
ensure_ascii=False,
indent=2,
)
print(f"\n结果已写入 {args.json_out}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except QtError as error:
print(f"[ERROR] {error}", file=sys.stderr)
sys.exit(2)
+109
View File
@@ -0,0 +1,109 @@
"""诊断工具:定位 BatchImportFeature 空引用异常。
只操作一张临时草稿工单,结束时删除,不影响业务数据。
用法:
python tools/diag_import.py # 建单→填单→读字段→试导入→删单
python tools/diag_import.py --keep # 保留草稿(便于在页面上比对)
"""
from __future__ import annotations
import argparse
import json
import sys
sys.path.insert(0, ".")
from qtimport.api import CONN_QUICKTEAM, SP_IMPORT_EXCEL, SP_WORKSHEET, QuickTeamApi # noqa: E402
from qtimport.config import Config # noqa: E402
from qtimport.transport import Transport # noqa: E402
def dump(title: str, rows) -> dict:
print(f"\n=== {title} ===")
if isinstance(rows, list) and rows:
print(json.dumps(rows[0], ensure_ascii=False, indent=2))
return rows[0]
print(json.dumps(rows, ensure_ascii=False, indent=2))
return {}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="config/config.json")
parser.add_argument("--project", default="资管 - 华泰保险资管")
parser.add_argument("--excel", default=r"D:\Work\QuickTeam\2026 Sprint 10\资管 - 华泰保险资管.xlsx")
parser.add_argument("--keep", action="store_true")
args = parser.parse_args()
cfg = Config.load(args.config)
t = Transport(cfg.base_url, timeout=cfg.timeout, retries=0)
api = QuickTeamApi(t)
user = api.login(cfg.username, cfg.password)
team = api.resolve_team(api.teams(user.user_id), cfg.team_name)
project = next(p for p in api.projects(team.team_id) if p.project_name == args.project)
print(f"登录:{user.real_name};项目:{project.project_name}")
worksheet_id = api.create_worksheet()
print(f"草稿工单:{worksheet_id}")
try:
dump("建单后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
[("WorkSheetId", worksheet_id, "int")], op="raw"))
evaluator, technical, customer = cfg.resolved_roles(user.real_name)
print(f"角色:评估={evaluator} 技术={technical} 客户={customer}")
result = api.fill_worksheet(
worksheet_id, project,
system_name=cfg.sprint, start_time=cfg.start_time, end_time=cfg.end_time,
evaluator=evaluator, technical_leader=technical, customer_leader=customer,
belong_team=cfg.belong_team, content=cfg.content_desc,
operator=user.real_name, service_type=cfg.service_type,
)
print(f"填单 Result={result}")
after = dump("填单后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
[("WorkSheetId", worksheet_id, "int")], op="raw"))
print(f"CurrentVersionId={after.get('CurrentVersionId')!r} VersionCode={after.get('VersionCode')!r}")
remote = api.upload_excel(args.excel)
print(f"\n上传成功:{remote}")
# 逐个尝试参数组合,看哪种不报空引用
variants = [
("VersionId=0", [("WorkSheetId", worksheet_id, "int"), ("VersionId", 0, "int")]),
("VersionId=-1", [("WorkSheetId", worksheet_id, "int"), ("VersionId", -1, "int")]),
("VersionId 省略", [("WorkSheetId", worksheet_id, "int")]),
("VersionId=空串", [("WorkSheetId", worksheet_id, "int"), ("VersionId", "", "string")]),
]
for label, params in variants:
print(f"\n--- 尝试 {label} ---")
try:
out = t.batch_import(SP_IMPORT_EXCEL, params, remote)
print("返回:", json.dumps(out, ensure_ascii=False))
if isinstance(out, list) and out and isinstance(out[0], dict) and int(out[0].get("Column1", -1)) == 0:
print(f"✔ {label} 成功")
break
except Exception as error: # noqa: BLE001
print(f"✘ {type(error).__name__}: {error}")
dump("导入后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
[("WorkSheetId", worksheet_id, "int")], op="raw"))
feats = api.features(worksheet_id)
print(f"功能模块数量:{len(feats)}")
for f in feats[:3]:
print(" -", f.feature_id, f.code, f.name)
finally:
if args.keep:
print(f"\n草稿保留:{worksheet_id}(请手工核对后删除)")
else:
try:
api.delete_worksheet(worksheet_id)
print(f"\n已删除草稿 {worksheet_id}")
except Exception as error: # noqa: BLE001
print(f"\n删除失败:{error}(请手工删除 {worksheet_id})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+59
View File
@@ -0,0 +1,59 @@
"""只读工具:导出 QuickTeam 存储过程源码。
网关 `ExecTable` 可以直接执行 `sys.sp_helptext`,因此无需数据库账号即可读到
SP 定义 —— 排查参数语义、确认字段来源时比猜快得多。
用法:
python tools/sp_source.py usp_VerifyWorkSheetById
python tools/sp_source.py usp_InsertWorkSheet usp_UpdateWorkSheetById --out docs/sp
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, ".")
from qtimport.config import Config # noqa: E402
from qtimport.transport import Transport # noqa: E402
def fetch(transport: Transport, name: str) -> str:
rows = transport.exec_table("QuickTeam", "sys.sp_helptext", [("objname", name, "string")], op="helptext")
if not isinstance(rows, list) or not rows:
return ""
return "".join(str(row.get("Text", "")) for row in rows)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("names", nargs="+")
parser.add_argument("--config", default="config/config.json")
parser.add_argument("--out", default="")
args = parser.parse_args()
cfg = Config.load(args.config)
transport = Transport(cfg.base_url, timeout=60, retries=1)
if args.out:
os.makedirs(args.out, exist_ok=True)
for name in args.names:
full = name if name.startswith("QuickTeam.") else f"QuickTeam.{name}"
text = fetch(transport, full)
if not text.strip():
print(f"[空] {full}(不存在或无权读取)")
continue
print(f"===== {full} =====")
print(text)
if args.out:
path = os.path.join(args.out, f"{name.split('.')[-1]}.sql")
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
print(f"→ 已保存 {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())