初始提交: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
+131
View File
@@ -0,0 +1,131 @@
"""domain 层:判定「该做什么」,不含 HTTP、不含 SQL。
这里集中三个最容易被写散的规则:
1. 文件 ↔ 项目 的匹配(精确,绝不猜)
2. 工单是否已存在 / 是否可续跑(幂等的判断源)
3. 版本推进(确认工单)的下一步参数
"""
from __future__ import annotations
import os
import zipfile
from dataclasses import dataclass
from enum import StrEnum
from .models import Project, WorkSheet, normalize_name
class Action(StrEnum):
CREATE = "create"
RESUME = "resume"
SKIP_DONE = "skip-done"
SKIP_EXISTS = "skip-exists"
SKIP_PUSHED = "skip-pushed"
ERROR = "error"
@property
def writes(self) -> bool:
return self in {Action.CREATE, Action.RESUME}
@dataclass
class PlanRow:
project_name: str
file_path: str
action: Action
reason: str = ""
project: Project | None = None
worksheet: WorkSheet | None = None
existing_count: int = 0
# 版本链(前端 EffortListVersionInfo 的等价物)
# version_code -> (next_operator, next_version_code, percent);None 表示流程结束
VERSION_TRANSITIONS: dict[str, tuple[str, str, int] | None] = {
"": ("CustomerLeader", "客户责任人确认版", 20), # 新建草稿视同「技术责任人确认版」
"技术责任人确认版": ("CustomerLeader", "客户责任人确认版", 20),
"客户责任人确认版": ("CustomerLeader", "甲方确认版", 20),
"甲方确认版": None,
}
def next_transition(version_code: str) -> tuple[str, str, int]:
"""返回 (NextOperator, NextVersionCode, Percent)。到终点则报错。"""
if version_code not in VERSION_TRANSITIONS:
raise ValueError(f"未知版本「{version_code}」,无法推导下一步")
step = VERSION_TRANSITIONS[version_code]
if step is None:
raise ValueError(f"版本「{version_code}」已是流程终点,无需确认")
return step
def decide(existing: list[WorkSheet], *, ledger_stage: str | None, adopt: bool) -> tuple[Action, str, WorkSheet | None]:
"""根据「服务器现状 + 台账记录」决定动作。"""
if not existing:
return Action.CREATE, "本项目该 Sprint 无工单", None
# 优先看同一 Sprint 的工单(调用方已按 SystemName 过滤)
worksheet = existing[0]
if worksheet.done:
return Action.SKIP_DONE, f"工单 {worksheet.code} 已确认完成(Status={worksheet.status})", worksheet
if ledger_stage == "TFS_PUSHED":
return Action.SKIP_PUSHED, f"工单 {worksheet.code} 已推送 TFS", worksheet
if ledger_stage:
return Action.RESUME, f"台账记录停在 {ledger_stage},续跑", worksheet
if adopt:
return Action.RESUME, "工单存在但无台账记录,--adopt 接管续跑", worksheet
return Action.SKIP_EXISTS, f"工单 {worksheet.code} 已存在(无台账记录,未接管)", worksheet
def list_xlsx(folder: str) -> list[str]:
if not os.path.isdir(folder):
raise SystemExit(f"目录不存在:{folder}")
result = []
for name in sorted(os.listdir(folder)):
path = os.path.join(folder, name)
if os.path.isfile(path) and name.lower().endswith(".xlsx") and not name.startswith("~$"):
result.append(path)
return result
def match_project(stem: str, projects: list[Project]) -> tuple[Project | None, str]:
"""精确匹配项目;命中多个或零个都算失败,不做「包含即命中」的猜测。"""
target = normalize_name(stem)
exact = [p for p in projects if normalize_name(p.project_name) == target]
if len(exact) == 1:
return exact[0], ""
if len(exact) > 1:
names = "、".join(p.project_name for p in exact)
return None, f"项目名歧义({names})"
# 只用于提示,不给结果
near = [p.project_name for p in projects if target in normalize_name(p.project_name)]
hint = f",相近项目:{'、'.join(near[:3])}" if near else ""
return None, f"QuickTeam 中没有该项目{hint}"
def precheck_xlsx(path: str) -> tuple[bool, str]:
"""上传前的轻量自检:确认是「工作量评估表」模板,避免白跑一趟服务器解析。"""
try:
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
if "xl/workbook.xml" not in names:
return False, "不是 xlsx(缺少 xl/workbook.xml)"
if "xl/sharedStrings.xml" not in names:
return False, "文件为空表或已损坏"
text = archive.read("xl/sharedStrings.xml").decode("utf-8", "ignore")
except zipfile.BadZipFile:
return False, "不是合法的 xlsx(可能仍是 Excel 打开中的临时文件)"
except OSError as exc:
return False, f"读取失败:{exc}"
if "工作量评估表" not in text:
return False, "未找到「工作量评估表」标题,可能不是工单模板"
if "服务模块" not in text:
return False, "未找到明细表头(服务模块),模板可能已变更"
return True, ""