初始提交: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
+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}")