初始提交: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:
@@ -0,0 +1,3 @@
|
||||
"""QuickTeam 工单接口导入工具。"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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
@@ -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")),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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, ""
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user