初始提交: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
+3
View File
@@ -0,0 +1,3 @@
"""QuickTeam 工单接口导入工具。"""
__version__ = "0.1.0"
+206
View File
@@ -0,0 +1,206 @@
"""CLI:plan / apply / report / status。"""
from __future__ import annotations
import argparse
import json
import os
import secrets
import sys
import time
from . import __version__
from .config import DEFAULT_CONFIG, Config
from .errors import QtError
from .report import RunLog, write_report
from .store import Store
from .workflow import Importer, RunOptions
def new_run_id() -> str:
return time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
def build_importer(args) -> tuple[Importer, RunLog, Store, str]:
cfg = Config.load(args.config)
overrides = {"sprint": args.sprint, "work_dir": args.folder, "team_name": args.team}
for key, value in overrides.items():
if value:
setattr(cfg, key, value)
if not cfg.sprint:
raise SystemExit("配置缺少 sprint(或传 --sprint)")
if not cfg.work_dir:
raise SystemExit("配置缺少 work_dir(或传 --dir)")
if getattr(args, "workers", None):
cfg.workers = args.workers
run_id = args.run_id or new_run_id()
store = Store(cfg.db_path)
log = RunLog(run_id, verbose=not args.quiet)
store.start_run(run_id, cfg.sprint, cfg.work_dir, dry_run=args.dry_run)
options = RunOptions(
adopt=args.adopt,
skip_tfs=args.skip_tfs,
rollback_on_fail=args.rollback_on_fail,
limit=args.limit,
projects=args.project or [],
force=args.force,
dry_run=args.dry_run,
)
importer = Importer(cfg, store, run_id, log, options)
return importer, log, store, run_id
def cmd_plan(args) -> int:
importer, log, store, run_id = build_importer(args)
try:
rows = importer.plan()
finally:
pass
width = max([len(row.project_name) for row in rows] + [8]) + 2
print(f"\nSprint:{importer.cfg.sprint} 目录:{importer.cfg.work_dir}")
print(f"{'项目'.ljust(width)}{'动作':<12}说明")
print("-" * (width + 60))
for row in rows:
print(f"{row.project_name.ljust(width)}{str(row.action):<12}{row.reason}")
print("-" * (width + 60))
counts: dict[str, int] = {}
for row in rows:
counts[str(row.action)] = counts.get(str(row.action), 0) + 1
print("合计 " + ",".join(f"{k} {v}" for k, v in sorted(counts.items())))
if args.json:
payload = [
{
"project": row.project_name,
"file": row.file_path,
"action": str(row.action),
"reason": row.reason,
"worksheet_id": row.worksheet.worksheet_id if row.worksheet else None,
}
for row in rows
]
path = args.json if isinstance(args.json, str) else f"reports/{run_id}/plan.json"
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
print(f"计划已写入 {path}")
log.close()
store.finish_run(run_id, "PLANNED")
store.close()
return 0
def cmd_apply(args) -> int:
if not args.dry_run and not args.yes:
print("这会真实写入 QuickTeam(建单/导入/确认/推送)。确认后加 --yes 重跑;"
"只想看会做什么请用 --dry-run。", file=sys.stderr)
return 3
importer, log, store, run_id = build_importer(args)
started = time.time()
status = "DONE"
try:
results = importer.apply()
failed = [r for r in results if r.stage.startswith("FAILED")]
if failed:
status = "PARTIAL"
print(f"\n执行完成:{len(results)} 个项目,失败 {len(failed)},"
f"耗时 {time.time() - started:.1f}s")
except QtError as error:
print(f"[中止] {error}", file=sys.stderr)
status = "ABORTED"
return 2
finally:
log.close()
store.finish_run(run_id, status)
md, csv = write_report(run_id, store)
print(f"报告:{md} / {csv}")
store.close()
return 0
def cmd_report(args) -> int:
cfg = Config.load(args.config, need_password=False)
store = Store(cfg.db_path)
run_id = args.run_id or store.last_run()
if not run_id:
print("没有可用的运行记录", file=sys.stderr)
return 1
md, csv = write_report(run_id, store)
print(f"已生成:{md}\n {csv}")
store.close()
return 0
def cmd_status(args) -> int:
cfg = Config.load(args.config, need_password=False)
store = Store(cfg.db_path)
run_id = args.run_id or store.last_run()
if not run_id:
print("没有可用的运行记录", file=sys.stderr)
return 1
jobs = store.jobs(run_id)
print(f"run={run_id} 项目 {len(jobs)} 个")
for job in jobs:
flag = "!" if job["error"] else " "
print(f"{flag} {job['project_name']:<28} {job['action']:<12} {job['stage']:<20} "
f"ws={job['worksheet_id'] or '-'}")
store.close()
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="qtimport", description="QuickTeam 工单接口导入")
parser.add_argument("--version", action="version", version=f"qtimport {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
def common(p: argparse.ArgumentParser) -> None:
p.add_argument("--config", default=DEFAULT_CONFIG, help="配置文件路径")
p.add_argument("--sprint", help="覆盖配置中的 Sprint 名称")
p.add_argument("--dir", dest="folder", help="覆盖配置中的 xlsx 目录")
p.add_argument("--team", help="覆盖配置中的团队名称")
p.add_argument("--run-id", help="复用已有 run_id(续跑)")
p.add_argument("--adopt", action="store_true", help="接管无台账记录的既有工单")
p.add_argument("--skip-tfs", action="store_true", help="不推送 TFS")
p.add_argument("--limit", type=int, help="最多执行几个项目")
p.add_argument("--project", action="append", help="只处理指定项目(可多次)")
p.add_argument("--force", action="store_true", help="跳过 Excel 模板预检")
p.add_argument("--dry-run", action="store_true", help="演练:不产生任何服务端写入")
p.add_argument("--quiet", action="store_true", help="只输出结果")
p_plan = sub.add_parser("plan", help="只读预演:列出每个项目会做什么")
common(p_plan)
p_plan.add_argument("--json", nargs="?", const=True, help="把计划写成 JSON")
p_plan.set_defaults(func=cmd_plan, rollback_on_fail=False)
p_apply = sub.add_parser("apply", help="执行导入")
common(p_apply)
p_apply.add_argument("--yes", action="store_true", help="确认真实写入")
p_apply.add_argument("--rollback-on-fail", action="store_true", help="失败时删除本次新建的半成品工单")
p_apply.add_argument("--workers", type=int, help="覆盖并发数(默认取配置)")
p_apply.set_defaults(func=cmd_apply, json=False)
p_report = sub.add_parser("report", help="生成报告")
p_report.add_argument("--config", default=DEFAULT_CONFIG)
p_report.add_argument("--run-id")
p_report.set_defaults(func=cmd_report, quiet=False)
p_status = sub.add_parser("status", help="查看最近一次运行")
p_status.add_argument("--config", default=DEFAULT_CONFIG)
p_status.add_argument("--run-id")
p_status.set_defaults(func=cmd_status, quiet=False)
args = parser.parse_args(argv)
try:
return args.func(args)
except QtError as error:
print(f"[ERROR] {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
+454
View File
@@ -0,0 +1,454 @@
"""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")),
)
+93
View File
@@ -0,0 +1,93 @@
"""配置加载。
凭据解析优先级:环境变量 QT_PASSWORD > Windows DPAPI 文件 > 配置文件明文 > 交互输入。
配置文件默认 config/config.json(已在 .gitignore 中)。
"""
from __future__ import annotations
import getpass
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_CONFIG = "config/config.json"
@dataclass
class Config:
# 连接
base_url: str
username: str
password: str = field(default="", repr=False)
timeout: int = 60
# 业务
team_name: str = "当前项目"
work_dir: str = ""
sprint: str = ""
start_time: str = ""
end_time: str = ""
content_desc: str = "需求开发测试"
service_type: str = "DevelopService"
belong_team: str = ""
# 三个责任人;留空则用登录用户的真实姓名
evaluator: str = ""
technical_leader: str = ""
customer_leader: str = ""
# TFS
tfs_project: str = "Tools_Dev"
tfs_team: str = "DataTechnologies"
area_path: str = ""
iteration_path: str = ""
# 运行
workers: int = 2
db_path: str = "state/qtimport.db"
def resolved_roles(self, operator: str) -> tuple[str, str, str]:
"""三个责任人,缺省都落到操作人(与现有脚本一致)。"""
return (
self.evaluator or operator,
self.technical_leader or operator,
self.customer_leader or operator,
)
@classmethod
def load(cls, path: str | os.PathLike = DEFAULT_CONFIG, *, need_password: bool = True) -> "Config":
file_path = Path(path)
if not file_path.exists():
raise SystemExit(f"缺少配置文件:{file_path}(可从 config/config.example.json 复制)")
with open(file_path, encoding="utf-8-sig") as handle:
raw = json.load(handle)
allowed = {f for f in cls.__dataclass_fields__}
unknown = set(raw) - allowed
if unknown:
raise SystemExit(f"配置文件含未知字段:{sorted(unknown)}")
cfg = cls(**raw)
cfg.password = resolve_password(cfg.password, need=need_password)
if not cfg.base_url or not cfg.username:
raise SystemExit("配置缺少 base_url 或 username")
if need_password and not cfg.password:
raise SystemExit("未提供密码:设置 QT_PASSWORD 环境变量或在配置中填写")
return cfg
def resolve_password(configured: str, *, need: bool = True) -> str:
env = os.environ.get("QT_PASSWORD")
if env:
return env
if configured and configured != "REPLACE_ME":
return configured
if not need:
return ""
if not os.isatty(0): # 非交互(计划任务/CI)
return ""
return getpass.getpass("QuickTeam 密码:")
def load_raw(path: str | os.PathLike = DEFAULT_CONFIG) -> dict:
"""读原始 JSON(plan 阶段不需要密码时用)。"""
with open(Path(path), encoding="utf-8-sig") as handle:
return json.load(handle)
+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, ""
+58
View File
@@ -0,0 +1,58 @@
"""异常与错误分类。
WCF 的错误是 HTML 页面,真正的信息藏在 "The exception message is '...'" 里。
分类的目的是决定「重试 / 跳过 / 整批中止」,而不是把所有失败都当网络问题重试。
"""
from __future__ import annotations
from enum import StrEnum
class Category(StrEnum):
NET = "NET" # 网络层:可重试
SP_MISSING = "SP_MISSING" # 存储过程不存在:配置/版本问题,不重试
PARAM = "PARAM" # 参数错误/类型转换:数据问题,不重试
EXCEL = "EXCEL" # Excel 解析失败(Column1 != 0)
TFS = "TFS" # 推送 TFS 失败
AUTH = "AUTH" # 登录失败
UNKNOWN = "UNKNOWN"
class QtError(RuntimeError):
"""带分类的接口错误。"""
def __init__(self, message: str, category: Category = Category.UNKNOWN, *, retryable: bool = False):
super().__init__(message)
self.message = message
self.category = category
self.retryable = retryable
def __str__(self) -> str: # pragma: no cover - 直通
return f"[{self.category}] {self.message}"
@classmethod
def from_http(cls, status: int, body: str) -> "QtError":
"""把 WCF 的 HTML 错误页转成可读错误。"""
message = extract_exception_message(body) or f"HTTP {status}"
lowered = message.lower()
if "could not find stored procedure" in lowered:
return cls(message, Category.SP_MISSING)
if "missing parameter" in lowered or "conversion" in lowered or "procedure or function" in lowered:
return cls(message, Category.PARAM)
if status >= 500:
return cls(message, Category.NET, retryable=True)
return cls(message, Category.UNKNOWN)
def extract_exception_message(body: str) -> str:
"""从 WCF 错误页里抠出真正的异常文本。"""
import html
import re
match = re.search(r"exception message is '(.*?)'\s*\.?\s*See server logs", body, re.S)
if match:
return html.unescape(match.group(1)).strip()
plain = re.sub(r"<[^>]+>", " ", body)
plain = html.unescape(re.sub(r"\s+", " ", plain)).strip()
return plain[:400]
+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)
+115
View File
@@ -0,0 +1,115 @@
"""日志与报告。"""
from __future__ import annotations
import csv
import json
import os
import time
from typing import Any
from .store import Store
class RunLog:
"""控制台摘要 + JSONL 明细。"""
def __init__(self, run_id: str, log_dir: str = "logs", *, verbose: bool = True) -> None:
os.makedirs(log_dir, exist_ok=True)
self.path = os.path.join(log_dir, f"{run_id}.jsonl")
self.verbose = verbose
self.run_id = run_id
self._handle = open(self.path, "a", encoding="utf-8")
def close(self) -> None:
self._handle.close()
def _write(self, record: dict[str, Any]) -> None:
record.setdefault("ts", time.strftime("%Y-%m-%d %H:%M:%S"))
record.setdefault("run", self.run_id)
self._handle.write(json.dumps(record, ensure_ascii=False) + "\n")
self._handle.flush()
def info(self, message: str, **fields: Any) -> None:
self._write({"level": "info", "message": message, **fields})
if self.verbose:
print(message)
def event(self, stage: str, action: str, *, project: str = "", detail: str = "", elapsed_ms: int = 0) -> None:
self._write(
{
"level": "event",
"stage": stage,
"action": action,
"project": project,
"detail": detail,
"elapsed_ms": elapsed_ms,
}
)
def warn(self, message: str, **fields: Any) -> None:
self._write({"level": "warn", "message": message, **fields})
if self.verbose:
print(f" ! {message}")
def write_report(run_id: str, store: Store, out_dir: str = "reports") -> tuple[str, str]:
"""生成 summary.md 与 detail.csv。"""
target = os.path.join(out_dir, run_id)
os.makedirs(target, exist_ok=True)
jobs = store.jobs(run_id)
events = store.events(run_id)
md_path = os.path.join(target, "summary.md")
csv_path = os.path.join(target, "detail.csv")
counts: dict[str, int] = {}
for job in jobs:
counts[job["action"]] = counts.get(job["action"], 0) + 1
failures = [job for job in jobs if job["error"]]
with open(csv_path, "w", encoding="utf-8-sig", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(
["项目", "Sprint", "文件", "动作", "阶段", "工单号", "版本", "耗时(ms)", "错误"]
)
for job in jobs:
elapsed = sum(
e["elapsed_ms"] or 0
for e in events
if e["project_id"] == job["project_id"] and e["elapsed_ms"]
)
writer.writerow(
[
job["project_name"],
job["system_name"],
os.path.basename(job["source_file"]),
job["action"],
job["stage"],
job["worksheet_id"] or "",
job["version_id"] or "",
elapsed,
(job["error"] or "").replace("\n", " ")[:300],
]
)
info = store.run_info(run_id) or {}
mode = "演练(未写入任何数据)" if info.get("dry_run") else "实际执行"
with open(md_path, "w", encoding="utf-8") as handle:
handle.write(f"# 导入报告 {run_id}\n\n")
handle.write(f"- 模式:{mode}\n")
handle.write(f"- Sprint:{info.get('sprint', '')}\n")
handle.write(f"- 项目数:{len(jobs)}\n")
handle.write(f"- 失败:{len(failures)}\n")
for action, count in sorted(counts.items()):
handle.write(f"- {action}:{count}\n")
handle.write("\n| 项目 | 动作 | 阶段 | 工单号 | 错误 |\n| --- | --- | --- | --- | --- |\n")
for job in jobs:
error = (job["error"] or "").replace("\n", " ")[:120].replace("|", "\\|")
handle.write(
f"| {job['project_name']} | {job['action']} | {job['stage']} | "
f"{job['worksheet_id'] or '-'} | {error or ''} |\n"
)
return md_path, csv_path
+207
View File
@@ -0,0 +1,207 @@
"""SQLite 台账:断点、幂等判断与审计的唯一事实源。"""
from __future__ import annotations
import os
import sqlite3
import threading
import time
from typing import Any
SCHEMA = """
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
finished_at TEXT,
sprint TEXT NOT NULL,
work_dir TEXT NOT NULL,
dry_run INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'RUNNING'
);
CREATE TABLE IF NOT EXISTS jobs (
run_id TEXT NOT NULL,
project_id TEXT NOT NULL,
project_name TEXT NOT NULL,
system_name TEXT NOT NULL,
source_file TEXT NOT NULL,
action TEXT NOT NULL,
stage TEXT NOT NULL,
worksheet_id INTEGER,
version_id INTEGER,
reason TEXT,
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (run_id, project_id)
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
project_id TEXT,
stage TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT,
elapsed_ms INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs (project_id, system_name, updated_at);
CREATE INDEX IF NOT EXISTS idx_events_run ON events (run_id, id);
"""
def now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
class Store:
def __init__(self, db_path: str) -> None:
directory = os.path.dirname(os.path.abspath(db_path))
os.makedirs(directory, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
with self._lock:
self._conn.executescript(SCHEMA)
self._conn.commit()
def close(self) -> None:
with self._lock:
self._conn.close()
# ------------------------------------------------------------------ runs
def start_run(self, run_id: str, sprint: str, work_dir: str, dry_run: bool) -> None:
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO runs (run_id, started_at, sprint, work_dir, dry_run, status) "
"VALUES (?, ?, ?, ?, ?, 'RUNNING')",
(run_id, now(), sprint, work_dir, int(dry_run)),
)
self._conn.commit()
def finish_run(self, run_id: str, status: str = "DONE") -> None:
with self._lock:
self._conn.execute(
"UPDATE runs SET finished_at = ?, status = ? WHERE run_id = ?", (now(), status, run_id)
)
self._conn.commit()
# ------------------------------------------------------------------ jobs
def upsert_job(
self,
run_id: str,
*,
project_id: str,
project_name: str,
system_name: str,
source_file: str,
action: str,
stage: str,
worksheet_id: int | None = None,
version_id: int | None = None,
reason: str = "",
error: str = "",
) -> None:
with self._lock:
self._conn.execute(
"""
INSERT INTO jobs (run_id, project_id, project_name, system_name, source_file, action, stage,
worksheet_id, version_id, reason, error, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id, project_id) DO UPDATE SET
action = excluded.action,
stage = excluded.stage,
worksheet_id = COALESCE(excluded.worksheet_id, jobs.worksheet_id),
version_id = COALESCE(excluded.version_id, jobs.version_id),
reason = excluded.reason,
error = excluded.error,
updated_at = excluded.updated_at
""",
(
run_id,
project_id,
project_name,
system_name,
source_file,
str(action),
stage,
worksheet_id,
version_id,
reason,
error,
now(),
),
)
self._conn.commit()
def latest_stage(self, project_id: str, system_name: str) -> str | None:
"""跨 run 取该项目该 Sprint 最近一次落库的阶段,用于断点续跑。"""
job = self.latest_job(project_id, system_name)
return job["stage"] if job else None
def latest_job(self, project_id: str, system_name: str) -> dict[str, Any] | None:
"""最近一次**真实**执行记录。dry-run 不参与幂等判断,否则会把演练当成已完成。
`SKIPPED` 行也不参与:它只表示「这一轮不用做」,不带任何进度信息。若把它当成
最近记录,下次 plan 会把「已推送 TFS」看成「台账停在 SKIPPED」,误判 resume 并
再推一次 —— 而 TFS 推送接口没有服务端去重,重复推送就是重复工作项。
"""
with self._lock:
row = self._conn.execute(
"""
SELECT j.* FROM jobs j
JOIN runs r ON r.run_id = j.run_id
WHERE j.project_id = ? AND j.system_name = ? AND r.dry_run = 0
AND j.stage <> 'SKIPPED'
ORDER BY j.updated_at DESC LIMIT 1
""",
(project_id, system_name),
).fetchone()
return dict(row) if row else None
def jobs(self, run_id: str) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM jobs WHERE run_id = ? ORDER BY project_name", (run_id,)
).fetchall()
return [dict(row) for row in rows]
# ------------------------------------------------------------------ events
def add_event(
self,
run_id: str,
*,
stage: str,
action: str,
project_id: str = "",
detail: str = "",
elapsed_ms: int = 0,
) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO events (run_id, project_id, stage, action, detail, elapsed_ms, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(run_id, project_id, stage, action, detail[:2000], elapsed_ms, now()),
)
self._conn.commit()
def events(self, run_id: str) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM events WHERE run_id = ? ORDER BY id", (run_id,)
).fetchall()
return [dict(row) for row in rows]
def run_info(self, run_id: str) -> dict[str, Any] | None:
with self._lock:
row = self._conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
return dict(row) if row else None
def last_run(self) -> str | None:
with self._lock:
row = self._conn.execute("SELECT run_id FROM runs ORDER BY started_at DESC LIMIT 1").fetchone()
return row["run_id"] if row else None
+206
View File
@@ -0,0 +1,206 @@
"""传输层:WCF + 通用存储过程网关。
只负责 HTTP 与协议细节:
- 双层 JSON 解码(返回值常是「JSON 字符串里再套一层 JSON」)
- HTML 错误页 → 可读异常
- 网络层重试(仅幂等调用)
- multipart 上传
业务语义一律放在 api 层。
"""
from __future__ import annotations
import json
import mimetypes
import os
import socket
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable
from .errors import Category, QtError
CallHook = Callable[[str, int, bool, str], None]
# 服务路径(来自前端 app.js 的配置模块 a078)
COMMON_URL = "/Services/CommonService.svc/"
FILE_URL = "/Services/FileOperationService.svc/"
TFS_URL = "/Services/TFSOperationService.svc/"
def _content_type(path: str) -> str:
return mimetypes.guess_type(path)[0] or "application/octet-stream"
class Transport:
def __init__(
self,
base_url: str,
*,
timeout: int = 60,
retries: int = 2,
on_call: CallHook | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.retries = retries
self.on_call = on_call
# ------------------------------------------------------------------ 内部
def _record(self, op: str, started: float, ok: bool, detail: str = "") -> None:
if self.on_call:
self.on_call(op, int((time.time() - started) * 1000), ok, detail)
def _post(
self,
path: str,
payload: Any = None,
*,
op: str = "",
retry: bool = False,
raw_body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> str:
url = self.base_url + path
if raw_body is not None:
body = raw_body
else:
body = b"" if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
hdrs = {"Accept": "application/json"}
if raw_body is None:
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
attempts = self.retries + 1 if retry else 1
started = time.time()
last_error: Exception | None = None
for attempt in range(attempts):
request = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
text = response.read().decode("utf-8-sig", "replace")
self._record(op or path, started, True)
return text
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8-sig", "replace")
error = QtError.from_http(exc.code, detail)
self._record(op or path, started, False, str(error))
# 只有网络/服务端 5xx 才值得重试,业务错误重试无意义
if error.retryable and attempt < attempts - 1:
time.sleep(1.5 * (attempt + 1))
continue
raise error from None
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as exc:
last_error = exc
self._record(op or path, started, False, f"网络错误:{exc}")
if attempt < attempts - 1:
time.sleep(1.5 * (attempt + 1))
continue
raise QtError(f"网络不可达:{exc}", Category.NET, retryable=True) from None
raise QtError(f"请求失败:{last_error}", Category.NET)
@staticmethod
def decode(text: str) -> Any:
"""WCF 返回值可能是「JSON 字符串里再套一层 JSON」,最多解三层。"""
value: Any = text.strip()
for _ in range(3):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
break
return value
# ------------------------------------------------------------------ 网关
def exec_table(
self, connection: str, procedure: str, params: list[tuple[str, Any, str]], *, op: str = "", write: bool = False
) -> Any:
"""ExecTable:结果集语义。"""
return self._exec("ExecTable", connection, procedure, params, op=op, write=write)
def exec_result(
self, connection: str, procedure: str, params: list[tuple[str, Any, str]], *, op: str = ""
) -> Any:
"""ExecResult:标量/返回值语义(Result、Column1 等)。"""
return self._exec("ExecResult", connection, procedure, params, op=op, write=True)
def _exec(
self,
method: str,
connection: str,
procedure: str,
params: list[tuple[str, Any, str]],
*,
op: str,
write: bool,
) -> Any:
inner = {
"SPName": procedure,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": connection,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
text = self._post(f"{COMMON_URL}{method}", payload, op=op or procedure, retry=not write)
return self.decode(text)
# ------------------------------------------------------------------ 文件
def upload(self, local_path: str, folder: str, *, op: str = "FileUpload") -> str:
"""上传文件,返回服务器端绝对路径。
注意:必须发**裸字节**,不能套 multipart。前端把原生 File 对象直接交给
axios(`data: e`,e 是 upload-file,`send(File)` 走的是 file type),WCF 侧
按 `Stream` 接收后原样落盘。发 multipart 时服务器把整段 multipart 包体写进
.xlsx,文件损坏 → 服务端解析出空 DataTable → SqlBulkCopyToDB 抛
「Object reference not set to an instance of an object」。
"""
name = f"{int(time.time() * 1000)}{os.path.splitext(local_path)[1]}"
query = urllib.parse.urlencode({"fileName": name, "fileFolder": folder})
with open(local_path, "rb") as handle:
body = handle.read()
text = self._post(
f"{FILE_URL}FileUpload?{query}",
raw_body=body,
headers={"Content-Type": _content_type(local_path)},
op=op,
retry=True, # 同名覆盖无副作用,可重试
)
result = self.decode(text)
path = (result or {}).get("FileUploadResult") if isinstance(result, dict) else None
if not path:
raise QtError(f"上传未返回 FileUploadResult:{result!r}", Category.UNKNOWN)
return path
def batch_import(self, procedure: str, params: list[tuple[str, Any, str]], file_path: str) -> Any:
"""BatchImportFeature:服务器端解析 Excel 并写库。"""
inner = {
"SPName": procedure,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": "QuickTeam",
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
"filePath": file_path,
}
text = self._post(f"{FILE_URL}BatchImportFeature", payload, op="BatchImportFeature", retry=False)
return self.decode(text)
# ------------------------------------------------------------------ TFS
def tfs(self, operation: str, payload: Any = None, *, op: str = "", write: bool = False) -> Any:
text = self._post(f"{TFS_URL}{operation}", payload, op=op or f"TFS.{operation}", retry=not write)
return self.decode(text)
+416
View File
@@ -0,0 +1,416 @@
"""workflow 层:单项目编排、阶段推进、补偿与并发。
阶段顺序固定,台账里记到哪一步,下次就从下一步开始 —— 这是断点续跑的唯一依据。
"""
from __future__ import annotations
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from . import domain
from .api import QuickTeamApi
from .config import Config
from .domain import Action, PlanRow
from .errors import Category, QtError
from .models import Project, User, WorkSheet, WorkSheetVersion
from .report import RunLog
from .store import Store
STAGES = ["WS_CREATED", "WS_FILLED", "EXCEL_IMPORTED", "WS_VERIFIED", "TFS_PUSHED"]
STAGE_INDEX = {stage: index for index, stage in enumerate(STAGES)}
@dataclass
class RunOptions:
adopt: bool = False # 接管无台账记录的既有工单
skip_tfs: bool = False # 只建单+导入+确认,不推送
rollback_on_fail: bool = False
limit: int | None = None
projects: list[str] = field(default_factory=list)
force: bool = False # 跳过 Excel 模板预检
dry_run: bool = False # 只演练:不产生任何服务端写入
@dataclass
class ProjectProgress:
row: PlanRow
stage: str
worksheet_id: int | None = None
version_id: int | None = None
elapsed_ms: int = 0
class Importer:
def __init__(self, cfg: Config, store: Store, run_id: str, log: RunLog, options: RunOptions) -> None:
self.cfg = cfg
self.store = store
self.run_id = run_id
self.log = log
self.options = options
self._thread_state = threading.local()
self.api = QuickTeamApi(
self._build_transport(),
dry_run=options.dry_run,
log=lambda message: self.log.info(message, level="dry"),
)
self._tfs_lock = threading.Lock()
self._tfs_context: tuple[str, str] | None = None
self.user: User | None = None
self.team_name = ""
# ------------------------------------------------------------------ 装配
def _build_transport(self):
from .transport import Transport
def on_call(op: str, elapsed: int, ok: bool, detail: str) -> None:
project = getattr(self._thread_state, "project", "")
self.store.add_event(
self.run_id,
stage="API",
action="ok" if ok else "fail",
project_id=getattr(self._thread_state, "project_id", ""),
detail=f"{op} {detail}".strip(),
elapsed_ms=elapsed,
)
self.log.event("API", "ok" if ok else "fail", project=project, detail=op, elapsed_ms=elapsed)
return Transport(
self.cfg.base_url, timeout=self.cfg.timeout, retries=2, on_call=on_call
)
# ------------------------------------------------------------------ plan
def connect(self) -> User:
if not self.user:
self.user = self.api.login(self.cfg.username, self.cfg.password)
teams = self.api.teams(self.user.user_id)
team = self.api.resolve_team(teams, self.cfg.team_name)
self.team_name = team.team_name
self.team_id = team.team_id
self.log.info(f"已登录:{self.user.real_name}({self.user.user_name}),团队:{team.team_name}")
return self.user
def plan(self) -> list[PlanRow]:
self.connect()
projects = self.api.projects(self.team_id)
files = domain.list_xlsx(self.cfg.work_dir)
rows: list[PlanRow] = []
for path in files:
stem = os.path.splitext(os.path.basename(path))[0]
project, reason = domain.match_project(stem, projects)
if project is None:
rows.append(PlanRow(project_name=stem, file_path=path, action=Action.ERROR, reason=reason))
continue
if not self.options.force:
ok, why = domain.precheck_xlsx(path)
if not ok:
rows.append(
PlanRow(project_name=project.project_name, file_path=path, action=Action.ERROR,
project=project, reason=f"Excel 预检失败:{why}")
)
continue
ledger = self.store.latest_job(project.project_id, self.cfg.sprint)
existing = [
sheet for sheet in self.api.work_sheets(project.project_id)
if sheet.system_name == self.cfg.sprint
]
action, why, worksheet = domain.decide(
existing, ledger_stage=ledger["stage"] if ledger else None, adopt=self.options.adopt
)
rows.append(
PlanRow(
project_name=project.project_name,
file_path=path,
action=action,
reason=why,
project=project,
worksheet=worksheet,
existing_count=len(existing),
)
)
return rows
# ------------------------------------------------------------------ apply
def apply(self) -> list[ProjectProgress]:
rows = self.plan()
write_rows = [row for row in rows if row.action.writes]
if self.options.projects:
wanted = {domain.normalize_name(name) for name in self.options.projects}
write_rows = [row for row in write_rows if domain.normalize_name(row.project_name) in wanted]
if self.options.limit:
write_rows = write_rows[: self.options.limit]
# 非执行行也落台账,报告里才能看到「为什么跳过」
for row in rows:
if row.action.writes:
continue
project_id = row.project.project_id if row.project else row.project_name
if self.store.latest_job(project_id, self.cfg.sprint):
# 已经有真实进度记录,别用 SKIPPED 覆盖它(否则下次会把「已推送」当成「无进度」)
self.log.event(str(row.action), "skip", project=row.project_name, detail=row.reason)
continue
self.store.upsert_job(
self.run_id,
project_id=project_id,
project_name=row.project_name,
system_name=self.cfg.sprint,
source_file=row.file_path,
action=str(row.action),
stage="SKIPPED" if row.action != Action.ERROR else "FAILED",
worksheet_id=row.worksheet.worksheet_id if row.worksheet else None,
reason=row.reason,
error=row.reason if row.action == Action.ERROR else "",
)
self.log.event(str(row.action), "skip", project=row.project_name, detail=row.reason)
if not write_rows:
self.log.info("没有需要执行的项目")
return []
self.log.info(f"开始执行 {len(write_rows)} 个项目(并发 {self.cfg.workers})")
results: list[ProjectProgress] = []
with ThreadPoolExecutor(max_workers=max(1, self.cfg.workers)) as pool:
futures = {pool.submit(self._run_project, row): row for row in write_rows}
for future in as_completed(futures):
row = futures[future]
try:
results.append(future.result())
except QtError as error:
if error.category in {Category.AUTH, Category.SP_MISSING}:
self.log.warn(f"中止:{error}")
raise
results.append(ProjectProgress(row=row, stage="FAILED"))
self.log.warn(f"{row.project_name} 失败:{error}")
except Exception as error: # 兜底,避免一个项目的异常带崩整批
results.append(ProjectProgress(row=row, stage="FAILED"))
self.log.warn(f"{row.project_name} 未预期异常:{error!r}")
return results
def _run_project(self, row: PlanRow) -> ProjectProgress:
assert row.project is not None
started = time.time()
self._thread_state.project = row.project_name
self._thread_state.project_id = row.project.project_id
ledger = self.store.latest_job(row.project.project_id, self.cfg.sprint)
if row.action == Action.CREATE:
# plan 已经确认服务端没有该 Sprint 的工单,台账里的旧记录可能来自已被回滚的
# 半成品(工单已删除)。此时必须忽略台账,否则会去填一张不存在的工单。
ledger = None
done_upto = self._completed_index(ledger["stage"] if ledger else "")
worksheet_id = (ledger or {}).get("worksheet_id") or (
row.worksheet.worksheet_id if row.worksheet else None
)
version_id: int | None = (ledger or {}).get("version_id")
def need(stage: str) -> bool:
return STAGE_INDEX[stage] > done_upto
stage = "PLANNED"
created_here = row.action == Action.CREATE
try:
if need("WS_CREATED"):
stage = "WS_CREATED"
worksheet_id = self.api.create_worksheet()
self._mark(row, stage, worksheet_id, None)
if need("WS_FILLED"):
stage = "WS_FILLED"
assert worksheet_id is not None
evaluator, technical, customer = self.cfg.resolved_roles(self.user.real_name)
self.api.fill_worksheet(
worksheet_id,
row.project,
system_name=self.cfg.sprint,
start_time=self.cfg.start_time,
end_time=self.cfg.end_time,
evaluator=evaluator,
technical_leader=technical,
customer_leader=customer,
belong_team=self.cfg.belong_team,
content=self.cfg.content_desc,
operator=self.user.real_name,
service_type=self.cfg.service_type,
)
self._mark(row, stage, worksheet_id, None)
if need("EXCEL_IMPORTED"):
stage = "EXCEL_IMPORTED"
assert worksheet_id is not None
version = self._version(row.project.project_id, worksheet_id)
version_id = version.version_id
remote = self.api.upload_excel(row.file_path)
self.api.import_excel(worksheet_id, version_id, remote)
self._mark(row, stage, worksheet_id, version_id)
if need("WS_VERIFIED"):
stage = "WS_VERIFIED"
assert worksheet_id is not None
detail = self._detail(worksheet_id)
version = self._version(row.project.project_id, worksheet_id)
if detail.done:
self.log.event(stage, "skip", project=row.project_name, detail="工单已完成,无需确认")
elif version is None:
raise QtError(f"工单 {worksheet_id} 没有版本记录,无法确认", Category.PARAM)
elif domain.VERSION_TRANSITIONS.get(version.version_code) is None:
self.log.event(stage, "skip", project=row.project_name, detail="已是流程终点,无需确认")
else:
try:
next_operator, next_code, percent = domain.next_transition(version.version_code)
except ValueError as error:
raise QtError(str(error), Category.PARAM) from None
self.api.verify_worksheet(
worksheet_id,
version.version_id,
next_operator=next_operator,
next_version_code=next_code,
operator=self.user.real_name,
percent=percent,
)
self._mark(row, stage, worksheet_id, version.version_id if version else version_id)
if need("TFS_PUSHED"):
assert worksheet_id is not None
if self.options.skip_tfs:
self.log.event("TFS_PUSHED", "skip", project=row.project_name, detail="--skip-tfs")
else:
stage = "TFS_PUSHED"
area, iteration = self._resolve_tfs()
features = [] if self.api.dry_run else [
item.feature_id for item in self.api.features(worksheet_id)
]
if not features and not self.api.dry_run:
raise QtError("工单没有功能模块,跳过推送(请检查 Excel 是否为空表)", Category.EXCEL)
created = self.api.push_tfs(features, self.cfg.tfs_project, area, iteration)
self.log.event(
"TFS_PUSHED", "ok", project=row.project_name,
detail=f"features={len(features)} 创建={created}",
)
self._mark(row, stage, worksheet_id, version_id)
elapsed = int((time.time() - started) * 1000)
self.log.info(f" ✓ {row.project_name} → {stage} {elapsed / 1000:.1f}s")
return ProjectProgress(row=row, stage=stage, worksheet_id=worksheet_id,
version_id=version_id, elapsed_ms=elapsed)
except QtError as error:
elapsed = int((time.time() - started) * 1000)
self.store.upsert_job(
self.run_id,
project_id=row.project.project_id,
project_name=row.project_name,
system_name=self.cfg.sprint,
source_file=row.file_path,
action=str(row.action),
stage=f"FAILED:{stage}",
worksheet_id=worksheet_id,
version_id=version_id,
reason=row.reason,
error=str(error),
)
self.store.add_event(
self.run_id, stage=stage, action="fail", project_id=row.project.project_id,
detail=str(error), elapsed_ms=elapsed,
)
self.log.warn(f"{row.project_name} 在 {stage} 失败:{error}")
if self.options.rollback_on_fail and created_here and STAGE_INDEX[stage] < STAGE_INDEX["WS_VERIFIED"]:
self._rollback(row, worksheet_id, stage)
if error.category in {Category.AUTH, Category.SP_MISSING}:
raise
return ProjectProgress(row=row, stage=f"FAILED:{stage}", worksheet_id=worksheet_id,
version_id=version_id, elapsed_ms=elapsed)
finally:
self._thread_state.project = ""
self._thread_state.project_id = ""
# ------------------------------------------------------------------ 辅助
@staticmethod
def _completed_index(stage: str) -> int:
"""台账阶段 → 已完成的阶段下标。
`FAILED:EXCEL_IMPORTED` 表示「导入这一步失败了」,所以它之前的都算完成,
重跑要从 EXCEL_IMPORTED 本身重试,而不是从头再来(否则会重复建单)。
"""
if not stage:
return -1
if stage.startswith("FAILED:"):
failed_at = stage.split(":", 1)[1]
return STAGE_INDEX.get(failed_at, 0) - 1
return STAGE_INDEX.get(stage, -1)
def _detail(self, worksheet_id: int) -> WorkSheet:
if self.api.dry_run:
return WorkSheet(worksheet_id=worksheet_id, version_id=0, version_code="")
return self.api.worksheet(worksheet_id)
def _version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion | None:
"""当前待确认版本。导入明细与确认工单都挂在它上面。
`WorkSheet.CurrentVersionId` 在第一次保存后仍为 NULL(要等确认才回填),
所以不能拿它当 VersionId 用 —— 那会把 WorkSheetTask 插到不存在的版本上,
报 FK_WorkSheetTask_WorkSheetVersion 冲突。
"""
if self.api.dry_run:
return WorkSheetVersion(version_id=0, worksheet_id=worksheet_id,
version_code="技术责任人确认版", version_status="N")
return self.api.pending_version(project_id, worksheet_id)
def _resolve_tfs(self) -> tuple[str, str]:
if self.cfg.area_path and self.cfg.iteration_path:
return self.cfg.area_path, self.cfg.iteration_path
with self._tfs_lock:
if self._tfs_context:
return self._tfs_context
projects = self.api.tfs_projects()
target = next((p for p in projects if p.get("Name") == self.cfg.tfs_project), None)
if target is None:
raise QtError(f"TFS 项目「{self.cfg.tfs_project}」不存在", Category.TFS)
teams = self.api.tfs_teams(self.cfg.tfs_project, target["ProjectId"])
area, iteration = QuickTeamApi.resolve_tfs_context(teams, self.cfg.tfs_team, self.cfg.sprint)
self._tfs_context = (area, iteration)
self.log.info(f"TFS 上下文:area={area} iteration={iteration}")
return self._tfs_context
def _mark(
self, row: PlanRow, stage: str, worksheet_id: int | None, version_id: int | None
) -> None:
assert row.project is not None
self.store.upsert_job(
self.run_id,
project_id=row.project.project_id,
project_name=row.project_name,
system_name=self.cfg.sprint,
source_file=row.file_path,
action=str(row.action),
stage=stage,
worksheet_id=worksheet_id,
version_id=version_id,
reason=row.reason,
)
self.log.event(stage, "ok", project=row.project_name, detail=f"ws={worksheet_id}")
def _rollback(self, row: PlanRow, worksheet_id: int | None, stage: str) -> None:
if not worksheet_id:
return
try:
self.api.delete_worksheet(worksheet_id)
self.store.add_event(
self.run_id, stage=stage, action="rollback", project_id=row.project.project_id,
detail=f"已删除半成品工单 {worksheet_id}",
)
self.log.warn(f"{row.project_name} 已回滚(删除工单 {worksheet_id})")
except QtError as error:
self.log.warn(f"{row.project_name} 回滚失败:{error}")