用「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 个单测通过。
105 lines
2.5 KiB
Python
105 lines
2.5 KiB
Python
"""数据结构。"""
|
||
|
||
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)
|