Files
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

132 lines
5.0 KiB
Python
Raw Permalink 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.
"""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, ""