初始提交: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,211 @@
|
||||
"""编排测试:阶段推进、断点续跑、失败归属与回滚(全部用 FakeApi,不联网)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from qtimport.config import Config
|
||||
from qtimport.domain import Action, PlanRow
|
||||
from qtimport.errors import Category, QtError
|
||||
from qtimport.models import Feature, Project, User, WorkSheet, WorkSheetVersion
|
||||
from qtimport.store import Store
|
||||
from qtimport.workflow import Importer, RunOptions
|
||||
|
||||
|
||||
class FakeLog:
|
||||
def __init__(self) -> None:
|
||||
self.lines: list[str] = []
|
||||
|
||||
def info(self, message: str, **_) -> None:
|
||||
self.lines.append(message)
|
||||
|
||||
def warn(self, message: str, **_) -> None:
|
||||
self.lines.append("!" + message)
|
||||
|
||||
def event(self, *_, **__) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeApi:
|
||||
dry_run = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.deleted: list[int] = []
|
||||
self.version_code = "技术责任人确认版"
|
||||
self.import_error: QtError | None = None
|
||||
|
||||
def create_worksheet(self) -> int:
|
||||
self.calls.append("create")
|
||||
return 501
|
||||
|
||||
def fill_worksheet(self, worksheet_id, project, **kwargs) -> int:
|
||||
self.calls.append("fill")
|
||||
return 1
|
||||
|
||||
def worksheet(self, worksheet_id: int) -> WorkSheet:
|
||||
self.calls.append("detail")
|
||||
return WorkSheet(worksheet_id=worksheet_id, status="01", version_id=88, version_code=self.version_code)
|
||||
|
||||
def pending_version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion:
|
||||
"""只读查版本,不计入 calls —— 保持断言只看写操作序列。"""
|
||||
return WorkSheetVersion(
|
||||
version_id=88, worksheet_id=worksheet_id,
|
||||
version_code=self.version_code, version_status="N",
|
||||
)
|
||||
|
||||
def upload_excel(self, path: str, folder: str = "WorkSheetImport") -> str:
|
||||
self.calls.append("upload")
|
||||
return "E:\\tmp\\x.xlsx"
|
||||
|
||||
def import_excel(self, worksheet_id: int, version_id: int, remote: str) -> None:
|
||||
self.calls.append("import")
|
||||
if self.import_error:
|
||||
raise self.import_error
|
||||
|
||||
def verify_worksheet(self, worksheet_id, version_id, **kwargs) -> None:
|
||||
self.calls.append("verify")
|
||||
|
||||
def features(self, worksheet_id: int) -> list[Feature]:
|
||||
self.calls.append("features")
|
||||
return [Feature(1, "M1", "模块")]
|
||||
|
||||
def push_tfs(self, feature_ids, project_name, area_path, iteration_path):
|
||||
self.calls.append("push")
|
||||
return []
|
||||
|
||||
def delete_worksheet(self, worksheet_id: int) -> None:
|
||||
self.calls.append("delete")
|
||||
self.deleted.append(worksheet_id)
|
||||
|
||||
|
||||
class WorkflowTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.store = Store(os.path.join(self.folder.name, "t.db"))
|
||||
self.store.start_run("r1", "2026 Sprint 10", "d", dry_run=False)
|
||||
self.cfg = Config(
|
||||
base_url="http://example.invalid",
|
||||
username="u",
|
||||
password="p",
|
||||
sprint="2026 Sprint 10",
|
||||
start_time="2026-10-01",
|
||||
end_time="2026-10-31",
|
||||
tfs_project="Tools_Dev",
|
||||
area_path="Tools_Dev\\DataTechnologies",
|
||||
iteration_path="Tools_Dev\\2026 Sprint\\2026 Sprint 10",
|
||||
workers=1,
|
||||
db_path=os.path.join(self.folder.name, "t.db"),
|
||||
)
|
||||
self.log = FakeLog()
|
||||
self.api = FakeApi()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.store.close()
|
||||
self.folder.cleanup()
|
||||
|
||||
def _importer(self, **options) -> Importer:
|
||||
importer = Importer(self.cfg, self.store, "r1", self.log, RunOptions(**options))
|
||||
importer.api = self.api
|
||||
importer.user = User("g1", "陈首林", "u")
|
||||
importer.team_id = "t1"
|
||||
return importer
|
||||
|
||||
def _row(self, action: Action = Action.CREATE, worksheet: WorkSheet | None = None) -> PlanRow:
|
||||
return PlanRow(
|
||||
project_name="信托 - 上海信托",
|
||||
file_path="f.xlsx",
|
||||
action=action,
|
||||
project=Project("p1", "信托 - 上海信托"),
|
||||
worksheet=worksheet,
|
||||
)
|
||||
|
||||
def test_full_pipeline(self) -> None:
|
||||
progress = self._importer()._run_project(self._row())
|
||||
self.assertEqual(progress.stage, "TFS_PUSHED")
|
||||
self.assertEqual(
|
||||
self.api.calls, ["create", "fill", "upload", "import", "detail", "verify", "features", "push"]
|
||||
)
|
||||
self.assertEqual(self.store.latest_stage("p1", "2026 Sprint 10"), "TFS_PUSHED")
|
||||
|
||||
def test_resume_from_imported(self) -> None:
|
||||
"""台账停在 EXCEL_IMPORTED,只该跑 确认 + 推送,不能重复建单。"""
|
||||
self.store.upsert_job(
|
||||
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
|
||||
source_file="f.xlsx", action="create", stage="EXCEL_IMPORTED", worksheet_id=501, version_id=88,
|
||||
)
|
||||
progress = self._importer()._run_project(self._row(Action.RESUME, WorkSheet(501)))
|
||||
self.assertEqual(progress.stage, "TFS_PUSHED")
|
||||
self.assertEqual(self.api.calls, ["detail", "verify", "features", "push"])
|
||||
|
||||
def test_skip_tfs(self) -> None:
|
||||
progress = self._importer(skip_tfs=True)._run_project(self._row())
|
||||
self.assertNotIn("push", self.api.calls)
|
||||
self.assertEqual(progress.stage, "WS_VERIFIED")
|
||||
|
||||
def test_import_failure_is_attributed(self) -> None:
|
||||
self.api.import_error = QtError("模板不符", Category.EXCEL)
|
||||
progress = self._importer()._run_project(self._row())
|
||||
self.assertEqual(progress.stage, "FAILED:EXCEL_IMPORTED")
|
||||
job = self.store.latest_job("p1", "2026 Sprint 10")
|
||||
self.assertIn("EXCEL", job["error"])
|
||||
self.assertEqual(self.api.deleted, [], "未开 --rollback-on-fail 不应删单")
|
||||
|
||||
def test_rollback_on_failure(self) -> None:
|
||||
self.api.import_error = QtError("模板不符", Category.EXCEL)
|
||||
self._importer(rollback_on_fail=True)._run_project(self._row())
|
||||
self.assertEqual(self.api.deleted, [501])
|
||||
|
||||
def test_no_rollback_when_not_created_here(self) -> None:
|
||||
self.api.import_error = QtError("模板不符", Category.EXCEL)
|
||||
row = self._row(Action.RESUME, WorkSheet(501))
|
||||
self.store.upsert_job(
|
||||
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
|
||||
source_file="f.xlsx", action="resume", stage="WS_FILLED", worksheet_id=501,
|
||||
)
|
||||
self._importer(rollback_on_fail=True)._run_project(row)
|
||||
self.assertEqual(self.api.deleted, [], "接管别人的单不能删")
|
||||
|
||||
def test_resume_after_failure_does_not_recreate(self) -> None:
|
||||
"""上次在导入环节失败,重跑必须复用同一张工单,不能重新建。"""
|
||||
self.store.upsert_job(
|
||||
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
|
||||
source_file="f.xlsx", action="create", stage="FAILED:EXCEL_IMPORTED",
|
||||
worksheet_id=501, version_id=88,
|
||||
)
|
||||
progress = self._importer()._run_project(self._row(Action.RESUME, WorkSheet(501)))
|
||||
self.assertNotIn("create", self.api.calls)
|
||||
self.assertNotIn("fill", self.api.calls)
|
||||
self.assertEqual(self.api.calls[0], "upload", "失败点重试,先重传再导入")
|
||||
self.assertEqual(progress.stage, "TFS_PUSHED")
|
||||
|
||||
def test_create_ignores_stale_ledger(self) -> None:
|
||||
"""回滚删单后台账仍记着旧 WorkSheetId,重新规划为 create 时必须重新建单。"""
|
||||
self.store.upsert_job(
|
||||
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
|
||||
source_file="f.xlsx", action="create", stage="FAILED:WS_FILLED", worksheet_id=999,
|
||||
)
|
||||
progress = self._importer()._run_project(self._row(Action.CREATE))
|
||||
self.assertEqual(self.api.calls[0], "create")
|
||||
self.assertNotIn(999, [progress.worksheet_id])
|
||||
|
||||
def test_completed_index(self) -> None:
|
||||
self.assertEqual(Importer._completed_index(""), -1)
|
||||
self.assertEqual(Importer._completed_index("WS_FILLED"), 1)
|
||||
self.assertEqual(Importer._completed_index("FAILED:WS_FILLED"), 0)
|
||||
self.assertEqual(Importer._completed_index("FAILED:WS_CREATED"), -1)
|
||||
self.assertEqual(Importer._completed_index("SKIPPED"), -1)
|
||||
|
||||
def test_terminal_version_skips_verify(self) -> None:
|
||||
self.api.version_code = "甲方确认版"
|
||||
self._importer()._run_project(self._row())
|
||||
self.assertNotIn("verify", self.api.calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user