初始提交: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
+104
View File
@@ -0,0 +1,104 @@
"""数据结构。"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass, field
@dataclass
class User:
user_id: str
real_name: str
user_name: str
@dataclass
class Team:
team_id: str
team_name: str
@dataclass
class Project:
project_id: str
project_name: str
@dataclass
class WorkSheet:
"""工单(每项目每 Sprint 一张)。"""
worksheet_id: int
code: str = ""
status: str = ""
version_id: int | None = None
version_code: str = ""
system_name: str = ""
start_time: str = ""
end_time: str = ""
@property
def done(self) -> bool:
"""03/04 = 甲方已确认,流程闭环。"""
return self.status in {"03", "04"}
@dataclass
class Feature:
feature_id: int
code: str = ""
name: str = ""
@dataclass
class WorkSheetVersion:
"""工单版本(审批链上的一环)。
`usp_UpdateWorkSheetById` 在第一次保存时插入「技术责任人确认版」(VersionStatus='N'),
但**不回填** `WorkSheet.CurrentVersionId` —— 那要等 `usp_VerifyWorkSheetById` 才写。
所以导入明细/确认工单都必须用「待确认版本」的 VersionId,不能用 CurrentVersionId。
"""
version_id: int
worksheet_id: int
version_code: str = ""
version_status: str = "" # N=待处理(当前),Y=已确认
next_operator: str = ""
@property
def pending(self) -> bool:
return self.version_status.strip().upper() == "N"
@dataclass
class TfsTeam:
team_name: str
area_path: str
iterations: list[dict] = field(default_factory=list)
def ms_to_date(value: object) -> str:
"""`/Date(1785081600000)/` → `2026-07-26`。"""
if not value:
return ""
if isinstance(value, str) and value.startswith("/Date("):
match = re.search(r"\((-?\d+)", value)
if match:
return time.strftime("%Y-%m-%d", time.localtime(int(match.group(1)) / 1000))
return str(value)
def normalize_name(name: str) -> str:
"""项目/迭代名归一化:去空白、统一破折号、忽略大小写。用于精确匹配,避免 contains 误命中。"""
text = re.sub(r"\s+", "", name or "")
for dash in ("-", "—", "–", "−"):
text = text.replace(dash, "-")
return text.casefold()
def normalize_iteration(name: str) -> str:
"""迭代名比对:在 normalize_name 基础上忽略数字前导零(`2026 Sprint 06` ≈ `2026 Sprint 6`)。"""
text = normalize_name(name)
return re.sub(r"(?<=\D)0+(?=\d)", "", text)