Files
QTImport/qtimport/api.py
T
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

455 lines
19 KiB
Python
Raw 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.
"""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")),
)