初始提交: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
+454
View File
@@ -0,0 +1,454 @@
"""api 层:把存储过程封装成业务语义。
调用序列来自 QuickTeam 前端构建产物(chunk-0cadb1de / 039a8b29 / 9b10aab2),
每个方法对应的存储过程与参数见 docs/QuickTeam-API导入-调研与设计.md §2.4。
dry_run=True 时写操作只记录意图并返回桩值,不产生任何服务端副作用。
"""
from __future__ import annotations
import hashlib
from typing import Any, Callable
from .errors import Category, QtError
from .models import (
Feature,
Project,
Team,
TfsTeam,
User,
WorkSheet,
WorkSheetVersion,
ms_to_date,
normalize_iteration,
)
from .transport import Transport
CONN_QUICKTEAM = "QuickTeam"
CONN_USERCENTER = "UserCenter"
SP_LOGIN = "dbo.usp_Authentication"
SP_TEAMS = "QuickTeam.usp_GetTeamsByUserId"
SP_PROJECTS = "QuickTeam.usp_GetProjectsByTeamId"
SP_WORKSHEET = "QuickTeam.usp_GetWorkSheetById"
SP_PROJECT_USERS = "QuickTeam.usp_GetProjectUsersByProjectId"
SP_INSERT_WORKSHEET = "QuickTeam.usp_InsertWorkSheet"
SP_UPDATE_WORKSHEET = "QuickTeam.usp_UpdateWorkSheetById"
SP_VERIFY_WORKSHEET = "QuickTeam.usp_VerifyWorkSheetById"
SP_FEATURES = "QuickTeam.usp_GetWorkFeaturesByWorkSheetIds"
SP_WORKSHEET_VERSIONS = "QuickTeam.usp_GetWorkSheetsById"
SP_IMPORT_EXCEL = "QuickTeam.usp_BatchImportFeatureFromExcel"
SP_PUSH_FEATURES = "[QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds]"
SP_DELETE_WORKSHEET = "QuickTeam.usp_DeleteWorkSheetAllById"
class QuickTeamApi:
def __init__(
self,
transport: Transport,
*,
dry_run: bool = False,
log: Callable[[str], None] | None = None,
) -> None:
self.t = transport
self.dry_run = dry_run
self.log = log or (lambda message: None)
self._projects: dict[str, list[Project]] = {}
# ------------------------------------------------------------------ 只读
def login(self, username: str, password: str) -> User:
digest = hashlib.md5(password.encode("utf-8")).hexdigest()
rows = self.t.exec_table(
CONN_USERCENTER,
SP_LOGIN,
[("UserName", username, "string"), ("Password", digest, "string")],
op="login",
)
if not rows:
raise QtError("登录失败:账号或密码不匹配", Category.AUTH)
row = rows[0]
return User(user_id=row["GuidId"], real_name=row.get("RealName") or username, user_name=row["UserName"])
def teams(self, user_id: str) -> list[Team]:
rows = self.t.exec_table(CONN_QUICKTEAM, SP_TEAMS, [("UserId", user_id, "string")], op="teams") or []
return [Team(team_id=row["TeamId"], team_name=row.get("TeamName", "")) for row in rows]
@staticmethod
def resolve_team(teams: list[Team], name: str) -> Team:
for team in teams:
if team.team_name == name:
return team
available = "、".join(t.team_name for t in teams)
raise QtError(f"未找到团队「{name}」,可选:{available}", Category.PARAM)
def projects(self, team_id: str) -> list[Project]:
if team_id not in self._projects:
rows = (
self.t.exec_table(
CONN_QUICKTEAM,
SP_PROJECTS,
[("TeamId", team_id, "string"), ("IsTemplate", "1", "string")],
op="projects",
)
or []
)
self._projects[team_id] = [
Project(project_id=row["ProjectId"], project_name=row.get("ProjectName", "")) for row in rows
]
return self._projects[team_id]
def worksheet(self, worksheet_id: int) -> WorkSheet:
rows = self.t.exec_table(
CONN_QUICKTEAM, SP_WORKSHEET, [("WorkSheetId", worksheet_id, "int")], op="worksheet"
)
if not rows:
raise QtError(f"工单 {worksheet_id} 不存在", Category.PARAM)
return self._to_worksheet(rows[0])
def project_users(self, project_id: str) -> list[dict]:
return (
self.t.exec_table(
CONN_QUICKTEAM, SP_PROJECT_USERS, [("ProjectId", project_id, "string")], op="project_users"
)
or []
)
def work_sheets(self, project_id: str) -> list[WorkSheet]:
"""项目下全部工单(一次查全,含 SystemName / Status / 当前版本)。
比「按窗口查 + 逐单读详情」少 N 次往返,也不受 `usp_GetWeeklyWorkSheetsByProjectId`
按季度过滤的坑影响 —— plan 的幂等判定就靠它。
"""
rows = (
self.t.exec_table(
CONN_QUICKTEAM,
SP_WORKSHEET_VERSIONS,
[("ProjectId", project_id, "string"), ("isReclaimed", 0, "int")],
op="work_sheets",
)
or []
)
# 该 SP 用 union 拼了两部分:工单本体行(VersionId 为空)+ 每个版本一行。
# 版本行的 WorkSheetCode 被覆盖成了 VersionCode,所以字段优先取本体行。
base: dict[int, dict] = {}
other: dict[int, dict] = {}
for row in rows:
try:
worksheet_id = int(row.get("WorkSheetId"))
except (TypeError, ValueError):
continue
is_base = row.get("VersionId") in (None, "", 0)
(base if is_base else other).setdefault(worksheet_id, row)
result: list[WorkSheet] = []
for worksheet_id in sorted(set(base) | set(other)):
row = base.get(worksheet_id) or other[worksheet_id]
result.append(
WorkSheet(
worksheet_id=worksheet_id,
code=row.get("WorkSheetCode", ""),
status=str(row.get("Status") or ""),
version_id=row.get("CurrentVersionId"),
version_code=str(row.get("VersionCode") or ""),
system_name=str(row.get("SystemName") or ""),
start_time=ms_to_date(row.get("StartTime")),
end_time=ms_to_date(row.get("EndTime")),
)
)
return result
def worksheet_versions(self, project_id: str, worksheet_id: int | None = None) -> list[WorkSheetVersion]:
"""项目下所有工单的版本行(列表页用的那个 SP,一行 = 一个版本)。"""
rows = (
self.t.exec_table(
CONN_QUICKTEAM,
SP_WORKSHEET_VERSIONS,
[("ProjectId", project_id, "string"), ("isReclaimed", 0, "int")],
op="worksheet_versions",
)
or []
)
result: list[WorkSheetVersion] = []
seen: set[int] = set()
for row in rows:
raw_id = row.get("VersionId")
if raw_id in (None, "", 0):
continue # 该 SP 用 union 拼了「无版本」的占位行,跳过
try:
version_id = int(raw_id)
ws_id = int(row.get("WorkSheetId"))
except (TypeError, ValueError):
continue
if version_id in seen:
continue
if worksheet_id is not None and ws_id != worksheet_id:
continue
seen.add(version_id)
result.append(
WorkSheetVersion(
version_id=version_id,
worksheet_id=ws_id,
version_code=str(row.get("VersionCode") or ""),
version_status=str(row.get("VersionStatus") or ""),
next_operator=str(row.get("NextOperator") or ""),
)
)
result.sort(key=lambda item: item.version_id)
return result
def pending_version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion | None:
"""取「待确认版本」。导入明细和确认工单都必须落到它上面。"""
versions = self.worksheet_versions(project_id, worksheet_id)
if not versions:
return None
pending = [item for item in versions if item.pending]
if pending:
return max(pending, key=lambda item: item.version_id)
# 全部已确认:退回最后一个版本,交给上层按 version_code 判断流程是否走完
return versions[-1]
def features(self, worksheet_id: int) -> list[Feature]:
rows = (
self.t.exec_table(CONN_QUICKTEAM, SP_FEATURES, [("WorkSheetIds", str(worksheet_id), "string")], op="features")
or []
)
return [
Feature(feature_id=row["FeatureId"], code=row.get("FeatureCode", ""), name=row.get("FeatureName", ""))
for row in rows
]
# ------------------------------------------------------------------ 写入
def create_worksheet(self) -> int:
"""新增空工单,返回 WorkSheetId。"""
if self.dry_run:
self.log(" [dry-run] InsertWorkSheet() → 桩 WorkSheetId=0")
return 0
rows = self.t.exec_table(CONN_QUICKTEAM, SP_INSERT_WORKSHEET, [], op="insert_worksheet", write=True)
if not rows or not isinstance(rows, list) or "WorkSheetId" not in rows[0]:
raise QtError(f"InsertWorkSheet 返回异常:{rows!r}", Category.PARAM)
return int(rows[0]["WorkSheetId"])
def fill_worksheet(
self,
worksheet_id: int,
project: Project,
*,
system_name: str,
start_time: str,
end_time: str,
evaluator: str,
technical_leader: str,
customer_leader: str,
belong_team: str,
content: str,
operator: str,
service_type: str = "DevelopService",
status: str = "00",
other_instructions: str = "",
) -> int:
"""填工单基本信息(Operation=new)。返回 Result(>0 视为成功)。"""
params: list[tuple[str, Any, str]] = [
("WorkSheetId", worksheet_id, "string"),
("ProjectId", project.project_id, "string"),
("ProjectName", project.project_name, "string"),
("SystemName", system_name, "string"),
("Evaluator", evaluator, "string"),
("TechnicalLeader", technical_leader, "string"),
("CustomerLeader", customer_leader, "string"),
("StartTime", start_time, "string"),
("EndTime", end_time, "string"),
("Status", status, "string"),
("ServiceType", service_type, "string"),
("ImplementContent", content, "string"),
("OtherInstructions", other_instructions, "string"),
("Operator", operator, "string"),
("DeleteFlag", "0", "string"),
("Operation", "new", "string"),
("BelongTeam", belong_team, "string"),
]
if self.dry_run:
self.log(f" [dry-run] UpdateWorkSheetById(ws={worksheet_id}, sprint={system_name}, "
f"{start_time}~{end_time}, 负责人={technical_leader}) → 桩 Result=1")
return 1
rows = self.t.exec_table(CONN_QUICKTEAM, SP_UPDATE_WORKSHEET, params, op="update_worksheet", write=True)
result = rows[0].get("Result") if rows and isinstance(rows, list) else None
# 实测结论:这个 SP 的 Result 恒为 0,但数据确实写入了(WorkSheetCode/ProjectId/
# SystemName/StartTime/Status/BelongTeam 全部落库)。前端 `Result>0` 的提示判断
# 也不可靠,所以这里以「重新读取工单」为准,而不是信返回值。
if result is not None and int(result) > 0:
return int(result)
detail = self.worksheet(worksheet_id)
if detail.system_name != system_name:
raise QtError(
f"保存后校验失败:SystemName={detail.system_name!r},期望 {system_name!r}"
f"(Result={result!r})",
Category.PARAM,
)
return int(result or 0)
def upload_excel(self, local_path: str, folder: str = "WorkSheetImport") -> str:
if self.dry_run:
self.log(f" [dry-run] FileUpload({local_path}) → 桩路径")
return "<dry-run>"
return self.t.upload(local_path, folder)
def import_excel(self, worksheet_id: int, version_id: int, remote_path: str) -> None:
"""服务器端解析 Excel 写明细;Column1 == 0 为成功。"""
params: list[tuple[str, Any, str]] = [
("WorkSheetId", worksheet_id, "int"),
("VersionId", version_id, "int"),
]
if self.dry_run:
self.log(f" [dry-run] BatchImportFeature(ws={worksheet_id}, version={version_id}, {remote_path})")
return
result = self.t.batch_import(SP_IMPORT_EXCEL, params, remote_path)
code = None
if isinstance(result, list) and result and isinstance(result[0], dict):
code = result[0].get("Column1")
if code is None:
raise QtError(f"Excel 解析未返回结果:{result!r}", Category.EXCEL)
if int(code) != 0:
raise QtError(f"Excel 解析失败(Column1={code}),模板或内容不符", Category.EXCEL)
def delete_worksheet(self, worksheet_id: int) -> None:
"""删除整单(回滚半成品用)。"""
if self.dry_run:
self.log(f" [dry-run] DeleteWorkSheetAllById(ws={worksheet_id})")
return
self.t.exec_table(
CONN_QUICKTEAM, SP_DELETE_WORKSHEET, [("WorkSheetId", worksheet_id, "int")], op="delete_worksheet", write=True
)
def verify_worksheet(
self,
worksheet_id: int,
version_id: int,
*,
next_operator: str,
next_version_code: str,
operator: str,
percent: int,
) -> None:
"""确认工单(推进版本)。返回 0 为成功。"""
params: list[tuple[str, Any, str]] = [
("WorkSheetId", worksheet_id, "int"),
("VersionId", version_id, "int"),
("NextOperator", next_operator, "string"),
("NextVersionCode", next_version_code, "string"),
("Percent", percent, "int"),
("Operator", operator, "string"),
]
if self.dry_run:
self.log(f" [dry-run] VerifyWorkSheetById(ws={worksheet_id}, v={version_id}, "
f"next={next_operator}/{next_version_code}, percent={percent})")
return
result = self.t.exec_result(CONN_QUICKTEAM, SP_VERIFY_WORKSHEET, params, op="verify_worksheet")
if isinstance(result, list) and result and isinstance(result[0], dict):
result = result[0].get("Result", result[0].get("Column1"))
if result is not None and str(result).strip() not in {"0", "0.0"}:
raise QtError(f"确认工单失败,返回 {result!r}", Category.UNKNOWN)
# ------------------------------------------------------------------ TFS
def tfs_projects(self) -> list[dict]:
return self.t.tfs("GetTeamProjects", op="TFS.GetTeamProjects") or []
def tfs_teams(self, project_name: str, project_id: str) -> list[TfsTeam]:
rows = (
self.t.tfs(
"GetTeamByProject",
{"projectName": project_name, "projectId": project_id},
op="TFS.GetTeamByProject",
)
or []
)
teams: list[TfsTeam] = []
for row in rows:
area = row.get("AreaPath") or []
teams.append(
TfsTeam(
team_name=row.get("TeamName", ""),
area_path=area[0] if area else "",
iterations=row.get("Iterations") or [],
)
)
return teams
@staticmethod
def resolve_tfs_context(
teams: list[TfsTeam], team_name: str, sprint: str
) -> tuple[str, str]:
"""按团队名与 Sprint 名解析 (areaPath, iterationPath)。匹配不到直接报错,不猜。"""
team = next((t for t in teams if t.team_name == team_name), None)
if team is None:
available = "、".join(t.team_name for t in teams)
raise QtError(f"TFS 团队「{team_name}」不存在,可选:{available}", Category.TFS)
if not team.area_path:
raise QtError(f"TFS 团队「{team_name}」没有 AreaPath", Category.TFS)
target = normalize_iteration(sprint)
hits = [item for item in team.iterations if normalize_iteration(str(item.get("Name", ""))) == target]
if not hits: # 退化:用完整路径的最后一段匹配
hits = [
item
for item in team.iterations
if normalize_iteration(str(item.get("Path", "")).split("\\")[-1]) == target
]
if len(hits) != 1:
sample = "、".join(str(i.get("Path")) for i in team.iterations[-3:]) or "(空)"
raise QtError(
f"TFS 迭代「{sprint}」在团队「{team_name}」下匹配到 {len(hits)} 条(最近:{sample})",
Category.TFS,
)
path = str(hits[0].get("Path", "")).replace("/", "\\")
return team.area_path, path
def push_tfs(self, feature_ids: list[int], project_name: str, area_path: str, iteration_path: str) -> Any:
joined = ",".join(str(i) for i in feature_ids)
if self.dry_run:
self.log(
f" [dry-run] BatchImportWorkItemsToTfs(项目={project_name}, "
f"area={area_path}, iter={iteration_path}, features={joined or '-'})"
)
return []
if not feature_ids:
raise QtError("没有可推送的功能模块", Category.TFS)
inner = {
"SPName": SP_PUSH_FEATURES,
"SQLParams": [{"Name": "FeatureIds", "Value": joined, "DBType": "string"}],
}
import json
import urllib.parse
payload = {
"connectionName": CONN_QUICKTEAM,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
"projectName": project_name,
"areaPath": area_path,
"iterationPath": iteration_path,
}
result = self.t.tfs("BatchImportWorkItemsToTfs", payload, op="TFS.BatchImportWorkItemsToTfs", write=True)
# 这个接口返回的是**状态码**不是数量:0 = 成功,-1 = 失败(实测传不存在的项目名
# 返回 -1;正常返回 0)。别拿 >0 当成功,否则会把一批已推送的工单记成失败。
if isinstance(result, int) and not isinstance(result, bool) and result != 0:
raise QtError(f"TFS 推送失败(返回 {result})", Category.TFS)
return result
# ------------------------------------------------------------------ 内部
@staticmethod
def _to_worksheet(row: dict) -> WorkSheet:
return WorkSheet(
worksheet_id=int(row.get("WorkSheetId", 0)),
code=row.get("WorkSheetCode", ""),
status=str(row.get("Status", "")),
version_id=row.get("CurrentVersionId"),
version_code=row.get("VersionCode", "") or "",
system_name=row.get("SystemName", ""),
start_time=ms_to_date(row.get("StartTime")),
end_time=ms_to_date(row.get("EndTime")),
)