初始提交: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
+24
View File
@@ -0,0 +1,24 @@
# 本地配置与产物
config/config.json
reports/
logs/
output/
state/
# Python
__pycache__/
*.py[cod]
.venv/
venv/
# WorkBuddy 本地记忆/会话
.workbuddy/
# 编辑器
.vscode/
.idea/
*.swp
# 系统
Thumbs.db
desktop.ini
+138
View File
@@ -0,0 +1,138 @@
# QTImport
通过接口(而非浏览器自动化)把工单数据批量导入 QuickTeam 的项目。
替代 `D:\Learning\Python\工单自动化\auto_quickteam.py`(Selenium 版):单项目 2~3 分钟、依赖 DOM、易随前端改版失效,失败重跑还可能重复建单。
## 状态
| 阶段 | 内容 | 状态 |
| --- | --- | --- |
| M0 | 接口调研 + 只读探针 | ✅ 完成 |
| M1 | 读链路(登录/项目/工单/上传/TFS 上下文) | ✅ 完成,已在生产实测 |
| M2 | 写入链路(建单/填单/导入/确认/推送) | ✅ 完成,单项目已真实写入并核对落库 |
| M3 | 幂等台账、断点续跑、回滚、报告 | ✅ 完成,69 个单测通过 |
| M4 | 真实项目联调 + 灰度 | ✅ 12 个项目已真实执行(工单 225134–225145) |
全量实测:Sprint `2026 Sprint 10` 的 12 个项目全部建单 → 导入明细(33 个功能模块,逐文件与 Excel 核对一致)
→ 确认 → 推送 TFS,整轮 175 s(旧方案同流程约 25~35 分钟),重跑 `plan` 全部 `skip-pushed`。设计文档与联调踩坑记录:
[docs/QuickTeam-API导入-调研与设计.md](docs/QuickTeam-API导入-调研与设计.md) §8。
## 为什么可行
QuickTeam 前端是 Vue SPA,业务数据全部经一层「WCF + 通用存储过程网关」读写:
```
POST /Services/CommonService.svc/ExecTable
{ "connectionName": "QuickTeam", "param": "<encodeURIComponent(SPName+SQLParams)>" }
```
前端本身就是这套接口的客户端,复刻调用序列即可绕开浏览器与 DOM。实测:登录 0.5s、215 个项目 1.7s、
单项目工单查询 1.05s、文件上传 0.19s;12 个项目的完整演练 19s(旧方案约 25~35 分钟)。
## 快速开始
```bash
cp config/config.example.json config/config.json # 填账号、Sprint、目录、起止日期
```
凭据可用环境变量覆盖,避免落盘:`export QT_PASSWORD=***`。
```bash
# 1. 只读预演:能做什么、为什么跳过
python -m qtimport plan --config config/config.json
# 2. 演练完整链路(不发任何写请求,打印每一步的真实参数)
python -m qtimport apply --config config/config.json --dry-run
# 3. 真实执行(必须显式 --yes)
python -m qtimport apply --config config/config.json --yes
# 4. 看状态与报告
python -m qtimport status --config config/config.json
python -m qtimport report --config config/config.json
```
`plan` 输出:
```
项目 动作 说明
信托 - 上海信托 create 本项目该 Sprint 无工单
资管 - 华泰保险资管 create 本项目该 Sprint 无工单
资管 - 华泰资管 create 本项目该 Sprint 无工单
```
## 幂等与续跑
- 业务唯一键 `(ProjectId, SystemName)`:同一项目同一 Sprint 只建一张工单。
- 台账在 `state/qtimport.db`(SQLite),每推进一个阶段落一次,中断后重跑自动从断点继续。
- 已存在但无台账记录的工单默认跳过(不接管别人的单);确认要接管加 `--adopt`。
- 已推送 TFS 的不再推;失败时可用 `--rollback-on-fail` 删掉本次新建的半成品(已推送的绝不删)。
常用参数:
| 参数 | 用途 |
| --- | --- |
| `--project "资管 - 粤海资本"` | 只处理指定项目(可多次) |
| `--limit 1` | 只跑前 1 个,联调用 |
| `--skip-tfs` | 只建单+导入+确认,不推送 |
| `--adopt` | 接管无台账记录的既有工单 |
| `--rollback-on-fail` | 失败时回滚本次新建的半成品 |
| `--workers 3` | 并发数(默认取配置,写路径建议 ≤4) |
| `--force` | 跳过 Excel 模板预检 |
## 联调(唯一剩余的验证)
第一次真实写入建议这样跑,只影响一个项目:
```bash
python -m qtimport apply --config config/config.json \
--project "资管 - 粤海资本" --limit 1 --skip-tfs --rollback-on-fail --yes
```
预期:建单 → 填 17 个字段 → 上传 xlsx → 服务器解析明细 → 确认工单(技术责任人确认版 → 客户责任人确认版)。
然后到 QuickTeam 页面上核对:工单数、明细行数、三个责任人、起止日期、状态。
核对无误后再去掉 `--skip-tfs` 验证推送,最后 `--limit 1` 换成全量。
**注意**:`config.start_time` / `end_time` 会原样写进工单,执行前务必确认为该 Sprint 的真实区间。
## 目录
```
QTImport/
├── config/
│ ├── config.example.json # 模板
│ └── config.json # 含凭据,已 gitignore
├── docs/
│ └── QuickTeam-API导入-调研与设计.md
├── qtimport/
│ ├── transport.py # WCF 网关:双层 JSON、错误解析、裸字节上传
│ ├── api.py # 存储过程语义封装
│ ├── domain.py # 项目匹配、动作判定、版本推进、Excel 预检
│ ├── workflow.py # 阶段编排、并发、回滚
│ ├── store.py # SQLite 台账
│ ├── report.py # summary.md / detail.csv / JSONL
│ └── __main__.py # CLI
├── tools/
│ └── api_probe.py # 只读探针(零依赖,可独立交付)
└── tests/ # 69 个用例,无需网络
```
## 测试
```bash
python -m unittest discover -s tests -t .
```
覆盖:双层 JSON 解码、WCF 错误分类、项目名精确匹配(避免 `contains` 误命中)、
动作判定全分支、版本推进、Excel 预检、17 个参数构造、Column1 判定、TFS 路径解析、
dry-run 隔离、断点续跑、失败归属与回滚。
## 环境
Python 3.12+,零第三方依赖,不需要浏览器。需能访问 `http://192.168.1.213`。
## 注意
- `ExecTable` 网关无身份校验,本工具只在内网执行;请使用个人账号(`Operator` 会写入真实姓名,便于审计)。
- 凭据不要提交:`config/config.json`、`state/`、`reports/`、`logs/` 均在 `.gitignore` 中。
+27
View File
@@ -0,0 +1,27 @@
{
"base_url": "http://192.168.1.213",
"username": "chenshoulin@goldenstand.cn",
"password": "CHANGE_ME",
"timeout": 60,
"team_name": "当前项目",
"work_dir": "D:/Work/QuickTeam/2026 Sprint 10",
"sprint": "2026 Sprint 10",
"start_time": "2026-10-08",
"end_time": "2026-10-31",
"content_desc": "需求开发测试",
"service_type": "DevelopService",
"belong_team": "资管投资人事业部",
"evaluator": "",
"technical_leader": "",
"customer_leader": "",
"tfs_project": "Tools_Dev",
"tfs_team": "DataTechnologies",
"area_path": "",
"iteration_path": "",
"workers": 2,
"db_path": "state/qtimport.db"
}
+724
View File
@@ -0,0 +1,724 @@
# QuickTeam 工单接口导入 —— 调研与设计
| 项 | 内容 |
| --- | --- |
| 版本 | v1.0(调研 + 设计,未进入编码) |
| 日期 | 2026-11-05 |
| 目标系统 | QuickTeam `http://192.168.1.213` |
| 现有实现 | `D:\Learning\Python\工单自动化\auto_quickteam.py`(Selenium) |
| 结论摘要 | **可行**。QuickTeam 无公开 REST API,但其前端全部业务数据都经一套「WCF + 通用存储过程网关」读写,该网关可被直接调用;已实测登录、项目、工单查询、文件上传四类接口,写入链路 5 个存储过程的参数契约已从前端产物完整还原。 |
---
## 1. 背景与现状
### 1.1 现有做法
`auto_quickteam.py` 用 Selenium 驱动 Chrome,按「项目」逐个完成 5 步动作:
| 步骤 | 函数 | 实际动作 |
| --- | --- | --- |
| 1 | `login` / `open_team` | 打开 `#/Login`,填账号密码,进「当前项目」 |
| 2 | `open_project` | 搜索项目名 → 点项目卡片 → 点「工单」页签(`tab-seven`) |
| 3 | `create_work_order` | 点「新增」→ 填系统名称/所属团队/起止时间/实施内容/三个责任人 → 保存 |
| 4 | `entry_work_order_info` | 点进工单 → 编辑 → 隐藏的 `input[type=file]` 上传 xlsx → 点「确认工单」→ 返回 |
| 5 | `push_work_order_to_tfs` | 点「推送功能模块」→ 选 TFS 项目 `Tools_Dev` → 选迭代 → 全选 → 批量推送 |
进度靠 `done.txt` 记录,只在整条流程成功后追加一行。
### 1.2 慢在哪
代码里的固定 `sleep` 合计约 **128 秒/项目**(`open_project` 17.5s + `create_work_order` 41s + `entry_work_order_info` 14s + `push_work_order_to_tfs` 56s),再加上元素等待、浏览器渲染与网络往返,单项目实际 **2~3 分钟**。一个 Sprint 12~13 个项目就是 **25~35 分钟**,全程需保持前台浏览器与稳定网络。
### 1.3 不稳在哪
| 根因 | 表现 |
| --- | --- |
| 依赖 DOM 结构 | XPath 写死 Element UI 结构,前端一改版即失效(旧版甚至用 `/html/body/section/.../input` 绝对路径) |
| 固定等待 vs 真实加载 | `sleep(10)` 等保存、`sleep(20)` 等推送,加载慢时 `TimeoutException`;快时又白等 |
| 弹层/遮罩时序 | `el-loading-mask`、`el-select-dropdown`(`x-placement`)出现与消失时机不定 |
| 上传控件不可见 | 必须 JS 强改 `display` 才能赋值,任何前端调整都会破坏 |
| 项目名用 `contains` 匹配 | `资管 - 华泰资管` 同时命中 `资管 - 华泰保险资管`,存在点错项目的风险(现有 `open_project_error_*.html` 已记录多起同名/近名项目失败) |
| 单实例线性执行 | 任一步失败整批中断,无重试、无断点续跑 |
| `done.txt` 语义粗糙 | 只在成功后写,中途失败重跑会**重复建工单**;也没有可核对的中间状态 |
---
## 2. 接口调研
### 2.1 核心发现:后端不是 REST,而是「通用存储过程网关」
QuickTeam 前端是 Vue 2 + Element UI 的 SPA(`/QuickTeam/`,hash 路由)。抓取并分析其构建产物后确认,前端所有业务读写都汇聚到少数几个 WCF 端点,其中数据层只有一个入口:
```
POST http://192.168.1.213/Services/CommonService.svc/ExecTable
Content-Type: application/json
{
"connectionName": "QuickTeam", // 数据库连接名:QuickTeam / UserCenter
"param": "%7B%22SPName%22%3A...%7D" // encodeURIComponent(JSON)
}
```
`param` 解码后为:
```json
{
"SPName": "QuickTeam.usp_GetProjectsByTeamId",
"SQLParams": [
{ "Name": "TeamId", "Value": "090F8E57-...", "DBType": "string" },
{ "Name": "IsTemplate", "Value": "1", "DBType": "string" }
]
}
```
配套还有一个 `ExecResult`(标量/返回值语义,如 `Result`、`Column1`),另外两个服务:
| 端点 | 用途 | 本项目是否使用 |
| --- | --- | --- |
| `/Services/CommonService.svc/` | `ExecTable` / `ExecResult`:查项目、查工单、建工单、确认工单 | 是(核心) |
| `/Services/FileOperationService.svc/` | `FileUpload`(上传 xlsx)、`BatchImportFeature`(服务器解析 Excel 写库) | 是(导入) |
| `/Services/TFSOperationService.svc/` | `GetTeamProjects`、`BatchImportWorkItemsToTfs`(推送 TFS) | 是(推送) |
| `/TaskProcessEngine/...` | 任务流程引擎,前端仅用它换 Token | 否 |
**这意味着:所谓「通过接口导入」并不需要官方开放 API —— 前端自己就是一个接口客户端,我们只要复刻它的调用序列,就能绕开浏览器与 DOM。**
### 2.2 实测结果(只读)
在 `192.168.1.213` 上以 `chenshoulin@goldenstand.cn` 实测,全部通过:
| 调用 | 结果 | 耗时 |
| --- | --- | --- |
| `UserCenter.dbo.usp_Authentication`(账号 + MD5 密码) | 返回 `GuidId/UserName/RealName/Token` | 0.50 s |
| `QuickTeam.usp_GetTeamsByUserId` | 返回 2 个团队(当前项目 / 归档项目) | 0.46 s |
| `QuickTeam.usp_GetProjectsByTeamId` | 返回 **215** 个项目 | 1.73 s |
| `QuickTeam.usp_GetWeeklyWorkSheetsByProjectId` | 单项目 3~4 条工单 | 0.28~1.35 s |
| `QuickTeam.usp_GetWorkSheetById` | 返回完整工单字段(见 §2.5) | ≈0.3 s |
| `QuickTeam.usp_GetProjectUsersByProjectId` | 单项目 14 名可指派人员 | 0.34 s |
| `FileOperationService.FileUpload` | 返回服务器路径 `E:\TSSWCFServices\FileBaseFoloder\WorkSheetImport\<ts>.xlsx` | 0.19 s |
| `TFSOperationService.GetTeamProjects` | 9 个 TFS 项目(`Tools_Dev` 等) | ≈1 s |
| `TFSOperationService.GetTeamByProject` | `Tools_Dev` 下 9 个团队,含 `AreaPath` 与 107 条迭代 | ≈2 s |
**没有使用 Cookie/令牌**:`ExecTable` 不校验身份,登录接口只是前端用来取 `UserId/RealName` 做界面门禁与 `Operator` 字段填充。这一点对实现是便利,对安全是隐患(见 §6 R6)。
配套只读探针已落在 `tools/api_probe.py`,一个 Sprint 的现状扫描(12 个项目、含 N+1 次工单详情)实测 **12.63 s,平均 1.05 s/项目**——这已经比 Selenium 完成一个项目的耗时低两个数量级。
### 2.3 调用链还原(与 Selenium 步骤一一对应)
前端产物中可直接读出以下序列,即本项目的目标行为:
```
登录 usp_Authentication(username, md5(password)) -> GuidId, RealName
建工单 usp_InsertWorkSheet() -> [{WorkSheetId}]
usp_UpdateWorkSheetById(...17 个字段, Operation="new") -> [{Result}]
导入明细 FileUpload(file) -> {FileUploadResult: 服务器路径}
BatchImportFeature(connectionName, param, filePath) -> [{Column1: 0}] (0=成功)
确认工单 usp_GetWorkSheetById(WorkSheetId) -> CurrentVersionId / VersionCode
usp_VerifyWorkSheetById(...) -> 0 (成功)
推送 TFS usp_GetWorkFeaturesByWorkSheetIds(WorkSheetIds) -> FeatureId 列表
BatchImportWorkItemsToTfs(projectName, areaPath, iterationPath)
```
### 2.4 接口契约清单
#### 2.4.1 `CommonService.svc/ExecTable`
| 存储过程 | 参数(Name / DBType) | 返回 |
| --- | --- | --- |
| `UserCenter.dbo.usp_Authentication` | `UserName`/string,`Password`/string(**MD5 摘要**) | 用户行 |
| `QuickTeam.usp_GetTeamsByUserId` | `UserId`/string | 团队行(`TeamId`、`TeamName`、`Role`) |
| `QuickTeam.usp_GetProjectsByTeamId` | `TeamId`/string,`IsTemplate`/string(固定 `"1"`) | 项目行(`ProjectId`、`ProjectName`) |
| `QuickTeam.usp_GetWeeklyWorkSheetsByProjectId` | `ProjectId`/string,`Monday`/string,`Sunday`/string | 工单行(`WorkSheetId`、`WorkSheetCode`、`Status`) |
| `QuickTeam.usp_GetWorkSheetById` | `WorkSheetId`/int | 工单详情(单行) |
| `QuickTeam.usp_GetProjectUsersByProjectId` | `ProjectId`/string | 项目人员(`UserId`、`RealName`) |
| `QuickTeam.usp_InsertWorkSheet` | **无参数** | `[{WorkSheetId}]` |
| `QuickTeam.usp_UpdateWorkSheetById` | 见下表 | `[{Result}]`,>0 表示成功 |
| `QuickTeam.usp_VerifyWorkSheetById` | `WorkSheetId`/int,`VersionId`/int,`NextOperator`/string,`NextVersionCode`/string,`Percent`/int,`Operator`/string | `0` = 成功(走 `ExecResult`) |
| `QuickTeam.usp_GetWorkFeaturesByWorkSheetIds` | `WorkSheetIds`/string(逗号分隔) | 功能模块行(含 `FeatureId`、`FeatureCode`) |
| `QuickTeam.usp_DeleteWorkSheetAllById` | `WorkSheetId`/int | 删除整单(**回滚用**) |
`usp_UpdateWorkSheetById` 的 17 个参数(前端 `chunk-0cadb1de` 中原样提取):
| 参数 | 类型 | 取值来源 |
| --- | --- | --- |
| `WorkSheetId` | string | 上一步返回值 |
| `ProjectId` | string | 项目 GUID |
| `ProjectName` | string | 项目名 |
| `SystemName` | string | **Sprint 名称**(如 `2026 Sprint 10`) |
| `Evaluator` | string | 工单评估人**真实姓名** |
| `TechnicalLeader` | string | 技术负责人真实姓名 |
| `CustomerLeader` | string | 客户负责人真实姓名 |
| `StartTime` / `EndTime` | string | `yyyy-MM-dd` |
| `Status` | string | `00`~`04`(见 §2.6) |
| `ServiceType` | string | 默认 `DevelopService` |
| `ImplementContent` | string | 实施内容,默认 `需求开发测试` |
| `OtherInstructions` | string | 其它说明(可空) |
| `Operator` | string | 操作人真实姓名 |
| `DeleteFlag` | string | 固定 `"0"` |
| `Operation` | string | 新建固定 `"new"`,修改 `"update"` |
| `BelongTeam` | string | 所属团队(如 `资管投资人事业部`) |
#### 2.4.2 `FileOperationService.svc`
| 操作 | 请求 | 返回 |
| --- | --- | --- |
| `FileUpload?fileName=<ts>.xlsx&fileFolder=WorkSheetImport` | `multipart/form-data`,文件字段名不限(实测 `file`/`upload` 均可) | `{"FileUploadResult":"E:\\TSSWCFServices\\FileBaseFoloder\\WorkSheetImport\\<ts>.xlsx"}` |
| `BatchImportFeature` | `{connectionName:"QuickTeam", param:<同 ExecTable>, filePath:"<上一步路径>"}`,其中 `SPName = QuickTeam.usp_BatchImportFeatureFromExcel`,`SQLParams = [WorkSheetId/int, VersionId/int]` | `[{Column1: 0}]`,**0 为成功** |
#### 2.4.3 `TFSOperationService.svc`
| 操作 | 请求 | 返回 |
| --- | --- | --- |
| `GetTeamProjects` | 空 body | TFS 项目列表(实测 9 个:`BeiJing_Dev`、`Tools_Dev`、`ShangHai_Dev`…,含 `ProjectId`) |
| `GetTeamByProject` | `{projectName:"Tools_Dev", projectId:"adbb5ee9-..."}` | 团队列表,每项含 `TeamName`、`AreaPath: ["Tools_Dev\\DataTechnologies"]`、`Iterations: [{Id, Name, Path}]` |
| `BatchImportWorkItemsToTfs` | `{connectionName:"QuickTeam", param:<SPName=[QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds], FeatureIds:"1,2,3">, projectName, areaPath, iterationPath}` | 推送结果 |
**推送所需的两个路径都能自动取到**(这点对稳定性很关键):
```
GetTeamProjects() -> Tools_Dev 的 ProjectId
GetTeamByProject("Tools_Dev", pid) -> teams[];team.AreaPath[0] = "Tools_Dev\DataTechnologies"
team.Iterations[].Path 形如 "Tools_Dev\2026 Sprint\2026 Sprint 10"
```
按 Sprint 名称(`2026 Sprint 10`)在 `Iterations` 里精确匹配即可得到 `iterationPath`,`areaPath` 取所选团队的 `AreaPath[0]`——**无需在配置里手工维护迭代路径**。前端 `GetIterationPaths` 在当前部署上返回 404(服务端未暴露该端点),但走 `GetTeamByProject` 已完全覆盖其用途。
### 2.5 工单数据模型(实测)
```json
{
"WorkSheetId": 225072,
"WorkSheetCode": "信托 - 上海信托_20260830_225072",
"ProjectId": "0f52024c-...",
"ProjectName": "信托 - 上海信托",
"SystemName": "2026 Sprint 8",
"Evaluator": "陈首林",
"TechnicalLeader": "陈首林",
"CustomerLeader": "陈首林",
"StartTime": "/Date(1785081600000)/",
"EndTime": "/Date(1788019200000)/",
"Status": "01",
"ServiceType": "DevelopService",
"ImplementContent": "需求开发测试",
"CurrentVersionId": 260113,
"VersionCode": "技术责任人确认版",
"BelongTeam": "资管投资人事业部",
"Finished": "00",
"DeleteFlag": "0"
}
```
要点:
- 日期是 **.NET `/Date(ms)/`** 格式,需转换;
- 工单标识 = `WorkSheetCode`(`项目名_日期_WorkSheetId`),业务唯一键实际是 **(ProjectId, SystemName)**;
- `SystemName` 就是 Sprint 名称,是幂等判定的关键字段。
### 2.6 工单状态与版本机
前端 `EffortListVersionInfo` 是硬编码常量,即审批链:
| 版本 | 版本名(VersionCode) | 确认后流转到 | NextOperator | NNextOperator | Percent |
| --- | --- | --- | --- | --- | --- |
| 01 | 技术责任人确认版 | 客户责任人确认版 | TechnicalLeader | CustomerLeader | 20 |
| 02 | 客户责任人确认版 | 甲方确认版 | CustomerLeader | CustomerLeader | — |
| 03 | 甲方确认版 | 流程结束 | CustomerLeader | — | — |
状态码:
| Status | 含义 |
| --- | --- |
| 00 | 等待技术责任人确认 |
| 01 | 等待客户责任人确认 |
| 02 | 等待甲方确认 |
| 03 / 04 | 甲方已确认 |
**「确认工单」= 推进版本**:调用 `usp_VerifyWorkSheetById` 时必须传当前版本的 `NNextOperator` 作为 `NextOperator`、`NextVersionName` 作为 `NextVersionCode`;技术责任人确认版这一步 `Percent=20`。这些值不要写死,应从 `usp_GetWorkSheetById` 返回的 `VersionCode` 映射得到(本表即映射依据)。
### 2.7 Excel 模板
上传的 xlsx 是「工作量评估表」模板(`Sheet1`),由**服务器端** `usp_BatchImportFeatureFromExcel` 按固定单元格位置解析——基本信息区(项目名称/起止时间/系统名称/三个责任人/实施内容)+ 明细表(服务模块代码、服务模块、服务内容、详细说明、需求/开发/测试/实施分项、小计)。
因此:
- **导入阶段必须沿用现有模板**,不能改结构;`D:\Work\QuickTeam\*` 下的文件已是该模板;
- 客户端**不需要**解析 Excel(这是原方案的一个隐含优点,本项目保留);
- 解析有副作用(直接写库),所以每次导入都必须是幂等的(见 §4.5)。
---
## 3. 可行性结论
### 3.1 技术可行(已验证部分)
| 验证项 | 状态 |
| --- | --- |
| 登录 / 取用户身份 | ✅ 实测通过 |
| 团队、项目、人员、工单查询 | ✅ 实测通过(215 项目 / 单项目 3~4 工单) |
| 文件上传通道 | ✅ 实测通过,返回服务器绝对路径 |
| 读取链路端到端 | ✅ `tools/api_probe.py` 已跑通一个 Sprint 的现状扫描 |
| 写链路(建单/导入/确认/推送) | ⚠️ 契约已完整还原,**尚未实写**(避免污染生产数据),需在测试项目上做一次小批量联调 |
### 3.2 收益
| 指标 | 现状(Selenium) | 接口方案(估算) | 依据 |
| --- | --- | --- | --- |
| 单项目耗时 | 2~3 min | 10~25 s | 实测读 1.05 s/项目;写操作 4~5 次 + 上传 0.19 s + TFS 推送(未知,按 5~15 s 估) |
| 一个 Sprint(12 项目) | 25~35 min | 3~6 min | 线性外推 |
| 人工介入 | 必须人在前台看着 | 可无人值守 + 微信/邮件告警 | 无浏览器 |
| 失败恢复 | 整批中断、可能重复建单 | 台账驱动,按阶段续跑 | §4.5 |
| 环境依赖 | Chrome + Driver + 分辨率 + DOM 版本 | 只要能访问 HTTP 服务 | — |
| 前端改版影响 | 直接失效 | 无影响(契约是存储过程) | — |
### 3.3 风险可控但需正视
接口方案把「UI 不确定性」换成了「服务端契约不确定性」:不动的是存储过程名与参数,动的是**我们无法从代码里看到的分支逻辑**(例如 `usp_InsertWorkSheet` 无参却要返回合法 WorkSheetId、Excel 解析的隐含校验、TFS 重复推送的行为)。因此设计上必须把「可重入 + 可回滚 + 可审计」做成第一等公民,而不是事后补。
### 3.4 被否决的方案
| 方案 | 否决原因 |
| --- | --- |
| 继续优化 Selenium(显式等待、并发多实例) | 只是把不稳定的边界往后推,前端一改版仍然全废;并发浏览器成本高 |
| 直连数据库改表 | 绕过业务逻辑与审批链,无审计,风险远高于收益 |
| 抓包录制重放(HAR → 固定请求) | 与直接调接口等价,但可维护性更差 |
---
## 4. 系统设计
### 4.1 目标与非目标
**目标**
- 用接口完成「建工单 → 导入 Excel 明细 → 确认工单 → 推送 TFS」全流程;
- 一个 Sprint 批量执行,支持 `plan`(只读预演)/ `apply`(执行)/ `resume`(续跑)/ `report`;
- 幂等:重复执行不产生重复工单、不重复推送;
- 可观测:每一步有结构化日志与报告,失败可定位到具体阶段与接口。
**非目标**
- 不替代 QuickTeam 的审批流程(只做「技术责任人」这一跳);
- 不改服务器端、不改 Excel 模板;
- 不做 Web 界面(CLI 优先,稳定后再考虑)。
### 4.2 技术选型
| 项 | 选择 | 理由 |
| --- | --- | --- |
| 语言 | Python 3.12 | 与现有脚本同栈,团队可直接接手;生产环境已有 3.12 |
| HTTP | 标准库 `urllib`(可选 `requests`) | 零依赖,便于离线部署;只有 3 个服务、8 个操作,不需要框架 |
| 状态存储 | SQLite(单文件) | 无需服务,天然支持事务与断点;可直接用 DB 工具排查 |
| 日志 | JSONL + 控制台摘要 | 机器可读,便于后续接告警 |
| 测试 | pytest + 录制的响应 fixtures | 契约回放可在无网环境跑 |
| 打包 | 单目录 + `python -m qtimport` | 内网机器直接拷贝运行 |
### 4.3 分层架构
```
┌──────────────────────────────────────────────────────┐
│ cli plan / apply / resume / report │
├──────────────────────────────────────────────────────┤
│ workflow 单项目编排、阶段推进、补偿、并发调度 │
├──────────────────────────────────────────────────────┤
│ domain 工单聚合:查重、建单、填写、确认、推送 │
├──────────────────────────────────────────────────────┤
│ api SP 语义封装(login/projects/worksheets…) │
├──────────────────────────────────────────────────────┤
│ transport WCF 网关:ExecTable/ExecResult/Upload │
│ 双层 JSON 解码、错误解析、重试、耗时埋点 │
├──────────────────────────────────────────────────────┤
│ store SQLite 台账 + 运行报告 │
└──────────────────────────────────────────────────────┘
```
分层原则:**transport 不含业务,domain 不含 HTTP**。这样存储过程改名、端点迁移只影响一层;而幂等判定全部集中在 workflow/domain,不散落在 HTTP 调用里。
### 4.4 单项目时序
```
plan ─┬─ 登录 → 取 TeamId → 取项目列表(1 次,缓存)
├─ 读 xlsx 目录 → 项目名规范化匹配(精确优先,模糊仅提示)
└─ 查该项目历史工单 → 比对 SystemName → 输出计划(create / skip / resume)
apply ─┬─ [create] InsertWorkSheet() → UpdateWorkSheetById(Operation="new")
├─ [detail] GetWorkSheetById → CurrentVersionId / VersionCode
├─ [import] FileUpload(xlsx) → BatchImportFeature(WorkSheetId, VersionId)
├─ [verify] VerifyWorkSheetById(NextOperator, NextVersionCode, Percent)
└─ [push] GetTeamProjects → GetTeamByProject(解析 area/iteration)
GetWorkFeaturesByWorkSheetIds → BatchImportWorkItemsToTfs
```
每一步落台账后才进入下一步(write-ahead),因此任何时刻中断都能从记录点续跑。
### 4.5 幂等与断点(核心设计)
**业务唯一键**:`(ProjectId, SystemName)` —— 同一项目同一 Sprint 只应有一张工单。
**台账表**:
```sql
CREATE TABLE import_job (
run_id TEXT NOT NULL,
project_id TEXT NOT NULL,
project_name TEXT NOT NULL,
system_name TEXT NOT NULL, -- Sprint
source_file TEXT NOT NULL,
worksheet_id INTEGER, -- 建单后回填
version_id INTEGER,
stage TEXT NOT NULL, -- 见下
tfs_project TEXT,
iteration TEXT,
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (run_id, project_id)
);
CREATE TABLE import_event ( -- 追加型审计流水
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
project_id TEXT,
stage TEXT NOT NULL,
action TEXT NOT NULL, -- call/ok/fail/skip
detail TEXT,
elapsed_ms INTEGER,
created_at TEXT NOT NULL
);
```
**阶段机**:
```
PLANNED → WS_CREATED → WS_FILLED → EXCEL_IMPORTED → WS_VERIFIED → TFS_PUSHED
└──────────┴────────────┴──────────────┴──→ FAILED(stage, error)
```
**重入规则**(`apply` 前先跑一次判定,而不是无脑建单):
| 现场状态 | 动作 |
| --- | --- |
| 无该 Sprint 的工单 | 新建(create) |
| 有工单,`Status` ∈ {00,01},且台账显示是本工具创建 | **resume**:从台账记录的下一阶段继续,不重复建单 |
| 有工单,`Status` ∈ {00,01},但台账无记录(人工建的/上次工具崩了) | 默认 **skip 并告警**;加 `--adopt` 才接管续跑(保守优先,避免误改别人的单) |
| 有工单,`Status` ∈ {03,04} | **skip**(已闭环) |
| 有工单且已推送 TFS | **skip**(推送不可逆) |
**补偿(回滚)**:仅当「本次创建 + 尚未推送 TFS + 在导入/确认阶段失败」时,`--rollback-on-fail` 会调 `usp_DeleteWorkSheetAllById` 清掉半成品。已推送的一律不删——TFS 侧工作项已产生,必须人工处理,这是硬边界。
**并发**:`--workers 2` 默认(写路径串行度受服务端 SP 与 TFS 限流约束),读路径可 4。并发下每个项目独立事务,互不影响;工单编号由服务端生成,不存在本地竞争。
### 4.6 配置与凭据
```json
{
"base_url": "http://192.168.1.213",
"username": "chenshoulin@goldenstand.cn",
"password": "(不入库,见下)",
"team_name": "当前项目",
"work_dir": "D:/Work/QuickTeam/2026 Sprint 10",
"sprint": "2026 Sprint 10",
"start_time": "2026-10-01",
"end_time": "2026-10-31",
"content_desc": "需求开发测试",
"tfs_project": "Tools_Dev",
"tfs_team": "DataTechnologies",
"area_path": "",
"iteration_path": ""
}
```
`area_path` / `iteration_path` 留空即自动解析(`GetTeamByProject` → 按 Sprint 名匹配),填了则作为覆盖值使用。`tfs_team` 决定 `AreaPath`,缺省取该 TFS 项目的第一个团队。
凭据优先级:`QT_PASSWORD` 环境变量 → Windows DPAPI 加密文件 → 交互式输入。**明文密码不进仓库、不进日志**(`config.json` 已在 `.gitignore`)。
### 4.7 CLI
```bash
# 只读预演:打印将要做什么,不写任何数据
qtimport plan --config config/config.json
# 执行(默认幂等,已完成的跳过)
qtimport apply --config config/config.json --workers 2
# 指定项目重跑(失败重试的常用姿势)
qtimport apply --config config/config.json --project "信托 - 上海信托" --from-stage EXCEL_IMPORTED
# 出报告
qtimport report --run-id 20261105-1030
```
### 4.8 错误处理
WCF 的错误是 HTML 页面,真正的信息在 `The exception message is '...'` 里,必须解析出来再分类,否则只会看到 `HTTP 400`:
| 分类 | 判定特征 | 策略 |
| --- | --- | --- |
| `NET` | `URLError` / timeout | 重试 2 次(指数退避),仍失败则标记该阶段 |
| `SP_MISSING` | `Could not find stored procedure` | 不重试(配置/版本问题),整批中止并告警 |
| `PARAM` | `missing parameter` / `conversion` | 不重试,记到项目级错误,继续下一个项目 |
| `EXCEL` | `BatchImportFeature` 返回 `Column1 != 0` | 不重试,标记该文件模板不符,继续 |
| `TFS` | 推送异常 | 不重试,标记并保留工单(可人工补推) |
| `AUTH` | 登录返回空 | 立即中止(避免锁号) |
另有两个必须处理的协议细节:
1. **双层 JSON**:返回值常是「JSON 字符串里再套一层 JSON」,需循环解码 ≤3 次;
2. **BOM**:错误页带 UTF-8 BOM,需 `utf-8-sig` 解码。
### 4.9 报告
每次 run 产出:
- `reports/<run_id>/summary.md`:项目 / 文件 / 工单号 / 阶段 / 耗时 / 结论(create|skip|resume|fail);
- `reports/<run_id>/detail.csv`:逐阶段明细,便于 Excel 复核;
- `logs/<run_id>.jsonl`:结构化调用日志(含每次 HTTP 的 `sp`、`elapsed_ms`、`result`)。
### 4.10 目录结构(拟)
```
QTImport/
├── README.md
├── config/
│ ├── config.example.json
│ └── config.json # gitignore
├── docs/
│ └── QuickTeam-API导入-调研与设计.md
├── tools/
│ └── api_probe.py # 只读探针(已完成,可独立交付)
├── qtimport/
│ ├── __main__.py # CLI 入口
│ ├── transport.py # WCF 网关
│ ├── api.py # SP 语义封装
│ ├── domain.py # 工单聚合与状态机
│ ├── workflow.py # 编排 / 并发 / 补偿
│ ├── store.py # SQLite 台账
│ ├── report.py # 报告
│ └── config.py
├── tests/
│ ├── fixtures/ # 录制的真实响应
│ └── test_*.py
└── pyproject.toml
```
### 4.11 测试策略
| 层级 | 做法 |
| --- | --- |
| 单元 | payload 构造(`encodeURIComponent`+双层 JSON)、`/Date(ms)/` 转换、错误页解析、项目名规范化、状态机流转 |
| 契约回放 | 把本次实测的真实响应存 `tests/fixtures/`,离线断言字段与解码层数(防止服务端升级后静默失败) |
| 集成 | 在一个**专用测试项目**上跑 1 张工单:建 → 导 → 确认,人工核对 UI 与 DB,再删单 |
| 灰度 | 一个真实 Sprint 中挑 2 个项目跑接口,其余照旧;逐字段 diff(明细行数、小计、三个责任人、TFS 工作项数) |
| 回归 | 全量切换后,用 `plan` 的只读结果与 UI 页面勾兑一次 |
### 4.12 实施计划
| 里程碑 | 内容 | 交付 | 预估 |
| --- | --- | --- | --- |
| M0 | 调研 + 只读探针(**已完成**) | 本文档 + `tools/api_probe.py` | — |
| M1 | 写链路代码:transport/api/domain + `plan`/`apply`(**已实现**,未真实写入) | `qtimport/` 包 | — |
| M2 | 幂等/断点/补偿/并发/报告(**已完成**) | SQLite 台账 + 报告 | — |
| M3 | 真实联调:一个测试项目跑通建单→导入→确认(**待执行**) | 联调记录 | 0.5 d |
| M4 | 灰度一个 Sprint(2 个项目)+ 全量切换 | 切换说明 | 0.5 d |
| M5 | 退役 Selenium 脚本,保留为应急兜底 | — | — |
M3 是唯一剩下的卡点:**写链路一旦真跑通,后面都是工程量**。
---
## 5. 与现有脚本的对应关系
| 现有能力 | 新实现 | 备注 |
| --- | --- | --- |
| `get_project_name_list` + `done.txt` | 读目录 + SQLite 台账 | 台账可查、可重入,替代文本文件 |
| `login` | `usp_Authentication` | 无浏览器 |
| `open_team` / `open_project` | `usp_GetTeamsByUserId` / `usp_GetProjectsByTeamId` | 项目名做规范化精确匹配,避免 `contains` 误命中 |
| `create_work_order` | `InsertWorkSheet` + `UpdateWorkSheetById` | 字段一一对应 |
| `entry_work_order_info`(上传 xlsx) | `FileUpload` + `BatchImportFeature` | 服务器解析逻辑不变 |
| 「确认工单」点击 | `VerifyWorkSheetById` | 需按版本表推导 NextOperator/Percent |
| `push_work_order_to_tfs` | `GetWorkFeaturesByWorkSheetIds` + `BatchImportWorkItemsToTfs` | 全量勾选 = 拼全部 FeatureId |
| 错误页 dump HTML | 解析 `exception message` + 结构化日志 | 从「事后看截图」变「当场有原因」 |
---
## 6. 风险与开放问题
| 编号 | 风险/问题 | 影响 | 对策 | 优先级 |
| --- | --- | --- | --- | --- |
| R1 | 写链路未实测(`InsertWorkSheet` 无参、`UpdateWorkSheetById` 的 `Result` 语义、Excel 解析副作用) | 阻塞实施 | M1 在测试项目上小批量验证;先建后删,验证补偿可用 | **P0** |
| R2 | `GetIterationPaths` 404(服务端未暴露) | 原本以为迭代路径不可取 | 已解决:改走 `GetTeamByProject`,返回团队 `AreaPath` 与 `Iterations[].Path`,按 Sprint 名精确匹配 | — |
| R3 | 迭代/区域路径匹配失败(Sprint 名与 TFS 迭代命名不一致,如 `2026 Sprint 06` 补零) | 推送会失败 | 匹配时做归一化(去空格、去前导零、忽略大小写);匹配不到则**拒绝推送并报错**,不猜 | P1 |
| R4 | TFS 重复推送可能产生重复工作项(服务端无幂等标记) | 数据脏 | 台账记录 `TFS_PUSHED`,推过即不再推;报告里显式列出 | P1 |
| R5 | 服务器端 Excel 解析耗时未知(明细多时可能数十秒) | 影响单项目耗时估算与超时设置 | 首次联调即埋点计时;超时按实测值 × 2 设定 | P2 |
| R6 | `ExecTable` 无鉴权(实测无 Cookie 可读写任意 SP);密码 MD5 明文传输 | 安全 | 仅在内网机器执行、不使用管理员账号;不新增对外暴露;建议后续接入 SSO / 网络层限制 | P2 |
| R7 | 服务器 `WorkSheetImport` 目录只增不删(`FileBaseFoloder`) | 磁盘堆积 | 上传文件名带 run_id 前缀;提供清理脚本;联系运维确认保留策略 | P3 |
| R8 | 存储过程改名/加参(前端同步改) | 契约失效 | 契约回放测试会在字段缺失时立刻报错;版本化 `api.py` 的 SP 映射表 | P3 |
| R9 | 凭据落盘 | 泄露 | `config.json` 已 gitignore;支持环境变量/DPAPI;日志脱敏 | P3 |
---
## 7. 附录
### A. 实测请求样例(脱敏)
```http
POST /Services/CommonService.svc/ExecTable HTTP/1.1
Content-Type: application/json
{"connectionName":"QuickTeam","param":"%7B%22SPName%22%3A%22QuickTeam.usp_GetProjectsByTeamId%22%2C%22SQLParams%22%3A%5B%7B%22Name%22%3A%22TeamId%22%2C%22Value%22%3A%22090F8E57-...%22%2C%22DBType%22%3A%22string%22%7D%2C%7B%22Name%22%3A%22IsTemplate%22%2C%22Value%22%3A%221%22%2C%22DBType%22%3A%22string%22%7D%5D%7D"}
```
响应(注意外层是字符串,需二次解码):
```
"[{\"ProjectId\":\"0f52024c-...\",\"ProjectName\":\"信托 - 上海信托\",...}, ...]"
```
### B. 前端产物中的存储过程清单
共 210 个(`QuickTeam.*` + `UserCenter.*`),本项目涉及 13 个存储过程与 6 个服务操作。完整清单可通过以下命令复现:
```bash
# 抓取并统计(在任一可访问 QuickTeam 的机器上)
curl -s http://192.168.1.213/QuickTeam/ -o index.html
grep -oE '/QuickTeam/js/[A-Za-z0-9._-]+\.js' index.html | sort -u | \
while read p; do curl -s "http://192.168.1.213$p"; done | \
grep -oh '[A-Za-z][A-Za-z0-9_]*\.usp_[A-Za-z0-9_]*' | sort -u
```
### C. Selenium 耗时拆解(按代码 sleep 累加)
| 函数 | 固定 sleep | 主要来源 |
| --- | --- | --- |
| `open_project` | 17.5 s | 搜索 0.5+1+1+2、点击 3、页签 10 |
| `create_work_order` | 41 s | 新增 10、保存 10、三个责任人各 3、其余 5 |
| `entry_work_order_info` | 14 s | 三次点击各 3、返回 5 |
| `push_work_order_to_tfs` | 56 s | 开头 20、选项目后 20、其余 16 |
| **合计** | **≈128 s** | 不含元素等待/渲染/网络 |
### D. 探针工具
```bash
# 现状扫描(只读)
python tools/api_probe.py --config config/config.json
# 额外验证上传通道(会在服务器留一个临时文件,不解析、不写库)
python tools/api_probe.py --config config/config.json --upload-test
# 输出 JSON 供后续程序消费
python tools/api_probe.py --config config/config.json --json-out reports/probe.json
```
实测输出(2026 Sprint 10,12 个文件):
```
[登录] 陈首林 <chenshoulin@goldenstand.cn> UserId=86334126-... 0.50s
[团队] 当前项目 (090F8E57-...) 0.46s
[项目] 共 215 个 1.73s
信托 - 上海信托 4 否 待创建
...
合计 12 个文件:待创建 12,已存在 0,异常 0
[耗时] 扫描 12.63s 平均 1.05s/项目
```
---
## 8. 实现说明(2026-11-05)
代码已落地,与设计的对应关系:
| 设计章节 | 实现 |
| --- | --- |
| §4.3 分层 | `qtimport/transport.py`(HTTP+协议)、`api.py`(SP 语义)、`domain.py`(纯规则)、`workflow.py`(编排)、`store.py`(台账)、`report.py`(报告)、`__main__.py`(CLI) |
| §4.4 时序 | `workflow.Importer._run_project`,阶段常量 `STAGES` |
| §4.5 幂等 | `store.latest_job` 跨 run 取最近阶段;dry-run 的 run 不参与判定(避免演练被当成已完成) |
| §4.5 补偿 | `--rollback-on-fail`,仅在「本次创建 + 未到确认阶段」时调 `usp_DeleteWorkSheetAllById` |
| §4.7 CLI | `plan` / `apply` / `report` / `status`,真实写入需 `--yes` |
| §4.8 错误 | `errors.py` 把 WCF 错误页分类为 NET/SP_MISSING/PARAM/EXCEL/TFS/AUTH,决定重试或中止 |
| §4.11 测试 | `tests/` 69 个用例(unittest,无需网络),覆盖解码、错误分类、项目匹配、动作判定、版本推进、参数构造、回滚、续跑、工单列表去重、待确认版本选择、TFS 状态码 |
实现期新增的两个设计约束(原方案没写细):
1. **查询窗口与业务窗口分离**:`StartTime/EndTime` 是写进工单的业务数据,必须精确;查「是否已有工单」时用前后各放宽 30 天的窗口,避免边界漏查。
2. **Excel 上传前预检**:读 `xl/sharedStrings.xml` 确认含「工作量评估表」「服务模块」,避免把一个错文件送到服务器解析(`Column1 != 0` 的失败代价更高)。
实测(`--dry-run`,12 个项目):19.2 s,0 失败,TFS 上下文自动解析为
`area=Tools_Dev\DataTechnologies`、`iteration=Tools_Dev\2026 Sprint\2026 Sprint 10`。
### 8.1 真实联调(2026-11-05,Sprint 2026 Sprint 10)
单项目「资管 - 华泰保险资管」跑通全链路:
| 阶段 | 结果 |
| --- | --- |
| 建单 + 填单 | `usp_InsertWorkSheet` → 225134,`usp_UpdateWorkSheetById` 后 `Status=00`、`WorkSheetCode=资管 - 华泰保险资管_20261031_225134` |
| 导入明细 | `FileUpload` → `usp_BatchImportFeatureFromExcel` → `Column1=0`,2 个功能模块(测试工时 12 / 10.5,与 Excel 一致) |
| 确认 | `usp_VerifyWorkSheetById` → `Status=01`、`CurrentVersionId=260220`、`VersionCode=技术责任人确认版` |
| 推送 TFS | `BatchImportWorkItemsToTfs` 返回创建数;`area=Tools_Dev\DataTechnologies`、`iteration=Tools_Dev\2026 Sprint\2026 Sprint 10` |
耗时 3.5 s(建单→确认),推送 1.5 s;含登录与规划整轮 20 s,Selenium 同流程按代码 sleep 累加约 15 分钟。
### 8.2 联调暴露的五个真问题(都已修)
**① `FileUpload` 必须发裸字节,不能套 multipart。**
前端是 `data: e`(原生 `File` 对象)→ XHR 直接发送文件字节,WCF 按 `Stream` 收下后原样落盘。
发 multipart 时服务器把整段包体写进 `.xlsx`,文件损坏 → 服务端解析出空 `DataTable` →
`SqlBulkCopyToDB` 抛 `Object reference not set to an instance of an object`。
这个错误极有误导性:它同时对「不存在的路径」「非 xlsx 文件」也报同样的错,看上去像参数问题。
**② 导入要用「待确认版本」,不能用 `CurrentVersionId`。**
`usp_UpdateWorkSheetById` 只在 `WorkSheet.Status` 长度为 0(第一次保存)时插入版本行
(技术责任人确认版,`VersionStatus='N'`),但**不回填** `CurrentVersionId` —— 那要等
`usp_VerifyWorkSheetById` 才写。所以首次导入时 `CurrentVersionId` 是 `NULL`,拿它当
`VersionId` 会插到不存在的版本上,报 `FK_WorkSheetTask_WorkSheetVersion` 冲突。
版本号要从 `usp_GetWorkSheetsById`(列表页那个 SP,一行一个版本)里取 `VersionStatus='N'` 的那一行。
**③ `usp_GetWeeklyWorkSheetsByProjectId` 名为按周,实际按季度过滤。**
SP 里 `@Sunday` 完全没用到,只用 `@Monday` 推出所在季度的起止:
`(StartTime between 季初 and 季末) or (EndTime between 季初 and 季末)`。
业务窗口 10-08~10-31 前后各放宽 30 天后起点落在 9 月(Q3),于是 Q4 的工单被整段漏掉 ——
`plan` 会误判「无工单」并重复建单(这条差点在批量执行时踩到)。
最终不再用它:改用 `usp_GetWorkSheetsById` 一次取回项目全部工单(含 SystemName / Status /
CurrentVersionId),既不漏也不受季度影响,还顺带消掉了「每张工单再查一次详情」的 N+1
(plan 从 17 s 降到 10.6 s)。
**④ `BatchImportWorkItemsToTfs` 返回的是状态码,不是数量。**
实测:正常推送返回 `0`,把 `projectName` 换成不存在的值返回 `-1`。最初把 `0` 误判成
「一件都没建」而报失败,结果 11 个项目的推送被判错(实际都已建出工作项),只能改台账而不是重跑 ——
因为该 SP 只是 `select` 出待建清单,本身不记录「已推送」状态,**重复推送会产生重复工作项**,
`TFS_PUSHED` 阶段完全靠台账防重。
**⑤ 跳过行不能覆盖台账阶段。**
`apply` 对不执行的行也写了一条台账(`stage='SKIPPED'`)用于报告,但下一轮 `latest_job`
取到它之后,`decide` 会把「已推送 TFS」当成「台账停在 SKIPPED」判成 `resume` —— 接着就是重复推送。
修法:`latest_job` 排除 `SKIPPED`,且已有真实记录时不再写跳过行(只记事件)。
### 8.3 新增工具
| 工具 | 用途 |
| --- | --- |
| `tools/api_probe.py` | 零依赖只读探针,扫描一个 Sprint 的工单现状 |
| `tools/sp_source.py` | 只读导出存储过程源码。网关 `ExecTable` 能直接执行 `sys.sp_helptext`,不需要数据库账号 —— 排查参数语义比猜快得多(`docs/sp/` 下已存 6 个关键 SP) |
| `tools/diag_import.py` | 建草稿→填单→上传→试各种 `VersionId`→删单,用于定位导入阶段问题 |
> 顺带发现:网关允许把任意 SP 名塞进 `ExecTable`,包括 `sys.sp_helptext`、`sys.sp_columns`、
> `sys.sp_executesql`。也就是说**无凭证即可执行任意只读甚至写 SQL**,这是服务端的安全问题,
> 不在本项目范围内,但值得单独提给 QuickTeam 维护方。
### 8.4 全量执行结果(Sprint 2026 Sprint 10,2026-11-05)
12 个项目全部执行完成(`apply --yes --rollback-on-fail`,并发 2,整轮 175 s):
| 项目 | 工单 | 功能模块 |
| --- | --- | --- |
| 资管 - 华泰保险资管 | 225134 | 2 |
| 内部研发 - QuickDealService | 225135 | 1 |
| 信托 - 上海信托 | 225136 | 9 |
| 汽金 - 长安汽车 | 225137 | 1 |
| 汽金 - 吉致汽车 | 225138 | 1 |
| 消金 - 平安消金 | 225139 | 1 |
| 消金 - 滴滴金融 | 225140 | 2 |
| 消金 - 美团金融 | 225141 | 1 |
| 清算 - 海南清算所 | 225142 | 4 |
| 资管 - 华泰资管 | 225143 | 7 |
| 资管 - 招商信诺 | 225144 | 1 |
| 资管 - 粤海资本 | 225145 | 5 |
核对口径:每张工单 `SystemName=2026 Sprint 10`、`Status=01`(等待客户责任人确认)、
功能模块数与对应 xlsx 明细行数逐文件相等(12 张工单合计 35 个,其中本轮新建 11 张 33 个)。复跑 `plan` 12 个项目全为 `skip-pushed`。
旧方案(Selenium)同流程约 25~35 分钟,且失败重跑可能重复建单。
@@ -0,0 +1,11 @@
CREATE PROCEDURE [QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds]␍
@FeatureIds nvarchar(max)␍
AS␍
BEGIN␍
select FeatureCode AS N'模块代码',FeatureName as N'服务模块' , FeatureContent as N'服务内容',FeatureCode+'('+FeatureName+':'+FeatureContent+')' as BackLogItemCode FROM QuickTeam.Feature WHERE FeatureId in (select Data from [QuickTeam].[StringSplit]␍
(@FeatureIds,',')␍
) and FeatureCode!=''␍
END␍
␍
--test␍
--exec [QuickTeam].[usp_BatchGetWorkFeaturesByWorkSheetIds] '1,5'␍
@@ -0,0 +1,38 @@
-- =============================================␍
-- Author: <Author,,Name>␍
-- Create date: <Create Date,,>␍
-- Description: <Description,,>␍
-- =============================================␍
CREATE PROCEDURE [QuickTeam].[usp_GetWeeklyWorkSheetsByProjectId] ␍
@ProjectId nvarchar(50),␍
@Monday nvarchar(50),␍
@Sunday nvarchar(50)␍
AS␍
BEGIN␍
␍
DECLARE @dt datetime,@qstartday datetime,@qendday datetime␍
SET @dt=convert(datetime,@Monday)␍
--2.指定日期所在季度的第一天或最后一天␍
SELECT @qstartday=DATEADD(qq, DATEDIFF(qq, 0, @dt), 0) ,␍
@qendday=DATEADD(Day,-1,␍
CONVERT(char(8),␍
DATEADD(Month,␍
1+DATEPART(Quarter,@dt)*3-Month(@dt),␍
@dt),␍
120)+'1')␍
␍
select ␍
ws.[WorkSheetId]␍
,ws.WorkSheetCode␍
,ws.[ProjectId]␍
,ws.[ProjectName]␍
,ws.[StartTime]␍
,ws.[EndTime]␍
,ws.Finished␍
,ws.[Status]␍
from [QuickTeam].[QuickTeam].[WorkSheet] ws ␍
where projectid=@ProjectId AND DeleteFlag<>1 and isReclaimed=0␍
and ((StartTime between @qstartday and @qendday) or (EndTime between @qstartday and @qendday))␍
order by Finished ,WorkSheetId␍
␍
END␍
+12
View File
@@ -0,0 +1,12 @@
CREATE PROCEDURE [QuickTeam].[usp_GetWorkSheetById] ␍
@WorkSheetId bigint␍
AS␍
BEGIN␍
SET NOCOUNT ON;␍
--select * from [QuickTeam].[WorkSheet] where WorkSheetId=@WorkSheetId␍
select ws.*,wv.VersionCode from [QuickTeam].[WorkSheet] as ws␍
left join [QuickTeam].[WorkSheetVersion] wv on ws.CurrentVersionId=wv.VersionId␍
where ws.WorkSheetId=@WorkSheetId␍
END␍
␍
␍
+77
View File
@@ -0,0 +1,77 @@
-- =============================================␍
-- Author: <Author,,Name>␍
-- Create date: <Create Date,,>␍
-- Description: <Description,,>␍
-- =============================================␍
CREATE PROCEDURE [QuickTeam].[usp_GetWorkSheetsById] ␍
@ProjectId nvarchar(50),␍
@isReclaimed bit=0␍
AS␍
BEGIN␍
SELECT␍
WorksheetId,␍
convert(nvarchar,sum([PaymentPercent])) as PaymentPercent␍
into #receiv␍
FROM [QuickTeam].[QuickTeam].[WorksheetPayment] group by WorksheetId␍
␍
select t.*␍
,(case ␍
when t.Finished='02' or t.Finished='03' then N'无回款'␍
else ␍
case when rs.StatusAlia=N'回款已经确认' and PaymentPercent is null then N'开票已经确认'␍
else iif(rs.StatusAlia=N'回款已经确认',rs.StatusAlia+N'(累计回款:'+r.PaymentPercent+'%)',rs.StatusAlia) end␍
end␍
) as 'ReceiveStatusName'␍
--,rs.StatusAlia␍
--,iif(rs.StatusAlia=N'回款已经确认',rs.StatusAlia+N'(累计回款:'+r.PaymentPercent+'%)',rs.StatusAlia) as 'ReceiveStatusName'␍
,s.ServiceTypeAlias, r.PaymentPercent,e.Effort,f.Effort CurrentEffort from (␍
select ws.*,''as VersionId,'' as VersionCode,'' as VersionStatus,'' as NextOperator,'' as Operator from [QuickTeam].[QuickTeam].[WorkSheet] ws␍
union␍
select ␍
ws.[WorkSheetId]␍
,wv.VersionCode as WorkSheetCode␍
,ws.[ProjectId]␍
,ws.[ProjectName]␍
,ws.[SystemName]␍
,ws.[Evaluator]␍
,ws.[TechnicalLeader]␍
,ws.[CustomerLeader]␍
,ws.[StartTime]␍
,ws.[EndTime]␍
,wv.[UpdateTime]␍
,ws.[Status]␍
,ws.ReceiveStatus␍
,ws.Finished␍
,ws.Finishtime␍
,ws.[ServiceType]␍
,ws.[ImplementContent]␍
,ws.[OtherInstructions]␍
,ws.CurrentVersionId␍
,ws.isMerge␍
,ws.DeleteFlag␍
,ws.Operation ␍
,ws.OperateUserName␍
,ws.isReclaimed␍
,ws.BelongTeam␍
,wv.VersionId,wv.VersionCode,wv.VersionStatus,wv.NextOperator,wv.Operator from [QuickTeam].[QuickTeam].[WorkSheet] ws ␍
join [QuickTeam].[WorkSheetVersion] wv␍
on ws.WorkSheetId=wv.WorkSheetId ␍
␍
) as t␍
left join #receiv r on r.WorksheetId=t.WorkSheetId␍
left join (␍
select distinct ServiceType,ServiceTypeAlias from ␍
QuickTeam.ServiceTypeTaskName) s␍
on t.ServiceType=s.ServiceType␍
left join QuickTeam.WorkSheetReceiveStatus rs on t.receiveStatus=rs.statusid␍
left join (SELECT [VersionId]␍
,sum([Effort]) [Effort]␍
FROM [QuickTeam].[QuickTeam].[WorkSheetTask] group by VersionId) e on t.VersionId=e.VersionId␍
left join (SELECT [VersionId]␍
,sum([Effort]) [Effort]␍
FROM [QuickTeam].[QuickTeam].[WorkSheetTask] group by VersionId) f on t.CurrentVersionId=f.VersionId␍
where projectid=@ProjectId AND t.DeleteFlag<>1 and isReclaimed=@isReclaimed␍
order by Finished ,WorkSheetId,VersionId␍
␍
drop table #receiv␍
END␍
+55
View File
@@ -0,0 +1,55 @@
CREATE PROCEDURE [QuickTeam].[usp_InsertWorkSheet]␍
-- Add the parameters for the stored procedure here␍
AS␍
BEGIN␍
-- SET NOCOUNT ON added to prevent extra result sets from␍
-- interfering with SELECT statements.␍
SET NOCOUNT ON;␍
␍
-- Insert statements for procedure here␍
INSERT INTO [QuickTeam].[WorkSheet]␍
([WorkSheetCode]␍
,[ProjectId]␍
,[ProjectName]␍
,[SystemName]␍
,[Evaluator]␍
,[TechnicalLeader]␍
,[CustomerLeader]␍
,[StartTime]␍
,[EndTime]␍
,[UpdateTime]␍
,[Status]␍
,ReceiveStatus␍
,[Finished]␍
,[FinishTime]␍
,[ServiceType]␍
,[ImplementContent]␍
,[OtherInstructions]␍
,isMerge␍
,isReclaimed␍
,BelongTeam␍
)␍
VALUES␍
(''␍
,N''␍
,N''␍
,''␍
,''␍
,''␍
,''␍
,GETDATE()␍
,dateadd(m,1,getdate())␍
,GETDATE()␍
,''␍
,'00'␍
,'00'␍
,null␍
,''␍
,''␍
,''␍
,'0'␍
,0␍
,''␍
);␍
select scope_identity() as WorkSheetId␍
END␍
+68
View File
@@ -0,0 +1,68 @@
CREATE PROCEDURE [QuickTeam].[usp_UpdateWorkSheetById]␍
@WorkSheetId bigint,␍
@ProjectId nvarchar(50),␍
--@TeamId nvarchar(50),␍
@ProjectName nvarchar(50),␍
@SystemName nvarchar(50),␍
@Evaluator nvarchar(50),␍
@TechnicalLeader nvarchar(50),␍
@CustomerLeader nvarchar(50),␍
@StartTime datetime,␍
@EndTime datetime,␍
@Status nvarchar(5)='01' ,␍
@ServiceType nvarchar(20),␍
@ImplementContent nvarchar(500),␍
@OtherInstructions nvarchar(1000),␍
@Operator nvarchar(50),␍
@DeleteFlag NVARCHAR(2),␍
@Operation NVARCHAR(10),␍
@BelongTeam NVARCHAR(50)␍
␍
␍
␍
AS␍
BEGIN␍
declare @length int␍
select @length=len([Status]) from [QuickTeam].[WorkSheet] where WorkSheetId=@WorkSheetId␍
update QuickTeam.WorkSheet ␍
set [WorkSheetCode]=@ProjectName+'_'+CONVERT(varchar(100), @EndTime, 112)+'_'+CONVERT(nvarchar(10),@WorkSheetId)␍
,[ProjectId]=@ProjectId␍
,[ProjectName]=@ProjectName␍
,[SystemName]=@SystemName␍
,[Evaluator]=@Evaluator␍
,[TechnicalLeader]=@TechnicalLeader␍
,[CustomerLeader]=@CustomerLeader␍
,[StartTime]=@StartTime␍
,[EndTime]=@EndTime␍
,[UpdateTime]=getdate()␍
,[Status]=@Status␍
,[ServiceType]=@ServiceType␍
,[ImplementContent]=@ImplementContent␍
,[OtherInstructions]=@OtherInstructions␍
,Operation=@Operation␍
,DeleteFlag=@DeleteFlag␍
,OperateUserName=@Operator␍
,BelongTeam=@BelongTeam␍
where WorkSheetId=CONVERT(BIGINT,@WorkSheetId)␍
␍
if(@length=0)␍
begin␍
INSERT INTO [QuickTeam].[WorkSheetVersion]␍
([VersionCode]␍
,[VersionStatus]␍
,NextOperator␍
,[WorkSheetId]␍
,Operator␍
,UpdateTime␍
)␍
VALUES␍
(N'技术责任人确认版'␍
,'N'␍
,'TechnicalLeader'␍
,@WorkSheetId␍
,''␍
,getdate()␍
)␍
end␍
select @@ERROR as Result␍
END␍
+51
View File
@@ -0,0 +1,51 @@
CREATE PROCEDURE [QuickTeam].[usp_VerifyWorkSheetById]␍
@WorkSheetId bigint␍
,@VersionId int␍
,@NextVersionCode nvarchar(50)␍
,@NextOperator nvarchar(50)␍
,@Percent int ␍
,@Operator nvarchar(50)␍
AS␍
BEGIN␍
␍
␍
␍
declare @ParentEffortStatus nvarchar(10),@PreEffortStatus nvarchar(5)='Y',@NextVersionId int,@Count int,@CurrentVersionId int␍
select @ParentEffortStatus=replace(str(status+1,2,0),' ','0') from [QuickTeam].[WorkSheet] where WorkSheetId=@WorkSheetId␍
select @Count= count(*) from [QuickTeam].[WorkSheetVersion] where NextOperator='CustomerLeader' and WorkSheetId=@WorkSheetId␍
if(@Count=2)␍
begin␍
--update [QuickTeam].[WorkSheet] set Status=@ParentEffortStatus where WorkSheetId=@WorkSheetId␍
update [QuickTeam].[WorkSheetVersion] set VersionStatus=@PreEffortStatus,Operator=@Operator,UpdateTime=getdate() where WorkSheetId=@WorkSheetId and VersionId=@VersionId␍
select @CurrentVersionId=max(VersionId) from [QuickTeam].[WorkSheetVersion] where WorkSheetId=@WorkSheetId and VersionStatus='Y'␍
update [QuickTeam].[WorkSheet] set Status=@ParentEffortStatus,CurrentVersionId=@CurrentVersionId ,UpdateTime=GETDATE(),Operation=N'Verify',OperateUserName=@Operator where WorkSheetId=@WorkSheetId␍
end␍
else␍
begin␍
--插入下一版本␍
insert into [QuickTeam].[WorkSheetVersion]([VersionCode]␍
,[VersionStatus]␍
,[NextOperator]␍
,[WorkSheetId]␍
,Operator␍
,UpdateTime␍
) ␍
select @NextVersionCode,'N',@NextOperator,@WorkSheetId,'',GETDATE()␍
---更新当前版本␍
update [QuickTeam].[WorkSheetVersion] set VersionStatus=@PreEffortStatus ,Operator=@Operator,UpdateTime=getdate() where WorkSheetId=@WorkSheetId and VersionId=@VersionId␍
␍
set @NextVersionId=scope_identity()␍
insert into QuickTeam.WorkSheetTask([FeatureId]␍
,[VersionId]␍
,[TaskCode]␍
,[Effort])␍
select [FeatureId]␍
,@NextVersionId␍
,[TaskCode]␍
,Effort from QuickTeam.WorkSheetTask where VersionId=@VersionId␍
select @CurrentVersionId=max(VersionId) from [QuickTeam].[WorkSheetVersion] where WorkSheetId=@WorkSheetId and VersionStatus='Y'␍
update [QuickTeam].[WorkSheet] set Status=@ParentEffortStatus,CurrentVersionId=@CurrentVersionId ,UpdateTime=GETDATE() where WorkSheetId=@WorkSheetId␍
end ␍
␍
select @@ERROR As Result␍
END␍
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "qtimport"
version = "0.1.0"
description = "通过接口把工单数据导入 QuickTeam(替代 Selenium)"
requires-python = ">=3.12"
dependencies = []
[project.scripts]
qtimport = "qtimport.__main__:main"
[tool.setuptools]
packages = ["qtimport"]
+3
View File
@@ -0,0 +1,3 @@
"""QuickTeam 工单接口导入工具。"""
__version__ = "0.1.0"
+206
View File
@@ -0,0 +1,206 @@
"""CLI:plan / apply / report / status。"""
from __future__ import annotations
import argparse
import json
import os
import secrets
import sys
import time
from . import __version__
from .config import DEFAULT_CONFIG, Config
from .errors import QtError
from .report import RunLog, write_report
from .store import Store
from .workflow import Importer, RunOptions
def new_run_id() -> str:
return time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
def build_importer(args) -> tuple[Importer, RunLog, Store, str]:
cfg = Config.load(args.config)
overrides = {"sprint": args.sprint, "work_dir": args.folder, "team_name": args.team}
for key, value in overrides.items():
if value:
setattr(cfg, key, value)
if not cfg.sprint:
raise SystemExit("配置缺少 sprint(或传 --sprint)")
if not cfg.work_dir:
raise SystemExit("配置缺少 work_dir(或传 --dir)")
if getattr(args, "workers", None):
cfg.workers = args.workers
run_id = args.run_id or new_run_id()
store = Store(cfg.db_path)
log = RunLog(run_id, verbose=not args.quiet)
store.start_run(run_id, cfg.sprint, cfg.work_dir, dry_run=args.dry_run)
options = RunOptions(
adopt=args.adopt,
skip_tfs=args.skip_tfs,
rollback_on_fail=args.rollback_on_fail,
limit=args.limit,
projects=args.project or [],
force=args.force,
dry_run=args.dry_run,
)
importer = Importer(cfg, store, run_id, log, options)
return importer, log, store, run_id
def cmd_plan(args) -> int:
importer, log, store, run_id = build_importer(args)
try:
rows = importer.plan()
finally:
pass
width = max([len(row.project_name) for row in rows] + [8]) + 2
print(f"\nSprint:{importer.cfg.sprint} 目录:{importer.cfg.work_dir}")
print(f"{'项目'.ljust(width)}{'动作':<12}说明")
print("-" * (width + 60))
for row in rows:
print(f"{row.project_name.ljust(width)}{str(row.action):<12}{row.reason}")
print("-" * (width + 60))
counts: dict[str, int] = {}
for row in rows:
counts[str(row.action)] = counts.get(str(row.action), 0) + 1
print("合计 " + ",".join(f"{k} {v}" for k, v in sorted(counts.items())))
if args.json:
payload = [
{
"project": row.project_name,
"file": row.file_path,
"action": str(row.action),
"reason": row.reason,
"worksheet_id": row.worksheet.worksheet_id if row.worksheet else None,
}
for row in rows
]
path = args.json if isinstance(args.json, str) else f"reports/{run_id}/plan.json"
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
print(f"计划已写入 {path}")
log.close()
store.finish_run(run_id, "PLANNED")
store.close()
return 0
def cmd_apply(args) -> int:
if not args.dry_run and not args.yes:
print("这会真实写入 QuickTeam(建单/导入/确认/推送)。确认后加 --yes 重跑;"
"只想看会做什么请用 --dry-run。", file=sys.stderr)
return 3
importer, log, store, run_id = build_importer(args)
started = time.time()
status = "DONE"
try:
results = importer.apply()
failed = [r for r in results if r.stage.startswith("FAILED")]
if failed:
status = "PARTIAL"
print(f"\n执行完成:{len(results)} 个项目,失败 {len(failed)},"
f"耗时 {time.time() - started:.1f}s")
except QtError as error:
print(f"[中止] {error}", file=sys.stderr)
status = "ABORTED"
return 2
finally:
log.close()
store.finish_run(run_id, status)
md, csv = write_report(run_id, store)
print(f"报告:{md} / {csv}")
store.close()
return 0
def cmd_report(args) -> int:
cfg = Config.load(args.config, need_password=False)
store = Store(cfg.db_path)
run_id = args.run_id or store.last_run()
if not run_id:
print("没有可用的运行记录", file=sys.stderr)
return 1
md, csv = write_report(run_id, store)
print(f"已生成:{md}\n {csv}")
store.close()
return 0
def cmd_status(args) -> int:
cfg = Config.load(args.config, need_password=False)
store = Store(cfg.db_path)
run_id = args.run_id or store.last_run()
if not run_id:
print("没有可用的运行记录", file=sys.stderr)
return 1
jobs = store.jobs(run_id)
print(f"run={run_id} 项目 {len(jobs)} 个")
for job in jobs:
flag = "!" if job["error"] else " "
print(f"{flag} {job['project_name']:<28} {job['action']:<12} {job['stage']:<20} "
f"ws={job['worksheet_id'] or '-'}")
store.close()
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="qtimport", description="QuickTeam 工单接口导入")
parser.add_argument("--version", action="version", version=f"qtimport {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
def common(p: argparse.ArgumentParser) -> None:
p.add_argument("--config", default=DEFAULT_CONFIG, help="配置文件路径")
p.add_argument("--sprint", help="覆盖配置中的 Sprint 名称")
p.add_argument("--dir", dest="folder", help="覆盖配置中的 xlsx 目录")
p.add_argument("--team", help="覆盖配置中的团队名称")
p.add_argument("--run-id", help="复用已有 run_id(续跑)")
p.add_argument("--adopt", action="store_true", help="接管无台账记录的既有工单")
p.add_argument("--skip-tfs", action="store_true", help="不推送 TFS")
p.add_argument("--limit", type=int, help="最多执行几个项目")
p.add_argument("--project", action="append", help="只处理指定项目(可多次)")
p.add_argument("--force", action="store_true", help="跳过 Excel 模板预检")
p.add_argument("--dry-run", action="store_true", help="演练:不产生任何服务端写入")
p.add_argument("--quiet", action="store_true", help="只输出结果")
p_plan = sub.add_parser("plan", help="只读预演:列出每个项目会做什么")
common(p_plan)
p_plan.add_argument("--json", nargs="?", const=True, help="把计划写成 JSON")
p_plan.set_defaults(func=cmd_plan, rollback_on_fail=False)
p_apply = sub.add_parser("apply", help="执行导入")
common(p_apply)
p_apply.add_argument("--yes", action="store_true", help="确认真实写入")
p_apply.add_argument("--rollback-on-fail", action="store_true", help="失败时删除本次新建的半成品工单")
p_apply.add_argument("--workers", type=int, help="覆盖并发数(默认取配置)")
p_apply.set_defaults(func=cmd_apply, json=False)
p_report = sub.add_parser("report", help="生成报告")
p_report.add_argument("--config", default=DEFAULT_CONFIG)
p_report.add_argument("--run-id")
p_report.set_defaults(func=cmd_report, quiet=False)
p_status = sub.add_parser("status", help="查看最近一次运行")
p_status.add_argument("--config", default=DEFAULT_CONFIG)
p_status.add_argument("--run-id")
p_status.set_defaults(func=cmd_status, quiet=False)
args = parser.parse_args(argv)
try:
return args.func(args)
except QtError as error:
print(f"[ERROR] {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
+454
View File
@@ -0,0 +1,454 @@
"""api 层:把存储过程封装成业务语义。
调用序列来自 QuickTeam 前端构建产物(chunk-0cadb1de / 039a8b29 / 9b10aab2),
每个方法对应的存储过程与参数见 docs/QuickTeam-API导入-调研与设计.md §2.4。
dry_run=True 时写操作只记录意图并返回桩值,不产生任何服务端副作用。
"""
from __future__ import annotations
import hashlib
from typing import Any, Callable
from .errors import Category, QtError
from .models import (
Feature,
Project,
Team,
TfsTeam,
User,
WorkSheet,
WorkSheetVersion,
ms_to_date,
normalize_iteration,
)
from .transport import Transport
CONN_QUICKTEAM = "QuickTeam"
CONN_USERCENTER = "UserCenter"
SP_LOGIN = "dbo.usp_Authentication"
SP_TEAMS = "QuickTeam.usp_GetTeamsByUserId"
SP_PROJECTS = "QuickTeam.usp_GetProjectsByTeamId"
SP_WORKSHEET = "QuickTeam.usp_GetWorkSheetById"
SP_PROJECT_USERS = "QuickTeam.usp_GetProjectUsersByProjectId"
SP_INSERT_WORKSHEET = "QuickTeam.usp_InsertWorkSheet"
SP_UPDATE_WORKSHEET = "QuickTeam.usp_UpdateWorkSheetById"
SP_VERIFY_WORKSHEET = "QuickTeam.usp_VerifyWorkSheetById"
SP_FEATURES = "QuickTeam.usp_GetWorkFeaturesByWorkSheetIds"
SP_WORKSHEET_VERSIONS = "QuickTeam.usp_GetWorkSheetsById"
SP_IMPORT_EXCEL = "QuickTeam.usp_BatchImportFeatureFromExcel"
SP_PUSH_FEATURES = "[QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds]"
SP_DELETE_WORKSHEET = "QuickTeam.usp_DeleteWorkSheetAllById"
class QuickTeamApi:
def __init__(
self,
transport: Transport,
*,
dry_run: bool = False,
log: Callable[[str], None] | None = None,
) -> None:
self.t = transport
self.dry_run = dry_run
self.log = log or (lambda message: None)
self._projects: dict[str, list[Project]] = {}
# ------------------------------------------------------------------ 只读
def login(self, username: str, password: str) -> User:
digest = hashlib.md5(password.encode("utf-8")).hexdigest()
rows = self.t.exec_table(
CONN_USERCENTER,
SP_LOGIN,
[("UserName", username, "string"), ("Password", digest, "string")],
op="login",
)
if not rows:
raise QtError("登录失败:账号或密码不匹配", Category.AUTH)
row = rows[0]
return User(user_id=row["GuidId"], real_name=row.get("RealName") or username, user_name=row["UserName"])
def teams(self, user_id: str) -> list[Team]:
rows = self.t.exec_table(CONN_QUICKTEAM, SP_TEAMS, [("UserId", user_id, "string")], op="teams") or []
return [Team(team_id=row["TeamId"], team_name=row.get("TeamName", "")) for row in rows]
@staticmethod
def resolve_team(teams: list[Team], name: str) -> Team:
for team in teams:
if team.team_name == name:
return team
available = "、".join(t.team_name for t in teams)
raise QtError(f"未找到团队「{name}」,可选:{available}", Category.PARAM)
def projects(self, team_id: str) -> list[Project]:
if team_id not in self._projects:
rows = (
self.t.exec_table(
CONN_QUICKTEAM,
SP_PROJECTS,
[("TeamId", team_id, "string"), ("IsTemplate", "1", "string")],
op="projects",
)
or []
)
self._projects[team_id] = [
Project(project_id=row["ProjectId"], project_name=row.get("ProjectName", "")) for row in rows
]
return self._projects[team_id]
def worksheet(self, worksheet_id: int) -> WorkSheet:
rows = self.t.exec_table(
CONN_QUICKTEAM, SP_WORKSHEET, [("WorkSheetId", worksheet_id, "int")], op="worksheet"
)
if not rows:
raise QtError(f"工单 {worksheet_id} 不存在", Category.PARAM)
return self._to_worksheet(rows[0])
def project_users(self, project_id: str) -> list[dict]:
return (
self.t.exec_table(
CONN_QUICKTEAM, SP_PROJECT_USERS, [("ProjectId", project_id, "string")], op="project_users"
)
or []
)
def work_sheets(self, project_id: str) -> list[WorkSheet]:
"""项目下全部工单(一次查全,含 SystemName / Status / 当前版本)。
比「按窗口查 + 逐单读详情」少 N 次往返,也不受 `usp_GetWeeklyWorkSheetsByProjectId`
按季度过滤的坑影响 —— plan 的幂等判定就靠它。
"""
rows = (
self.t.exec_table(
CONN_QUICKTEAM,
SP_WORKSHEET_VERSIONS,
[("ProjectId", project_id, "string"), ("isReclaimed", 0, "int")],
op="work_sheets",
)
or []
)
# 该 SP 用 union 拼了两部分:工单本体行(VersionId 为空)+ 每个版本一行。
# 版本行的 WorkSheetCode 被覆盖成了 VersionCode,所以字段优先取本体行。
base: dict[int, dict] = {}
other: dict[int, dict] = {}
for row in rows:
try:
worksheet_id = int(row.get("WorkSheetId"))
except (TypeError, ValueError):
continue
is_base = row.get("VersionId") in (None, "", 0)
(base if is_base else other).setdefault(worksheet_id, row)
result: list[WorkSheet] = []
for worksheet_id in sorted(set(base) | set(other)):
row = base.get(worksheet_id) or other[worksheet_id]
result.append(
WorkSheet(
worksheet_id=worksheet_id,
code=row.get("WorkSheetCode", ""),
status=str(row.get("Status") or ""),
version_id=row.get("CurrentVersionId"),
version_code=str(row.get("VersionCode") or ""),
system_name=str(row.get("SystemName") or ""),
start_time=ms_to_date(row.get("StartTime")),
end_time=ms_to_date(row.get("EndTime")),
)
)
return result
def worksheet_versions(self, project_id: str, worksheet_id: int | None = None) -> list[WorkSheetVersion]:
"""项目下所有工单的版本行(列表页用的那个 SP,一行 = 一个版本)。"""
rows = (
self.t.exec_table(
CONN_QUICKTEAM,
SP_WORKSHEET_VERSIONS,
[("ProjectId", project_id, "string"), ("isReclaimed", 0, "int")],
op="worksheet_versions",
)
or []
)
result: list[WorkSheetVersion] = []
seen: set[int] = set()
for row in rows:
raw_id = row.get("VersionId")
if raw_id in (None, "", 0):
continue # 该 SP 用 union 拼了「无版本」的占位行,跳过
try:
version_id = int(raw_id)
ws_id = int(row.get("WorkSheetId"))
except (TypeError, ValueError):
continue
if version_id in seen:
continue
if worksheet_id is not None and ws_id != worksheet_id:
continue
seen.add(version_id)
result.append(
WorkSheetVersion(
version_id=version_id,
worksheet_id=ws_id,
version_code=str(row.get("VersionCode") or ""),
version_status=str(row.get("VersionStatus") or ""),
next_operator=str(row.get("NextOperator") or ""),
)
)
result.sort(key=lambda item: item.version_id)
return result
def pending_version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion | None:
"""取「待确认版本」。导入明细和确认工单都必须落到它上面。"""
versions = self.worksheet_versions(project_id, worksheet_id)
if not versions:
return None
pending = [item for item in versions if item.pending]
if pending:
return max(pending, key=lambda item: item.version_id)
# 全部已确认:退回最后一个版本,交给上层按 version_code 判断流程是否走完
return versions[-1]
def features(self, worksheet_id: int) -> list[Feature]:
rows = (
self.t.exec_table(CONN_QUICKTEAM, SP_FEATURES, [("WorkSheetIds", str(worksheet_id), "string")], op="features")
or []
)
return [
Feature(feature_id=row["FeatureId"], code=row.get("FeatureCode", ""), name=row.get("FeatureName", ""))
for row in rows
]
# ------------------------------------------------------------------ 写入
def create_worksheet(self) -> int:
"""新增空工单,返回 WorkSheetId。"""
if self.dry_run:
self.log(" [dry-run] InsertWorkSheet() → 桩 WorkSheetId=0")
return 0
rows = self.t.exec_table(CONN_QUICKTEAM, SP_INSERT_WORKSHEET, [], op="insert_worksheet", write=True)
if not rows or not isinstance(rows, list) or "WorkSheetId" not in rows[0]:
raise QtError(f"InsertWorkSheet 返回异常:{rows!r}", Category.PARAM)
return int(rows[0]["WorkSheetId"])
def fill_worksheet(
self,
worksheet_id: int,
project: Project,
*,
system_name: str,
start_time: str,
end_time: str,
evaluator: str,
technical_leader: str,
customer_leader: str,
belong_team: str,
content: str,
operator: str,
service_type: str = "DevelopService",
status: str = "00",
other_instructions: str = "",
) -> int:
"""填工单基本信息(Operation=new)。返回 Result(>0 视为成功)。"""
params: list[tuple[str, Any, str]] = [
("WorkSheetId", worksheet_id, "string"),
("ProjectId", project.project_id, "string"),
("ProjectName", project.project_name, "string"),
("SystemName", system_name, "string"),
("Evaluator", evaluator, "string"),
("TechnicalLeader", technical_leader, "string"),
("CustomerLeader", customer_leader, "string"),
("StartTime", start_time, "string"),
("EndTime", end_time, "string"),
("Status", status, "string"),
("ServiceType", service_type, "string"),
("ImplementContent", content, "string"),
("OtherInstructions", other_instructions, "string"),
("Operator", operator, "string"),
("DeleteFlag", "0", "string"),
("Operation", "new", "string"),
("BelongTeam", belong_team, "string"),
]
if self.dry_run:
self.log(f" [dry-run] UpdateWorkSheetById(ws={worksheet_id}, sprint={system_name}, "
f"{start_time}~{end_time}, 负责人={technical_leader}) → 桩 Result=1")
return 1
rows = self.t.exec_table(CONN_QUICKTEAM, SP_UPDATE_WORKSHEET, params, op="update_worksheet", write=True)
result = rows[0].get("Result") if rows and isinstance(rows, list) else None
# 实测结论:这个 SP 的 Result 恒为 0,但数据确实写入了(WorkSheetCode/ProjectId/
# SystemName/StartTime/Status/BelongTeam 全部落库)。前端 `Result>0` 的提示判断
# 也不可靠,所以这里以「重新读取工单」为准,而不是信返回值。
if result is not None and int(result) > 0:
return int(result)
detail = self.worksheet(worksheet_id)
if detail.system_name != system_name:
raise QtError(
f"保存后校验失败:SystemName={detail.system_name!r},期望 {system_name!r}"
f"(Result={result!r})",
Category.PARAM,
)
return int(result or 0)
def upload_excel(self, local_path: str, folder: str = "WorkSheetImport") -> str:
if self.dry_run:
self.log(f" [dry-run] FileUpload({local_path}) → 桩路径")
return "<dry-run>"
return self.t.upload(local_path, folder)
def import_excel(self, worksheet_id: int, version_id: int, remote_path: str) -> None:
"""服务器端解析 Excel 写明细;Column1 == 0 为成功。"""
params: list[tuple[str, Any, str]] = [
("WorkSheetId", worksheet_id, "int"),
("VersionId", version_id, "int"),
]
if self.dry_run:
self.log(f" [dry-run] BatchImportFeature(ws={worksheet_id}, version={version_id}, {remote_path})")
return
result = self.t.batch_import(SP_IMPORT_EXCEL, params, remote_path)
code = None
if isinstance(result, list) and result and isinstance(result[0], dict):
code = result[0].get("Column1")
if code is None:
raise QtError(f"Excel 解析未返回结果:{result!r}", Category.EXCEL)
if int(code) != 0:
raise QtError(f"Excel 解析失败(Column1={code}),模板或内容不符", Category.EXCEL)
def delete_worksheet(self, worksheet_id: int) -> None:
"""删除整单(回滚半成品用)。"""
if self.dry_run:
self.log(f" [dry-run] DeleteWorkSheetAllById(ws={worksheet_id})")
return
self.t.exec_table(
CONN_QUICKTEAM, SP_DELETE_WORKSHEET, [("WorkSheetId", worksheet_id, "int")], op="delete_worksheet", write=True
)
def verify_worksheet(
self,
worksheet_id: int,
version_id: int,
*,
next_operator: str,
next_version_code: str,
operator: str,
percent: int,
) -> None:
"""确认工单(推进版本)。返回 0 为成功。"""
params: list[tuple[str, Any, str]] = [
("WorkSheetId", worksheet_id, "int"),
("VersionId", version_id, "int"),
("NextOperator", next_operator, "string"),
("NextVersionCode", next_version_code, "string"),
("Percent", percent, "int"),
("Operator", operator, "string"),
]
if self.dry_run:
self.log(f" [dry-run] VerifyWorkSheetById(ws={worksheet_id}, v={version_id}, "
f"next={next_operator}/{next_version_code}, percent={percent})")
return
result = self.t.exec_result(CONN_QUICKTEAM, SP_VERIFY_WORKSHEET, params, op="verify_worksheet")
if isinstance(result, list) and result and isinstance(result[0], dict):
result = result[0].get("Result", result[0].get("Column1"))
if result is not None and str(result).strip() not in {"0", "0.0"}:
raise QtError(f"确认工单失败,返回 {result!r}", Category.UNKNOWN)
# ------------------------------------------------------------------ TFS
def tfs_projects(self) -> list[dict]:
return self.t.tfs("GetTeamProjects", op="TFS.GetTeamProjects") or []
def tfs_teams(self, project_name: str, project_id: str) -> list[TfsTeam]:
rows = (
self.t.tfs(
"GetTeamByProject",
{"projectName": project_name, "projectId": project_id},
op="TFS.GetTeamByProject",
)
or []
)
teams: list[TfsTeam] = []
for row in rows:
area = row.get("AreaPath") or []
teams.append(
TfsTeam(
team_name=row.get("TeamName", ""),
area_path=area[0] if area else "",
iterations=row.get("Iterations") or [],
)
)
return teams
@staticmethod
def resolve_tfs_context(
teams: list[TfsTeam], team_name: str, sprint: str
) -> tuple[str, str]:
"""按团队名与 Sprint 名解析 (areaPath, iterationPath)。匹配不到直接报错,不猜。"""
team = next((t for t in teams if t.team_name == team_name), None)
if team is None:
available = "、".join(t.team_name for t in teams)
raise QtError(f"TFS 团队「{team_name}」不存在,可选:{available}", Category.TFS)
if not team.area_path:
raise QtError(f"TFS 团队「{team_name}」没有 AreaPath", Category.TFS)
target = normalize_iteration(sprint)
hits = [item for item in team.iterations if normalize_iteration(str(item.get("Name", ""))) == target]
if not hits: # 退化:用完整路径的最后一段匹配
hits = [
item
for item in team.iterations
if normalize_iteration(str(item.get("Path", "")).split("\\")[-1]) == target
]
if len(hits) != 1:
sample = "、".join(str(i.get("Path")) for i in team.iterations[-3:]) or "(空)"
raise QtError(
f"TFS 迭代「{sprint}」在团队「{team_name}」下匹配到 {len(hits)} 条(最近:{sample})",
Category.TFS,
)
path = str(hits[0].get("Path", "")).replace("/", "\\")
return team.area_path, path
def push_tfs(self, feature_ids: list[int], project_name: str, area_path: str, iteration_path: str) -> Any:
joined = ",".join(str(i) for i in feature_ids)
if self.dry_run:
self.log(
f" [dry-run] BatchImportWorkItemsToTfs(项目={project_name}, "
f"area={area_path}, iter={iteration_path}, features={joined or '-'})"
)
return []
if not feature_ids:
raise QtError("没有可推送的功能模块", Category.TFS)
inner = {
"SPName": SP_PUSH_FEATURES,
"SQLParams": [{"Name": "FeatureIds", "Value": joined, "DBType": "string"}],
}
import json
import urllib.parse
payload = {
"connectionName": CONN_QUICKTEAM,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
"projectName": project_name,
"areaPath": area_path,
"iterationPath": iteration_path,
}
result = self.t.tfs("BatchImportWorkItemsToTfs", payload, op="TFS.BatchImportWorkItemsToTfs", write=True)
# 这个接口返回的是**状态码**不是数量:0 = 成功,-1 = 失败(实测传不存在的项目名
# 返回 -1;正常返回 0)。别拿 >0 当成功,否则会把一批已推送的工单记成失败。
if isinstance(result, int) and not isinstance(result, bool) and result != 0:
raise QtError(f"TFS 推送失败(返回 {result})", Category.TFS)
return result
# ------------------------------------------------------------------ 内部
@staticmethod
def _to_worksheet(row: dict) -> WorkSheet:
return WorkSheet(
worksheet_id=int(row.get("WorkSheetId", 0)),
code=row.get("WorkSheetCode", ""),
status=str(row.get("Status", "")),
version_id=row.get("CurrentVersionId"),
version_code=row.get("VersionCode", "") or "",
system_name=row.get("SystemName", ""),
start_time=ms_to_date(row.get("StartTime")),
end_time=ms_to_date(row.get("EndTime")),
)
+93
View File
@@ -0,0 +1,93 @@
"""配置加载。
凭据解析优先级:环境变量 QT_PASSWORD > Windows DPAPI 文件 > 配置文件明文 > 交互输入。
配置文件默认 config/config.json(已在 .gitignore 中)。
"""
from __future__ import annotations
import getpass
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_CONFIG = "config/config.json"
@dataclass
class Config:
# 连接
base_url: str
username: str
password: str = field(default="", repr=False)
timeout: int = 60
# 业务
team_name: str = "当前项目"
work_dir: str = ""
sprint: str = ""
start_time: str = ""
end_time: str = ""
content_desc: str = "需求开发测试"
service_type: str = "DevelopService"
belong_team: str = ""
# 三个责任人;留空则用登录用户的真实姓名
evaluator: str = ""
technical_leader: str = ""
customer_leader: str = ""
# TFS
tfs_project: str = "Tools_Dev"
tfs_team: str = "DataTechnologies"
area_path: str = ""
iteration_path: str = ""
# 运行
workers: int = 2
db_path: str = "state/qtimport.db"
def resolved_roles(self, operator: str) -> tuple[str, str, str]:
"""三个责任人,缺省都落到操作人(与现有脚本一致)。"""
return (
self.evaluator or operator,
self.technical_leader or operator,
self.customer_leader or operator,
)
@classmethod
def load(cls, path: str | os.PathLike = DEFAULT_CONFIG, *, need_password: bool = True) -> "Config":
file_path = Path(path)
if not file_path.exists():
raise SystemExit(f"缺少配置文件:{file_path}(可从 config/config.example.json 复制)")
with open(file_path, encoding="utf-8-sig") as handle:
raw = json.load(handle)
allowed = {f for f in cls.__dataclass_fields__}
unknown = set(raw) - allowed
if unknown:
raise SystemExit(f"配置文件含未知字段:{sorted(unknown)}")
cfg = cls(**raw)
cfg.password = resolve_password(cfg.password, need=need_password)
if not cfg.base_url or not cfg.username:
raise SystemExit("配置缺少 base_url 或 username")
if need_password and not cfg.password:
raise SystemExit("未提供密码:设置 QT_PASSWORD 环境变量或在配置中填写")
return cfg
def resolve_password(configured: str, *, need: bool = True) -> str:
env = os.environ.get("QT_PASSWORD")
if env:
return env
if configured and configured != "REPLACE_ME":
return configured
if not need:
return ""
if not os.isatty(0): # 非交互(计划任务/CI)
return ""
return getpass.getpass("QuickTeam 密码:")
def load_raw(path: str | os.PathLike = DEFAULT_CONFIG) -> dict:
"""读原始 JSON(plan 阶段不需要密码时用)。"""
with open(Path(path), encoding="utf-8-sig") as handle:
return json.load(handle)
+131
View File
@@ -0,0 +1,131 @@
"""domain 层:判定「该做什么」,不含 HTTP、不含 SQL。
这里集中三个最容易被写散的规则:
1. 文件 ↔ 项目 的匹配(精确,绝不猜)
2. 工单是否已存在 / 是否可续跑(幂等的判断源)
3. 版本推进(确认工单)的下一步参数
"""
from __future__ import annotations
import os
import zipfile
from dataclasses import dataclass
from enum import StrEnum
from .models import Project, WorkSheet, normalize_name
class Action(StrEnum):
CREATE = "create"
RESUME = "resume"
SKIP_DONE = "skip-done"
SKIP_EXISTS = "skip-exists"
SKIP_PUSHED = "skip-pushed"
ERROR = "error"
@property
def writes(self) -> bool:
return self in {Action.CREATE, Action.RESUME}
@dataclass
class PlanRow:
project_name: str
file_path: str
action: Action
reason: str = ""
project: Project | None = None
worksheet: WorkSheet | None = None
existing_count: int = 0
# 版本链(前端 EffortListVersionInfo 的等价物)
# version_code -> (next_operator, next_version_code, percent);None 表示流程结束
VERSION_TRANSITIONS: dict[str, tuple[str, str, int] | None] = {
"": ("CustomerLeader", "客户责任人确认版", 20), # 新建草稿视同「技术责任人确认版」
"技术责任人确认版": ("CustomerLeader", "客户责任人确认版", 20),
"客户责任人确认版": ("CustomerLeader", "甲方确认版", 20),
"甲方确认版": None,
}
def next_transition(version_code: str) -> tuple[str, str, int]:
"""返回 (NextOperator, NextVersionCode, Percent)。到终点则报错。"""
if version_code not in VERSION_TRANSITIONS:
raise ValueError(f"未知版本「{version_code}」,无法推导下一步")
step = VERSION_TRANSITIONS[version_code]
if step is None:
raise ValueError(f"版本「{version_code}」已是流程终点,无需确认")
return step
def decide(existing: list[WorkSheet], *, ledger_stage: str | None, adopt: bool) -> tuple[Action, str, WorkSheet | None]:
"""根据「服务器现状 + 台账记录」决定动作。"""
if not existing:
return Action.CREATE, "本项目该 Sprint 无工单", None
# 优先看同一 Sprint 的工单(调用方已按 SystemName 过滤)
worksheet = existing[0]
if worksheet.done:
return Action.SKIP_DONE, f"工单 {worksheet.code} 已确认完成(Status={worksheet.status})", worksheet
if ledger_stage == "TFS_PUSHED":
return Action.SKIP_PUSHED, f"工单 {worksheet.code} 已推送 TFS", worksheet
if ledger_stage:
return Action.RESUME, f"台账记录停在 {ledger_stage},续跑", worksheet
if adopt:
return Action.RESUME, "工单存在但无台账记录,--adopt 接管续跑", worksheet
return Action.SKIP_EXISTS, f"工单 {worksheet.code} 已存在(无台账记录,未接管)", worksheet
def list_xlsx(folder: str) -> list[str]:
if not os.path.isdir(folder):
raise SystemExit(f"目录不存在:{folder}")
result = []
for name in sorted(os.listdir(folder)):
path = os.path.join(folder, name)
if os.path.isfile(path) and name.lower().endswith(".xlsx") and not name.startswith("~$"):
result.append(path)
return result
def match_project(stem: str, projects: list[Project]) -> tuple[Project | None, str]:
"""精确匹配项目;命中多个或零个都算失败,不做「包含即命中」的猜测。"""
target = normalize_name(stem)
exact = [p for p in projects if normalize_name(p.project_name) == target]
if len(exact) == 1:
return exact[0], ""
if len(exact) > 1:
names = "、".join(p.project_name for p in exact)
return None, f"项目名歧义({names})"
# 只用于提示,不给结果
near = [p.project_name for p in projects if target in normalize_name(p.project_name)]
hint = f",相近项目:{'、'.join(near[:3])}" if near else ""
return None, f"QuickTeam 中没有该项目{hint}"
def precheck_xlsx(path: str) -> tuple[bool, str]:
"""上传前的轻量自检:确认是「工作量评估表」模板,避免白跑一趟服务器解析。"""
try:
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
if "xl/workbook.xml" not in names:
return False, "不是 xlsx(缺少 xl/workbook.xml)"
if "xl/sharedStrings.xml" not in names:
return False, "文件为空表或已损坏"
text = archive.read("xl/sharedStrings.xml").decode("utf-8", "ignore")
except zipfile.BadZipFile:
return False, "不是合法的 xlsx(可能仍是 Excel 打开中的临时文件)"
except OSError as exc:
return False, f"读取失败:{exc}"
if "工作量评估表" not in text:
return False, "未找到「工作量评估表」标题,可能不是工单模板"
if "服务模块" not in text:
return False, "未找到明细表头(服务模块),模板可能已变更"
return True, ""
+58
View File
@@ -0,0 +1,58 @@
"""异常与错误分类。
WCF 的错误是 HTML 页面,真正的信息藏在 "The exception message is '...'" 里。
分类的目的是决定「重试 / 跳过 / 整批中止」,而不是把所有失败都当网络问题重试。
"""
from __future__ import annotations
from enum import StrEnum
class Category(StrEnum):
NET = "NET" # 网络层:可重试
SP_MISSING = "SP_MISSING" # 存储过程不存在:配置/版本问题,不重试
PARAM = "PARAM" # 参数错误/类型转换:数据问题,不重试
EXCEL = "EXCEL" # Excel 解析失败(Column1 != 0)
TFS = "TFS" # 推送 TFS 失败
AUTH = "AUTH" # 登录失败
UNKNOWN = "UNKNOWN"
class QtError(RuntimeError):
"""带分类的接口错误。"""
def __init__(self, message: str, category: Category = Category.UNKNOWN, *, retryable: bool = False):
super().__init__(message)
self.message = message
self.category = category
self.retryable = retryable
def __str__(self) -> str: # pragma: no cover - 直通
return f"[{self.category}] {self.message}"
@classmethod
def from_http(cls, status: int, body: str) -> "QtError":
"""把 WCF 的 HTML 错误页转成可读错误。"""
message = extract_exception_message(body) or f"HTTP {status}"
lowered = message.lower()
if "could not find stored procedure" in lowered:
return cls(message, Category.SP_MISSING)
if "missing parameter" in lowered or "conversion" in lowered or "procedure or function" in lowered:
return cls(message, Category.PARAM)
if status >= 500:
return cls(message, Category.NET, retryable=True)
return cls(message, Category.UNKNOWN)
def extract_exception_message(body: str) -> str:
"""从 WCF 错误页里抠出真正的异常文本。"""
import html
import re
match = re.search(r"exception message is '(.*?)'\s*\.?\s*See server logs", body, re.S)
if match:
return html.unescape(match.group(1)).strip()
plain = re.sub(r"<[^>]+>", " ", body)
plain = html.unescape(re.sub(r"\s+", " ", plain)).strip()
return plain[:400]
+104
View File
@@ -0,0 +1,104 @@
"""数据结构。"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass, field
@dataclass
class User:
user_id: str
real_name: str
user_name: str
@dataclass
class Team:
team_id: str
team_name: str
@dataclass
class Project:
project_id: str
project_name: str
@dataclass
class WorkSheet:
"""工单(每项目每 Sprint 一张)。"""
worksheet_id: int
code: str = ""
status: str = ""
version_id: int | None = None
version_code: str = ""
system_name: str = ""
start_time: str = ""
end_time: str = ""
@property
def done(self) -> bool:
"""03/04 = 甲方已确认,流程闭环。"""
return self.status in {"03", "04"}
@dataclass
class Feature:
feature_id: int
code: str = ""
name: str = ""
@dataclass
class WorkSheetVersion:
"""工单版本(审批链上的一环)。
`usp_UpdateWorkSheetById` 在第一次保存时插入「技术责任人确认版」(VersionStatus='N'),
但**不回填** `WorkSheet.CurrentVersionId` —— 那要等 `usp_VerifyWorkSheetById` 才写。
所以导入明细/确认工单都必须用「待确认版本」的 VersionId,不能用 CurrentVersionId。
"""
version_id: int
worksheet_id: int
version_code: str = ""
version_status: str = "" # N=待处理(当前),Y=已确认
next_operator: str = ""
@property
def pending(self) -> bool:
return self.version_status.strip().upper() == "N"
@dataclass
class TfsTeam:
team_name: str
area_path: str
iterations: list[dict] = field(default_factory=list)
def ms_to_date(value: object) -> str:
"""`/Date(1785081600000)/` → `2026-07-26`。"""
if not value:
return ""
if isinstance(value, str) and value.startswith("/Date("):
match = re.search(r"\((-?\d+)", value)
if match:
return time.strftime("%Y-%m-%d", time.localtime(int(match.group(1)) / 1000))
return str(value)
def normalize_name(name: str) -> str:
"""项目/迭代名归一化:去空白、统一破折号、忽略大小写。用于精确匹配,避免 contains 误命中。"""
text = re.sub(r"\s+", "", name or "")
for dash in ("-", "—", "–", "−"):
text = text.replace(dash, "-")
return text.casefold()
def normalize_iteration(name: str) -> str:
"""迭代名比对:在 normalize_name 基础上忽略数字前导零(`2026 Sprint 06` ≈ `2026 Sprint 6`)。"""
text = normalize_name(name)
return re.sub(r"(?<=\D)0+(?=\d)", "", text)
+115
View File
@@ -0,0 +1,115 @@
"""日志与报告。"""
from __future__ import annotations
import csv
import json
import os
import time
from typing import Any
from .store import Store
class RunLog:
"""控制台摘要 + JSONL 明细。"""
def __init__(self, run_id: str, log_dir: str = "logs", *, verbose: bool = True) -> None:
os.makedirs(log_dir, exist_ok=True)
self.path = os.path.join(log_dir, f"{run_id}.jsonl")
self.verbose = verbose
self.run_id = run_id
self._handle = open(self.path, "a", encoding="utf-8")
def close(self) -> None:
self._handle.close()
def _write(self, record: dict[str, Any]) -> None:
record.setdefault("ts", time.strftime("%Y-%m-%d %H:%M:%S"))
record.setdefault("run", self.run_id)
self._handle.write(json.dumps(record, ensure_ascii=False) + "\n")
self._handle.flush()
def info(self, message: str, **fields: Any) -> None:
self._write({"level": "info", "message": message, **fields})
if self.verbose:
print(message)
def event(self, stage: str, action: str, *, project: str = "", detail: str = "", elapsed_ms: int = 0) -> None:
self._write(
{
"level": "event",
"stage": stage,
"action": action,
"project": project,
"detail": detail,
"elapsed_ms": elapsed_ms,
}
)
def warn(self, message: str, **fields: Any) -> None:
self._write({"level": "warn", "message": message, **fields})
if self.verbose:
print(f" ! {message}")
def write_report(run_id: str, store: Store, out_dir: str = "reports") -> tuple[str, str]:
"""生成 summary.md 与 detail.csv。"""
target = os.path.join(out_dir, run_id)
os.makedirs(target, exist_ok=True)
jobs = store.jobs(run_id)
events = store.events(run_id)
md_path = os.path.join(target, "summary.md")
csv_path = os.path.join(target, "detail.csv")
counts: dict[str, int] = {}
for job in jobs:
counts[job["action"]] = counts.get(job["action"], 0) + 1
failures = [job for job in jobs if job["error"]]
with open(csv_path, "w", encoding="utf-8-sig", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(
["项目", "Sprint", "文件", "动作", "阶段", "工单号", "版本", "耗时(ms)", "错误"]
)
for job in jobs:
elapsed = sum(
e["elapsed_ms"] or 0
for e in events
if e["project_id"] == job["project_id"] and e["elapsed_ms"]
)
writer.writerow(
[
job["project_name"],
job["system_name"],
os.path.basename(job["source_file"]),
job["action"],
job["stage"],
job["worksheet_id"] or "",
job["version_id"] or "",
elapsed,
(job["error"] or "").replace("\n", " ")[:300],
]
)
info = store.run_info(run_id) or {}
mode = "演练(未写入任何数据)" if info.get("dry_run") else "实际执行"
with open(md_path, "w", encoding="utf-8") as handle:
handle.write(f"# 导入报告 {run_id}\n\n")
handle.write(f"- 模式:{mode}\n")
handle.write(f"- Sprint:{info.get('sprint', '')}\n")
handle.write(f"- 项目数:{len(jobs)}\n")
handle.write(f"- 失败:{len(failures)}\n")
for action, count in sorted(counts.items()):
handle.write(f"- {action}:{count}\n")
handle.write("\n| 项目 | 动作 | 阶段 | 工单号 | 错误 |\n| --- | --- | --- | --- | --- |\n")
for job in jobs:
error = (job["error"] or "").replace("\n", " ")[:120].replace("|", "\\|")
handle.write(
f"| {job['project_name']} | {job['action']} | {job['stage']} | "
f"{job['worksheet_id'] or '-'} | {error or ''} |\n"
)
return md_path, csv_path
+207
View File
@@ -0,0 +1,207 @@
"""SQLite 台账:断点、幂等判断与审计的唯一事实源。"""
from __future__ import annotations
import os
import sqlite3
import threading
import time
from typing import Any
SCHEMA = """
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
finished_at TEXT,
sprint TEXT NOT NULL,
work_dir TEXT NOT NULL,
dry_run INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'RUNNING'
);
CREATE TABLE IF NOT EXISTS jobs (
run_id TEXT NOT NULL,
project_id TEXT NOT NULL,
project_name TEXT NOT NULL,
system_name TEXT NOT NULL,
source_file TEXT NOT NULL,
action TEXT NOT NULL,
stage TEXT NOT NULL,
worksheet_id INTEGER,
version_id INTEGER,
reason TEXT,
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (run_id, project_id)
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
project_id TEXT,
stage TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT,
elapsed_ms INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs (project_id, system_name, updated_at);
CREATE INDEX IF NOT EXISTS idx_events_run ON events (run_id, id);
"""
def now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
class Store:
def __init__(self, db_path: str) -> None:
directory = os.path.dirname(os.path.abspath(db_path))
os.makedirs(directory, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
with self._lock:
self._conn.executescript(SCHEMA)
self._conn.commit()
def close(self) -> None:
with self._lock:
self._conn.close()
# ------------------------------------------------------------------ runs
def start_run(self, run_id: str, sprint: str, work_dir: str, dry_run: bool) -> None:
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO runs (run_id, started_at, sprint, work_dir, dry_run, status) "
"VALUES (?, ?, ?, ?, ?, 'RUNNING')",
(run_id, now(), sprint, work_dir, int(dry_run)),
)
self._conn.commit()
def finish_run(self, run_id: str, status: str = "DONE") -> None:
with self._lock:
self._conn.execute(
"UPDATE runs SET finished_at = ?, status = ? WHERE run_id = ?", (now(), status, run_id)
)
self._conn.commit()
# ------------------------------------------------------------------ jobs
def upsert_job(
self,
run_id: str,
*,
project_id: str,
project_name: str,
system_name: str,
source_file: str,
action: str,
stage: str,
worksheet_id: int | None = None,
version_id: int | None = None,
reason: str = "",
error: str = "",
) -> None:
with self._lock:
self._conn.execute(
"""
INSERT INTO jobs (run_id, project_id, project_name, system_name, source_file, action, stage,
worksheet_id, version_id, reason, error, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id, project_id) DO UPDATE SET
action = excluded.action,
stage = excluded.stage,
worksheet_id = COALESCE(excluded.worksheet_id, jobs.worksheet_id),
version_id = COALESCE(excluded.version_id, jobs.version_id),
reason = excluded.reason,
error = excluded.error,
updated_at = excluded.updated_at
""",
(
run_id,
project_id,
project_name,
system_name,
source_file,
str(action),
stage,
worksheet_id,
version_id,
reason,
error,
now(),
),
)
self._conn.commit()
def latest_stage(self, project_id: str, system_name: str) -> str | None:
"""跨 run 取该项目该 Sprint 最近一次落库的阶段,用于断点续跑。"""
job = self.latest_job(project_id, system_name)
return job["stage"] if job else None
def latest_job(self, project_id: str, system_name: str) -> dict[str, Any] | None:
"""最近一次**真实**执行记录。dry-run 不参与幂等判断,否则会把演练当成已完成。
`SKIPPED` 行也不参与:它只表示「这一轮不用做」,不带任何进度信息。若把它当成
最近记录,下次 plan 会把「已推送 TFS」看成「台账停在 SKIPPED」,误判 resume 并
再推一次 —— 而 TFS 推送接口没有服务端去重,重复推送就是重复工作项。
"""
with self._lock:
row = self._conn.execute(
"""
SELECT j.* FROM jobs j
JOIN runs r ON r.run_id = j.run_id
WHERE j.project_id = ? AND j.system_name = ? AND r.dry_run = 0
AND j.stage <> 'SKIPPED'
ORDER BY j.updated_at DESC LIMIT 1
""",
(project_id, system_name),
).fetchone()
return dict(row) if row else None
def jobs(self, run_id: str) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM jobs WHERE run_id = ? ORDER BY project_name", (run_id,)
).fetchall()
return [dict(row) for row in rows]
# ------------------------------------------------------------------ events
def add_event(
self,
run_id: str,
*,
stage: str,
action: str,
project_id: str = "",
detail: str = "",
elapsed_ms: int = 0,
) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO events (run_id, project_id, stage, action, detail, elapsed_ms, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(run_id, project_id, stage, action, detail[:2000], elapsed_ms, now()),
)
self._conn.commit()
def events(self, run_id: str) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM events WHERE run_id = ? ORDER BY id", (run_id,)
).fetchall()
return [dict(row) for row in rows]
def run_info(self, run_id: str) -> dict[str, Any] | None:
with self._lock:
row = self._conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
return dict(row) if row else None
def last_run(self) -> str | None:
with self._lock:
row = self._conn.execute("SELECT run_id FROM runs ORDER BY started_at DESC LIMIT 1").fetchone()
return row["run_id"] if row else None
+206
View File
@@ -0,0 +1,206 @@
"""传输层:WCF + 通用存储过程网关。
只负责 HTTP 与协议细节:
- 双层 JSON 解码(返回值常是「JSON 字符串里再套一层 JSON」)
- HTML 错误页 → 可读异常
- 网络层重试(仅幂等调用)
- multipart 上传
业务语义一律放在 api 层。
"""
from __future__ import annotations
import json
import mimetypes
import os
import socket
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable
from .errors import Category, QtError
CallHook = Callable[[str, int, bool, str], None]
# 服务路径(来自前端 app.js 的配置模块 a078)
COMMON_URL = "/Services/CommonService.svc/"
FILE_URL = "/Services/FileOperationService.svc/"
TFS_URL = "/Services/TFSOperationService.svc/"
def _content_type(path: str) -> str:
return mimetypes.guess_type(path)[0] or "application/octet-stream"
class Transport:
def __init__(
self,
base_url: str,
*,
timeout: int = 60,
retries: int = 2,
on_call: CallHook | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.retries = retries
self.on_call = on_call
# ------------------------------------------------------------------ 内部
def _record(self, op: str, started: float, ok: bool, detail: str = "") -> None:
if self.on_call:
self.on_call(op, int((time.time() - started) * 1000), ok, detail)
def _post(
self,
path: str,
payload: Any = None,
*,
op: str = "",
retry: bool = False,
raw_body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> str:
url = self.base_url + path
if raw_body is not None:
body = raw_body
else:
body = b"" if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
hdrs = {"Accept": "application/json"}
if raw_body is None:
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
attempts = self.retries + 1 if retry else 1
started = time.time()
last_error: Exception | None = None
for attempt in range(attempts):
request = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
text = response.read().decode("utf-8-sig", "replace")
self._record(op or path, started, True)
return text
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8-sig", "replace")
error = QtError.from_http(exc.code, detail)
self._record(op or path, started, False, str(error))
# 只有网络/服务端 5xx 才值得重试,业务错误重试无意义
if error.retryable and attempt < attempts - 1:
time.sleep(1.5 * (attempt + 1))
continue
raise error from None
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as exc:
last_error = exc
self._record(op or path, started, False, f"网络错误:{exc}")
if attempt < attempts - 1:
time.sleep(1.5 * (attempt + 1))
continue
raise QtError(f"网络不可达:{exc}", Category.NET, retryable=True) from None
raise QtError(f"请求失败:{last_error}", Category.NET)
@staticmethod
def decode(text: str) -> Any:
"""WCF 返回值可能是「JSON 字符串里再套一层 JSON」,最多解三层。"""
value: Any = text.strip()
for _ in range(3):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
break
return value
# ------------------------------------------------------------------ 网关
def exec_table(
self, connection: str, procedure: str, params: list[tuple[str, Any, str]], *, op: str = "", write: bool = False
) -> Any:
"""ExecTable:结果集语义。"""
return self._exec("ExecTable", connection, procedure, params, op=op, write=write)
def exec_result(
self, connection: str, procedure: str, params: list[tuple[str, Any, str]], *, op: str = ""
) -> Any:
"""ExecResult:标量/返回值语义(Result、Column1 等)。"""
return self._exec("ExecResult", connection, procedure, params, op=op, write=True)
def _exec(
self,
method: str,
connection: str,
procedure: str,
params: list[tuple[str, Any, str]],
*,
op: str,
write: bool,
) -> Any:
inner = {
"SPName": procedure,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": connection,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
text = self._post(f"{COMMON_URL}{method}", payload, op=op or procedure, retry=not write)
return self.decode(text)
# ------------------------------------------------------------------ 文件
def upload(self, local_path: str, folder: str, *, op: str = "FileUpload") -> str:
"""上传文件,返回服务器端绝对路径。
注意:必须发**裸字节**,不能套 multipart。前端把原生 File 对象直接交给
axios(`data: e`,e 是 upload-file,`send(File)` 走的是 file type),WCF 侧
按 `Stream` 接收后原样落盘。发 multipart 时服务器把整段 multipart 包体写进
.xlsx,文件损坏 → 服务端解析出空 DataTable → SqlBulkCopyToDB 抛
「Object reference not set to an instance of an object」。
"""
name = f"{int(time.time() * 1000)}{os.path.splitext(local_path)[1]}"
query = urllib.parse.urlencode({"fileName": name, "fileFolder": folder})
with open(local_path, "rb") as handle:
body = handle.read()
text = self._post(
f"{FILE_URL}FileUpload?{query}",
raw_body=body,
headers={"Content-Type": _content_type(local_path)},
op=op,
retry=True, # 同名覆盖无副作用,可重试
)
result = self.decode(text)
path = (result or {}).get("FileUploadResult") if isinstance(result, dict) else None
if not path:
raise QtError(f"上传未返回 FileUploadResult:{result!r}", Category.UNKNOWN)
return path
def batch_import(self, procedure: str, params: list[tuple[str, Any, str]], file_path: str) -> Any:
"""BatchImportFeature:服务器端解析 Excel 并写库。"""
inner = {
"SPName": procedure,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": "QuickTeam",
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
"filePath": file_path,
}
text = self._post(f"{FILE_URL}BatchImportFeature", payload, op="BatchImportFeature", retry=False)
return self.decode(text)
# ------------------------------------------------------------------ TFS
def tfs(self, operation: str, payload: Any = None, *, op: str = "", write: bool = False) -> Any:
text = self._post(f"{TFS_URL}{operation}", payload, op=op or f"TFS.{operation}", retry=not write)
return self.decode(text)
+416
View File
@@ -0,0 +1,416 @@
"""workflow 层:单项目编排、阶段推进、补偿与并发。
阶段顺序固定,台账里记到哪一步,下次就从下一步开始 —— 这是断点续跑的唯一依据。
"""
from __future__ import annotations
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from . import domain
from .api import QuickTeamApi
from .config import Config
from .domain import Action, PlanRow
from .errors import Category, QtError
from .models import Project, User, WorkSheet, WorkSheetVersion
from .report import RunLog
from .store import Store
STAGES = ["WS_CREATED", "WS_FILLED", "EXCEL_IMPORTED", "WS_VERIFIED", "TFS_PUSHED"]
STAGE_INDEX = {stage: index for index, stage in enumerate(STAGES)}
@dataclass
class RunOptions:
adopt: bool = False # 接管无台账记录的既有工单
skip_tfs: bool = False # 只建单+导入+确认,不推送
rollback_on_fail: bool = False
limit: int | None = None
projects: list[str] = field(default_factory=list)
force: bool = False # 跳过 Excel 模板预检
dry_run: bool = False # 只演练:不产生任何服务端写入
@dataclass
class ProjectProgress:
row: PlanRow
stage: str
worksheet_id: int | None = None
version_id: int | None = None
elapsed_ms: int = 0
class Importer:
def __init__(self, cfg: Config, store: Store, run_id: str, log: RunLog, options: RunOptions) -> None:
self.cfg = cfg
self.store = store
self.run_id = run_id
self.log = log
self.options = options
self._thread_state = threading.local()
self.api = QuickTeamApi(
self._build_transport(),
dry_run=options.dry_run,
log=lambda message: self.log.info(message, level="dry"),
)
self._tfs_lock = threading.Lock()
self._tfs_context: tuple[str, str] | None = None
self.user: User | None = None
self.team_name = ""
# ------------------------------------------------------------------ 装配
def _build_transport(self):
from .transport import Transport
def on_call(op: str, elapsed: int, ok: bool, detail: str) -> None:
project = getattr(self._thread_state, "project", "")
self.store.add_event(
self.run_id,
stage="API",
action="ok" if ok else "fail",
project_id=getattr(self._thread_state, "project_id", ""),
detail=f"{op} {detail}".strip(),
elapsed_ms=elapsed,
)
self.log.event("API", "ok" if ok else "fail", project=project, detail=op, elapsed_ms=elapsed)
return Transport(
self.cfg.base_url, timeout=self.cfg.timeout, retries=2, on_call=on_call
)
# ------------------------------------------------------------------ plan
def connect(self) -> User:
if not self.user:
self.user = self.api.login(self.cfg.username, self.cfg.password)
teams = self.api.teams(self.user.user_id)
team = self.api.resolve_team(teams, self.cfg.team_name)
self.team_name = team.team_name
self.team_id = team.team_id
self.log.info(f"已登录:{self.user.real_name}({self.user.user_name}),团队:{team.team_name}")
return self.user
def plan(self) -> list[PlanRow]:
self.connect()
projects = self.api.projects(self.team_id)
files = domain.list_xlsx(self.cfg.work_dir)
rows: list[PlanRow] = []
for path in files:
stem = os.path.splitext(os.path.basename(path))[0]
project, reason = domain.match_project(stem, projects)
if project is None:
rows.append(PlanRow(project_name=stem, file_path=path, action=Action.ERROR, reason=reason))
continue
if not self.options.force:
ok, why = domain.precheck_xlsx(path)
if not ok:
rows.append(
PlanRow(project_name=project.project_name, file_path=path, action=Action.ERROR,
project=project, reason=f"Excel 预检失败:{why}")
)
continue
ledger = self.store.latest_job(project.project_id, self.cfg.sprint)
existing = [
sheet for sheet in self.api.work_sheets(project.project_id)
if sheet.system_name == self.cfg.sprint
]
action, why, worksheet = domain.decide(
existing, ledger_stage=ledger["stage"] if ledger else None, adopt=self.options.adopt
)
rows.append(
PlanRow(
project_name=project.project_name,
file_path=path,
action=action,
reason=why,
project=project,
worksheet=worksheet,
existing_count=len(existing),
)
)
return rows
# ------------------------------------------------------------------ apply
def apply(self) -> list[ProjectProgress]:
rows = self.plan()
write_rows = [row for row in rows if row.action.writes]
if self.options.projects:
wanted = {domain.normalize_name(name) for name in self.options.projects}
write_rows = [row for row in write_rows if domain.normalize_name(row.project_name) in wanted]
if self.options.limit:
write_rows = write_rows[: self.options.limit]
# 非执行行也落台账,报告里才能看到「为什么跳过」
for row in rows:
if row.action.writes:
continue
project_id = row.project.project_id if row.project else row.project_name
if self.store.latest_job(project_id, self.cfg.sprint):
# 已经有真实进度记录,别用 SKIPPED 覆盖它(否则下次会把「已推送」当成「无进度」)
self.log.event(str(row.action), "skip", project=row.project_name, detail=row.reason)
continue
self.store.upsert_job(
self.run_id,
project_id=project_id,
project_name=row.project_name,
system_name=self.cfg.sprint,
source_file=row.file_path,
action=str(row.action),
stage="SKIPPED" if row.action != Action.ERROR else "FAILED",
worksheet_id=row.worksheet.worksheet_id if row.worksheet else None,
reason=row.reason,
error=row.reason if row.action == Action.ERROR else "",
)
self.log.event(str(row.action), "skip", project=row.project_name, detail=row.reason)
if not write_rows:
self.log.info("没有需要执行的项目")
return []
self.log.info(f"开始执行 {len(write_rows)} 个项目(并发 {self.cfg.workers})")
results: list[ProjectProgress] = []
with ThreadPoolExecutor(max_workers=max(1, self.cfg.workers)) as pool:
futures = {pool.submit(self._run_project, row): row for row in write_rows}
for future in as_completed(futures):
row = futures[future]
try:
results.append(future.result())
except QtError as error:
if error.category in {Category.AUTH, Category.SP_MISSING}:
self.log.warn(f"中止:{error}")
raise
results.append(ProjectProgress(row=row, stage="FAILED"))
self.log.warn(f"{row.project_name} 失败:{error}")
except Exception as error: # 兜底,避免一个项目的异常带崩整批
results.append(ProjectProgress(row=row, stage="FAILED"))
self.log.warn(f"{row.project_name} 未预期异常:{error!r}")
return results
def _run_project(self, row: PlanRow) -> ProjectProgress:
assert row.project is not None
started = time.time()
self._thread_state.project = row.project_name
self._thread_state.project_id = row.project.project_id
ledger = self.store.latest_job(row.project.project_id, self.cfg.sprint)
if row.action == Action.CREATE:
# plan 已经确认服务端没有该 Sprint 的工单,台账里的旧记录可能来自已被回滚的
# 半成品(工单已删除)。此时必须忽略台账,否则会去填一张不存在的工单。
ledger = None
done_upto = self._completed_index(ledger["stage"] if ledger else "")
worksheet_id = (ledger or {}).get("worksheet_id") or (
row.worksheet.worksheet_id if row.worksheet else None
)
version_id: int | None = (ledger or {}).get("version_id")
def need(stage: str) -> bool:
return STAGE_INDEX[stage] > done_upto
stage = "PLANNED"
created_here = row.action == Action.CREATE
try:
if need("WS_CREATED"):
stage = "WS_CREATED"
worksheet_id = self.api.create_worksheet()
self._mark(row, stage, worksheet_id, None)
if need("WS_FILLED"):
stage = "WS_FILLED"
assert worksheet_id is not None
evaluator, technical, customer = self.cfg.resolved_roles(self.user.real_name)
self.api.fill_worksheet(
worksheet_id,
row.project,
system_name=self.cfg.sprint,
start_time=self.cfg.start_time,
end_time=self.cfg.end_time,
evaluator=evaluator,
technical_leader=technical,
customer_leader=customer,
belong_team=self.cfg.belong_team,
content=self.cfg.content_desc,
operator=self.user.real_name,
service_type=self.cfg.service_type,
)
self._mark(row, stage, worksheet_id, None)
if need("EXCEL_IMPORTED"):
stage = "EXCEL_IMPORTED"
assert worksheet_id is not None
version = self._version(row.project.project_id, worksheet_id)
version_id = version.version_id
remote = self.api.upload_excel(row.file_path)
self.api.import_excel(worksheet_id, version_id, remote)
self._mark(row, stage, worksheet_id, version_id)
if need("WS_VERIFIED"):
stage = "WS_VERIFIED"
assert worksheet_id is not None
detail = self._detail(worksheet_id)
version = self._version(row.project.project_id, worksheet_id)
if detail.done:
self.log.event(stage, "skip", project=row.project_name, detail="工单已完成,无需确认")
elif version is None:
raise QtError(f"工单 {worksheet_id} 没有版本记录,无法确认", Category.PARAM)
elif domain.VERSION_TRANSITIONS.get(version.version_code) is None:
self.log.event(stage, "skip", project=row.project_name, detail="已是流程终点,无需确认")
else:
try:
next_operator, next_code, percent = domain.next_transition(version.version_code)
except ValueError as error:
raise QtError(str(error), Category.PARAM) from None
self.api.verify_worksheet(
worksheet_id,
version.version_id,
next_operator=next_operator,
next_version_code=next_code,
operator=self.user.real_name,
percent=percent,
)
self._mark(row, stage, worksheet_id, version.version_id if version else version_id)
if need("TFS_PUSHED"):
assert worksheet_id is not None
if self.options.skip_tfs:
self.log.event("TFS_PUSHED", "skip", project=row.project_name, detail="--skip-tfs")
else:
stage = "TFS_PUSHED"
area, iteration = self._resolve_tfs()
features = [] if self.api.dry_run else [
item.feature_id for item in self.api.features(worksheet_id)
]
if not features and not self.api.dry_run:
raise QtError("工单没有功能模块,跳过推送(请检查 Excel 是否为空表)", Category.EXCEL)
created = self.api.push_tfs(features, self.cfg.tfs_project, area, iteration)
self.log.event(
"TFS_PUSHED", "ok", project=row.project_name,
detail=f"features={len(features)} 创建={created}",
)
self._mark(row, stage, worksheet_id, version_id)
elapsed = int((time.time() - started) * 1000)
self.log.info(f" ✓ {row.project_name} → {stage} {elapsed / 1000:.1f}s")
return ProjectProgress(row=row, stage=stage, worksheet_id=worksheet_id,
version_id=version_id, elapsed_ms=elapsed)
except QtError as error:
elapsed = int((time.time() - started) * 1000)
self.store.upsert_job(
self.run_id,
project_id=row.project.project_id,
project_name=row.project_name,
system_name=self.cfg.sprint,
source_file=row.file_path,
action=str(row.action),
stage=f"FAILED:{stage}",
worksheet_id=worksheet_id,
version_id=version_id,
reason=row.reason,
error=str(error),
)
self.store.add_event(
self.run_id, stage=stage, action="fail", project_id=row.project.project_id,
detail=str(error), elapsed_ms=elapsed,
)
self.log.warn(f"{row.project_name} 在 {stage} 失败:{error}")
if self.options.rollback_on_fail and created_here and STAGE_INDEX[stage] < STAGE_INDEX["WS_VERIFIED"]:
self._rollback(row, worksheet_id, stage)
if error.category in {Category.AUTH, Category.SP_MISSING}:
raise
return ProjectProgress(row=row, stage=f"FAILED:{stage}", worksheet_id=worksheet_id,
version_id=version_id, elapsed_ms=elapsed)
finally:
self._thread_state.project = ""
self._thread_state.project_id = ""
# ------------------------------------------------------------------ 辅助
@staticmethod
def _completed_index(stage: str) -> int:
"""台账阶段 → 已完成的阶段下标。
`FAILED:EXCEL_IMPORTED` 表示「导入这一步失败了」,所以它之前的都算完成,
重跑要从 EXCEL_IMPORTED 本身重试,而不是从头再来(否则会重复建单)。
"""
if not stage:
return -1
if stage.startswith("FAILED:"):
failed_at = stage.split(":", 1)[1]
return STAGE_INDEX.get(failed_at, 0) - 1
return STAGE_INDEX.get(stage, -1)
def _detail(self, worksheet_id: int) -> WorkSheet:
if self.api.dry_run:
return WorkSheet(worksheet_id=worksheet_id, version_id=0, version_code="")
return self.api.worksheet(worksheet_id)
def _version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion | None:
"""当前待确认版本。导入明细与确认工单都挂在它上面。
`WorkSheet.CurrentVersionId` 在第一次保存后仍为 NULL(要等确认才回填),
所以不能拿它当 VersionId 用 —— 那会把 WorkSheetTask 插到不存在的版本上,
报 FK_WorkSheetTask_WorkSheetVersion 冲突。
"""
if self.api.dry_run:
return WorkSheetVersion(version_id=0, worksheet_id=worksheet_id,
version_code="技术责任人确认版", version_status="N")
return self.api.pending_version(project_id, worksheet_id)
def _resolve_tfs(self) -> tuple[str, str]:
if self.cfg.area_path and self.cfg.iteration_path:
return self.cfg.area_path, self.cfg.iteration_path
with self._tfs_lock:
if self._tfs_context:
return self._tfs_context
projects = self.api.tfs_projects()
target = next((p for p in projects if p.get("Name") == self.cfg.tfs_project), None)
if target is None:
raise QtError(f"TFS 项目「{self.cfg.tfs_project}」不存在", Category.TFS)
teams = self.api.tfs_teams(self.cfg.tfs_project, target["ProjectId"])
area, iteration = QuickTeamApi.resolve_tfs_context(teams, self.cfg.tfs_team, self.cfg.sprint)
self._tfs_context = (area, iteration)
self.log.info(f"TFS 上下文:area={area} iteration={iteration}")
return self._tfs_context
def _mark(
self, row: PlanRow, stage: str, worksheet_id: int | None, version_id: int | None
) -> None:
assert row.project is not None
self.store.upsert_job(
self.run_id,
project_id=row.project.project_id,
project_name=row.project_name,
system_name=self.cfg.sprint,
source_file=row.file_path,
action=str(row.action),
stage=stage,
worksheet_id=worksheet_id,
version_id=version_id,
reason=row.reason,
)
self.log.event(stage, "ok", project=row.project_name, detail=f"ws={worksheet_id}")
def _rollback(self, row: PlanRow, worksheet_id: int | None, stage: str) -> None:
if not worksheet_id:
return
try:
self.api.delete_worksheet(worksheet_id)
self.store.add_event(
self.run_id, stage=stage, action="rollback", project_id=row.project.project_id,
detail=f"已删除半成品工单 {worksheet_id}",
)
self.log.warn(f"{row.project_name} 已回滚(删除工单 {worksheet_id})")
except QtError as error:
self.log.warn(f"{row.project_name} 回滚失败:{error}")
View File
+333
View File
@@ -0,0 +1,333 @@
"""api 层:参数构造与返回判定的单元测试(不联网)。"""
from __future__ import annotations
import hashlib
import json
import unittest
import urllib.parse
from qtimport.api import QuickTeamApi
from qtimport.errors import Category, QtError
from qtimport.models import Project, TfsTeam
class FakeTransport:
def __init__(self) -> None:
self.calls: list[tuple] = []
self.table: dict = {}
self.result: object = 0
self.import_result: object = [{"Column1": 0}]
self.tfs_result: object = []
self.upload_result = "E:\\tmp\\x.xlsx"
def exec_table(self, connection, procedure, params, *, op="", write=False):
self.calls.append(("exec_table", connection, procedure, params, write))
return self.table.get(procedure, [])
def exec_result(self, connection, procedure, params, *, op=""):
self.calls.append(("exec_result", connection, procedure, params))
return self.result
def batch_import(self, procedure, params, file_path):
self.calls.append(("batch_import", procedure, params, file_path))
return self.import_result
def upload(self, local_path, folder, *, op="FileUpload"):
self.calls.append(("upload", local_path, folder))
return self.upload_result
def tfs(self, operation, payload=None, *, op="", write=False):
self.calls.append(("tfs", operation, payload))
return self.tfs_result
PROJECT = Project("0f52", "信托 - 上海信托")
class LoginTest(unittest.TestCase):
def test_password_is_md5(self) -> None:
fake = FakeTransport()
fake.table["dbo.usp_Authentication"] = [
{"GuidId": "g-1", "RealName": "陈首林", "UserName": "chen@goldenstand.cn"}
]
api = QuickTeamApi(fake)
user = api.login("chen@goldenstand.cn", "dummy-pass")
self.assertEqual(user.user_id, "g-1")
self.assertEqual(user.real_name, "陈首林")
_, connection, procedure, params, _ = fake.calls[0]
self.assertEqual(connection, "UserCenter")
self.assertEqual(procedure, "dbo.usp_Authentication")
sent = dict((name, value) for name, value, _ in params)
self.assertEqual(sent["Password"], hashlib.md5(b"dummy-pass").hexdigest())
def test_empty_result_is_auth_error(self) -> None:
api = QuickTeamApi(FakeTransport())
with self.assertRaises(QtError) as ctx:
api.login("a", "b")
self.assertEqual(ctx.exception.category, Category.AUTH)
class FillWorkSheetTest(unittest.TestCase):
def _api(self) -> tuple[QuickTeamApi, FakeTransport]:
fake = FakeTransport()
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 1}]
return QuickTeamApi(fake), fake
def test_payload(self) -> None:
api, fake = self._api()
api.fill_worksheet(
123,
PROJECT,
system_name="2026 Sprint 10",
start_time="2026-10-01",
end_time="2026-10-31",
evaluator="陈首林",
technical_leader="陈首林",
customer_leader="杜衡",
belong_team="资管投资人事业部",
content="需求开发测试",
operator="陈首林",
)
_, connection, procedure, params, write = fake.calls[0]
self.assertEqual(connection, "QuickTeam")
self.assertEqual(procedure, "QuickTeam.usp_UpdateWorkSheetById")
self.assertTrue(write, "写操作必须标记 write,避免被误重试")
sent = {name: (value, dbtype) for name, value, dbtype in params}
self.assertEqual(len(sent), 17)
self.assertEqual(sent["Operation"][0], "new")
self.assertEqual(sent["DeleteFlag"][0], "0")
self.assertEqual(sent["SystemName"][0], "2026 Sprint 10")
self.assertEqual(sent["ProjectId"][0], "0f52")
self.assertEqual(sent["CustomerLeader"][0], "杜衡")
self.assertEqual(sent["WorkSheetId"], (123, "string")) # 类型转换在 transport 层做
self.assertEqual(sent["Status"][0], "00", "新建工单从「等待技术责任人确认」开始")
def test_zero_result_but_state_applied_is_success(self) -> None:
"""实测:UpdateWorkSheetById 的 Result 恒为 0,但数据已落库,不能因此判失败。"""
fake = FakeTransport()
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 0}]
fake.table["QuickTeam.usp_GetWorkSheetById"] = [
{"WorkSheetId": 1, "SystemName": "2026 Sprint 10", "Status": "00", "CurrentVersionId": 5}
]
api = QuickTeamApi(fake)
self.assertEqual(
api.fill_worksheet(
1, PROJECT, system_name="2026 Sprint 10", start_time="a", end_time="b", evaluator="e",
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
),
0,
)
def test_zero_result_and_state_missing_raises(self) -> None:
fake = FakeTransport()
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 0}]
fake.table["QuickTeam.usp_GetWorkSheetById"] = [{"WorkSheetId": 1, "SystemName": "", "Status": ""}]
api = QuickTeamApi(fake)
with self.assertRaises(QtError):
api.fill_worksheet(
1, PROJECT, system_name="2026 Sprint 10", start_time="a", end_time="b", evaluator="e",
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
)
class ImportExcelTest(unittest.TestCase):
def test_success(self) -> None:
fake = FakeTransport()
api = QuickTeamApi(fake)
api.import_excel(9, 260113, "E:\\tmp\\x.xlsx")
_, procedure, params, path = fake.calls[0]
self.assertEqual(procedure, "QuickTeam.usp_BatchImportFeatureFromExcel")
self.assertEqual(params, [("WorkSheetId", 9, "int"), ("VersionId", 260113, "int")])
self.assertEqual(path, "E:\\tmp\\x.xlsx")
def test_column1_nonzero_raises_excel(self) -> None:
fake = FakeTransport()
fake.import_result = [{"Column1": 3}]
api = QuickTeamApi(fake)
with self.assertRaises(QtError) as ctx:
api.import_excel(9, 1, "p")
self.assertEqual(ctx.exception.category, Category.EXCEL)
def test_unexpected_payload(self) -> None:
fake = FakeTransport()
fake.import_result = None
api = QuickTeamApi(fake)
with self.assertRaises(QtError):
api.import_excel(9, 1, "p")
class WorkSheetListTest(unittest.TestCase):
"""项目工单列表:一次查全(该 SP 一行一个版本,工单本体行 VersionId 为空)。"""
ROWS = [
{"WorkSheetId": 501, "WorkSheetCode": "W_501", "SystemName": "2026 Sprint 10",
"Status": "01", "CurrentVersionId": 260220, "VersionId": "", "VersionCode": "",
"StartTime": "/Date(1791388800000)/"},
{"WorkSheetId": 501, "WorkSheetCode": "技术责任人确认版", "SystemName": "2026 Sprint 10",
"Status": "01", "CurrentVersionId": 260220, "VersionId": 260220,
"VersionCode": "技术责任人确认版"},
{"WorkSheetId": 502, "WorkSheetCode": "客户责任人确认版", "SystemName": "2026 Sprint 9",
"Status": "01", "CurrentVersionId": 260180, "VersionId": 260180,
"VersionCode": "客户责任人确认版"},
]
def test_dedupes_by_worksheet_and_prefers_base_row(self) -> None:
fake = FakeTransport()
fake.table["QuickTeam.usp_GetWorkSheetsById"] = self.ROWS
api = QuickTeamApi(fake)
sheets = api.work_sheets("0f52")
self.assertEqual([s.worksheet_id for s in sheets], [501, 502])
first = sheets[0]
self.assertEqual(first.code, "W_501", "版本行把 WorkSheetCode 覆盖成了 VersionCode,必须取本体行")
self.assertEqual(first.system_name, "2026 Sprint 10")
self.assertEqual(first.status, "01")
self.assertEqual(first.version_id, 260220)
self.assertEqual(first.start_time, "2026-10-08")
self.assertEqual(sheets[1].system_name, "2026 Sprint 9")
class WorksheetVersionTest(unittest.TestCase):
"""版本行来自 usp_GetWorkSheetsById(列表页那个 SP,一行一个版本)。"""
ROWS = [
# union 出来的「无版本」占位行,VersionId 是空串
{"WorkSheetId": 501, "VersionId": "", "VersionCode": "", "VersionStatus": "", "NextOperator": ""},
{"WorkSheetId": 501, "VersionId": 260299, "VersionCode": "客户责任人确认版",
"VersionStatus": "Y", "NextOperator": "CustomerLeader"},
{"WorkSheetId": 501, "VersionId": 260300, "VersionCode": "技术责任人确认版",
"VersionStatus": "N", "NextOperator": "TechnicalLeader"},
{"WorkSheetId": 502, "VersionId": 260400, "VersionCode": "技术责任人确认版",
"VersionStatus": "N", "NextOperator": "TechnicalLeader"},
]
def _api(self) -> tuple[QuickTeamApi, FakeTransport]:
fake = FakeTransport()
fake.table["QuickTeam.usp_GetWorkSheetsById"] = self.ROWS
return QuickTeamApi(fake), fake
def test_filters_placeholder_and_other_worksheets(self) -> None:
api, fake = self._api()
versions = api.worksheet_versions("0f52", 501)
self.assertEqual([v.version_id for v in versions], [260299, 260300])
_, connection, procedure, params, write = fake.calls[0]
self.assertEqual(procedure, "QuickTeam.usp_GetWorkSheetsById")
self.assertFalse(write)
def test_pending_version_prefers_n(self) -> None:
"""CurrentVersionId 是 NULL,导入必须落到『待确认』那一版,否则会撞 FK。"""
api, _ = self._api()
pending = api.pending_version("0f52", 501)
self.assertIsNotNone(pending)
self.assertEqual(pending.version_id, 260300)
self.assertEqual(pending.version_code, "技术责任人确认版")
def test_pending_falls_back_to_latest_when_all_confirmed(self) -> None:
fake = FakeTransport()
fake.table["QuickTeam.usp_GetWorkSheetsById"] = [
{"WorkSheetId": 501, "VersionId": 1, "VersionCode": "技术责任人确认版", "VersionStatus": "Y"},
{"WorkSheetId": 501, "VersionId": 2, "VersionCode": "甲方确认版", "VersionStatus": "Y"},
]
api = QuickTeamApi(fake)
self.assertEqual(api.pending_version("0f52", 501).version_id, 2)
def test_no_version_returns_none(self) -> None:
api = QuickTeamApi(FakeTransport())
self.assertIsNone(api.pending_version("0f52", 501))
class VerifyTest(unittest.TestCase):
def test_success_zero(self) -> None:
fake = FakeTransport()
fake.result = 0
api = QuickTeamApi(fake)
api.verify_worksheet(1, 2, next_operator="CustomerLeader", next_version_code="客户责任人确认版",
operator="陈首林", percent=20)
_, connection, procedure, params = fake.calls[0]
self.assertEqual(procedure, "QuickTeam.usp_VerifyWorkSheetById")
sent = {name: value for name, value, _ in params}
self.assertEqual(sent["NextOperator"], "CustomerLeader")
self.assertEqual(sent["Percent"], 20)
def test_nonzero_raises(self) -> None:
fake = FakeTransport()
fake.result = 1
api = QuickTeamApi(fake)
with self.assertRaises(QtError):
api.verify_worksheet(1, 2, next_operator="x", next_version_code="y", operator="o", percent=20)
class TfsTest(unittest.TestCase):
TEAMS = [
TfsTeam("DataTechnologies", "Tools_Dev\\DataTechnologies",
[{"Name": "2026 Sprint 10", "Path": "Tools_Dev\\2026 Sprint\\2026 Sprint 10"}]),
TfsTeam("FundHub", "Tools_Dev\\FundHub",
[{"Name": "2026 Sprint 06", "Path": "Tools_Dev\\FundHub\\2026 Sprint 06"}]),
]
def test_resolve_exact(self) -> None:
area, iteration = QuickTeamApi.resolve_tfs_context(self.TEAMS, "DataTechnologies", "2026 Sprint 10")
self.assertEqual(area, "Tools_Dev\\DataTechnologies")
self.assertEqual(iteration, "Tools_Dev\\2026 Sprint\\2026 Sprint 10")
def test_resolve_leading_zero(self) -> None:
_, iteration = QuickTeamApi.resolve_tfs_context(self.TEAMS, "FundHub", "2026 Sprint 6")
self.assertTrue(iteration.endswith("2026 Sprint 06"))
def test_missing_team(self) -> None:
with self.assertRaises(QtError):
QuickTeamApi.resolve_tfs_context(self.TEAMS, "Nope", "2026 Sprint 10")
def test_missing_iteration(self) -> None:
with self.assertRaises(QtError) as ctx:
QuickTeamApi.resolve_tfs_context(self.TEAMS, "DataTechnologies", "2026 Sprint 99")
self.assertEqual(ctx.exception.category, Category.TFS)
def test_push_payload(self) -> None:
fake = FakeTransport()
api = QuickTeamApi(fake)
api.push_tfs([11, 22], "Tools_Dev", "Tools_Dev\\DataTechnologies", "Tools_Dev\\2026 Sprint\\2026 Sprint 10")
_, operation, payload = fake.calls[0]
self.assertEqual(operation, "BatchImportWorkItemsToTfs")
inner = json.loads(urllib.parse.unquote(payload["param"]))
self.assertEqual(inner["SPName"], "[QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds]")
self.assertEqual(inner["SQLParams"][0]["Value"], "11,22")
self.assertEqual(payload["areaPath"], "Tools_Dev\\DataTechnologies")
def test_push_without_features(self) -> None:
with self.assertRaises(QtError):
QuickTeamApi(FakeTransport()).push_tfs([], "p", "a", "i")
def test_push_returns_status(self) -> None:
fake = FakeTransport()
fake.tfs_result = 0
self.assertEqual(QuickTeamApi(fake).push_tfs([1, 2], "p", "a", "i"), 0)
def test_push_negative_status_is_failure(self) -> None:
"""返回值是状态码:0 = 成功,-1 = 失败(实测:项目名不存在时返回 -1)。"""
fake = FakeTransport()
fake.tfs_result = -1
with self.assertRaises(QtError) as ctx:
QuickTeamApi(fake).push_tfs([1], "p", "a", "i")
self.assertEqual(ctx.exception.category, Category.TFS)
class DryRunTest(unittest.TestCase):
def test_no_server_calls(self) -> None:
fake = FakeTransport()
api = QuickTeamApi(fake, dry_run=True)
self.assertEqual(api.create_worksheet(), 0)
api.fill_worksheet(
0, PROJECT, system_name="s", start_time="a", end_time="b", evaluator="e",
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
)
api.import_excel(0, 0, "p")
api.verify_worksheet(0, 0, next_operator="x", next_version_code="y", operator="o", percent=20)
api.push_tfs([1], "p", "a", "i")
self.assertEqual(fake.calls, [], "dry-run 不允许发出任何服务端调用")
if __name__ == "__main__":
unittest.main()
+140
View File
@@ -0,0 +1,140 @@
"""domain 层规则测试:项目匹配、动作判定、版本推进、Excel 预检。"""
from __future__ import annotations
import os
import tempfile
import unittest
import zipfile
from qtimport.domain import Action, decide, match_project, next_transition, precheck_xlsx
from qtimport.models import Project, WorkSheet, normalize_iteration, normalize_name
def ws(worksheet_id: int = 1, status: str = "00", code: str = "W_1") -> WorkSheet:
return WorkSheet(worksheet_id=worksheet_id, code=code, status=status)
class NormalizeTest(unittest.TestCase):
def test_space_and_dash(self) -> None:
self.assertEqual(normalize_name("资管 - 华泰资管"), normalize_name("资管-华泰资管"))
self.assertEqual(normalize_name("信托 - 上海信托"), normalize_name("信托-上海信托"))
def test_iteration_leading_zero(self) -> None:
self.assertEqual(normalize_iteration("2026 Sprint 06"), normalize_iteration("2026 Sprint 6"))
self.assertNotEqual(normalize_iteration("2026 Sprint 10"), normalize_iteration("2026 Sprint 1"))
class MatchProjectTest(unittest.TestCase):
def setUp(self) -> None:
self.projects = [
Project("p1", "资管 - 华泰资管"),
Project("p2", "资管 - 华泰保险资管"),
Project("p3", "信托 - 上海信托"),
]
def test_exact_near_names_do_not_collide(self) -> None:
"""Selenium 用 contains,会把「华泰资管」误命中「华泰保险资管」;这里必须精确。"""
project, reason = match_project("资管 - 华泰资管", self.projects)
self.assertEqual(project.project_id, "p1")
self.assertEqual(reason, "")
def test_missing_gives_hint(self) -> None:
project, reason = match_project("资管 - 华泰", self.projects)
self.assertIsNone(project)
self.assertIn("相近项目", reason)
def test_ambiguous(self) -> None:
projects = [Project("a", "X - Y"), Project("b", "X-Y")]
project, reason = match_project("X - Y", projects)
self.assertIsNone(project)
self.assertIn("歧义", reason)
class DecideTest(unittest.TestCase):
def test_create_when_absent(self) -> None:
action, _, _ = decide([], ledger_stage=None, adopt=False)
self.assertEqual(action, Action.CREATE)
def test_skip_when_done(self) -> None:
action, _, _ = decide([ws(status="03")], ledger_stage=None, adopt=False)
self.assertEqual(action, Action.SKIP_DONE)
def test_skip_pushed(self) -> None:
action, _, _ = decide([ws(status="01")], ledger_stage="TFS_PUSHED", adopt=False)
self.assertEqual(action, Action.SKIP_PUSHED)
def test_resume_from_ledger(self) -> None:
action, _, sheet = decide([ws()], ledger_stage="WS_FILLED", adopt=False)
self.assertEqual(action, Action.RESUME)
self.assertEqual(sheet.worksheet_id, 1)
def test_existing_without_ledger_skips(self) -> None:
action, reason, _ = decide([ws()], ledger_stage=None, adopt=False)
self.assertEqual(action, Action.SKIP_EXISTS)
self.assertIn("未接管", reason)
def test_adopt_takes_over(self) -> None:
action, _, _ = decide([ws()], ledger_stage=None, adopt=True)
self.assertEqual(action, Action.RESUME)
def test_writes_flag(self) -> None:
self.assertTrue(Action.CREATE.writes)
self.assertTrue(Action.RESUME.writes)
for action in (Action.SKIP_DONE, Action.SKIP_EXISTS, Action.SKIP_PUSHED, Action.ERROR):
self.assertFalse(action.writes)
class VersionTest(unittest.TestCase):
def test_first_step(self) -> None:
self.assertEqual(next_transition("技术责任人确认版"), ("CustomerLeader", "客户责任人确认版", 20))
def test_draft_like_first_step(self) -> None:
self.assertEqual(next_transition(""), ("CustomerLeader", "客户责任人确认版", 20))
def test_second_step(self) -> None:
self.assertEqual(next_transition("客户责任人确认版")[1], "甲方确认版")
def test_terminal_raises(self) -> None:
with self.assertRaises(ValueError):
next_transition("甲方确认版")
def test_unknown_raises(self) -> None:
with self.assertRaises(ValueError):
next_transition("甲方确认版-v2")
class PrecheckTest(unittest.TestCase):
def _make_xlsx(self, path: str, strings: list[str]) -> None:
shared = "<sst>" + "".join(f"<si><t>{text}</t></si>" for text in strings) + "</sst>"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("xl/workbook.xml", "<workbook/>")
archive.writestr("xl/sharedStrings.xml", shared)
def test_valid_template(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "a.xlsx")
self._make_xlsx(path, ["工作量评估表", "服务模块", "服务内容"])
ok, why = precheck_xlsx(path)
self.assertTrue(ok, why)
def test_missing_title(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "b.xlsx")
self._make_xlsx(path, ["随便一个表", "服务模块"])
ok, why = precheck_xlsx(path)
self.assertFalse(ok)
self.assertIn("工作量评估表", why)
def test_not_zip(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "c.xlsx")
with open(path, "w", encoding="utf-8") as handle:
handle.write("not a zip")
ok, why = precheck_xlsx(path)
self.assertFalse(ok)
self.assertIn("xlsx", why)
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
"""台账测试:dry-run 隔离、跨 run 续跑、字段合并。"""
from __future__ import annotations
import os
import tempfile
import unittest
from qtimport.store import Store
class StoreTest(unittest.TestCase):
def setUp(self) -> None:
self.folder = tempfile.TemporaryDirectory()
self.store = Store(os.path.join(self.folder.name, "t.db"))
def tearDown(self) -> None:
self.store.close()
self.folder.cleanup()
def _job(self, run_id: str, stage: str, worksheet_id: int | None = 1) -> None:
self.store.upsert_job(
run_id,
project_id="p1",
project_name="P",
system_name="2026 Sprint 10",
source_file="f.xlsx",
action="create",
stage=stage,
worksheet_id=worksheet_id,
)
def test_latest_stage_across_runs(self) -> None:
self.store.start_run("r1", "s", "d", dry_run=False)
self._job("r1", "WS_CREATED")
self.store.start_run("r2", "s", "d", dry_run=False)
self._job("r2", "WS_FILLED")
self.assertEqual(self.store.latest_stage("p1", "2026 Sprint 10"), "WS_FILLED")
def test_dry_run_does_not_pollute_ledger(self) -> None:
self.store.start_run("real", "s", "d", dry_run=False)
self._job("real", "WS_FILLED")
self.store.start_run("dry", "s", "d", dry_run=True)
self._job("dry", "TFS_PUSHED")
self.assertEqual(
self.store.latest_stage("p1", "2026 Sprint 10"),
"WS_FILLED",
"dry-run 不能把项目标成已推送",
)
def test_worksheet_id_survives_partial_update(self) -> None:
self.store.start_run("r1", "s", "d", dry_run=False)
self._job("r1", "WS_CREATED", worksheet_id=777)
self._job("r1", "WS_FILLED", worksheet_id=None)
job = self.store.latest_job("p1", "2026 Sprint 10")
self.assertEqual(job["worksheet_id"], 777)
self.assertEqual(job["stage"], "WS_FILLED")
def test_skipped_row_does_not_mask_progress(self) -> None:
"""跳过行不带进度:不能把「已推送」覆盖成「SKIPPED」,否则下次会重推 TFS。"""
self.store.start_run("r1", "s", "d", dry_run=False)
self._job("r1", "TFS_PUSHED")
self.store.start_run("r2", "s", "d", dry_run=False)
self._job("r2", "SKIPPED")
job = self.store.latest_job("p1", "2026 Sprint 10")
self.assertEqual(job["stage"], "TFS_PUSHED")
def test_events_ordered(self) -> None:
self.store.start_run("r1", "s", "d", dry_run=False)
self.store.add_event("r1", stage="A", action="ok", detail="1")
self.store.add_event("r1", stage="B", action="ok", detail="2")
self.assertEqual([e["stage"] for e in self.store.events("r1")], ["A", "B"])
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
"""传输层与错误解析的单元测试(不联网)。"""
from __future__ import annotations
import unittest
from qtimport.errors import Category, QtError, extract_exception_message
from qtimport.transport import Transport
WCF_ERROR_PAGE = """\ufeff<?xml version="1.0" encoding="utf-8"?>
<html><body><div id="content">
<p class="heading1">Request Error</p>
<p>The server encountered an error processing the request. The exception message is
'Could not find stored procedure 'QuickTeam.usp_Nope'.'. See server logs for more details.</p>
</div></body></html>"""
class DecodeTest(unittest.TestCase):
def test_plain_json(self) -> None:
self.assertEqual(Transport.decode('[{"A":1}]'), [{"A": 1}])
def test_double_encoded(self) -> None:
raw = '"[{\\"WorkSheetId\\": 225072}]"'
self.assertEqual(Transport.decode(raw)[0]["WorkSheetId"], 225072)
def test_triple_encoded(self) -> None:
raw = '"\\"[{\\\\\\"A\\\\\\": 1}]\\""'
self.assertEqual(Transport.decode(raw), [{"A": 1}])
def test_scalar_string(self) -> None:
self.assertEqual(Transport.decode('"0"'), 0)
def test_not_json(self) -> None:
self.assertEqual(Transport.decode("<html/>"), "<html/>")
class ErrorTest(unittest.TestCase):
def test_extract_message(self) -> None:
message = extract_exception_message(WCF_ERROR_PAGE)
self.assertIn("Could not find stored procedure", message)
def test_missing_procedure_not_retryable(self) -> None:
error = QtError.from_http(400, WCF_ERROR_PAGE)
self.assertEqual(error.category, Category.SP_MISSING)
self.assertFalse(error.retryable)
def test_param_error(self) -> None:
body = "<p>The exception message is 'Procedure or function usp_X has too many arguments "
body += "specified.'. See server logs</p>"
self.assertEqual(QtError.from_http(400, body).category, Category.PARAM)
def test_server_error_retryable(self) -> None:
error = QtError.from_http(503, "<p>Service Unavailable</p>")
self.assertTrue(error.retryable)
def test_html_unescaped(self) -> None:
body = "<p>The exception message is 'Parameter &#39;UserId&#39; is missing.'. See server logs</p>"
self.assertIn("UserId", extract_exception_message(body))
if __name__ == "__main__":
unittest.main()
+211
View File
@@ -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()
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""QuickTeam 接口探针(只读)。
用途:在不动任何业务数据的前提下,验证「通过接口驱动 QuickTeam」的可行性,
并输出一份「本 Sprint 各项目工单现状」清单(plan 阶段的雏形)。
只调用以下只读接口:
usp_Authentication / usp_GetTeamsByUserId / usp_GetProjectsByTeamId
usp_GetWeeklyWorkSheetsByProjectId / usp_GetWorkSheetById / usp_GetProjectUsersByProjectId
可选(--upload-test)额外验证文件上传通道,会在服务器 WorkSheetImport 目录留一个临时文件,
不触发任何解析或写库动作。
用法:
python tools/api_probe.py --config config/config.json
python tools/api_probe.py --config config/config.json --sprint "2026 Sprint 10" --dir "D:/Work/QuickTeam/2026 Sprint 10"
python tools/api_probe.py --config config/config.json --upload-test
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------------------
# 传输层:WCF + 通用存储过程网关
# ---------------------------------------------------------------------------
class QtError(RuntimeError):
"""接口/业务错误。"""
@dataclass
class QtClient:
base_url: str
timeout: int = 60
retries: int = 2
# WCF 服务路径(来自前端 app.js 的 a078 配置模块)
common_url: str = "/Services/CommonService.svc/"
file_url: str = "/Services/FileOperationService.svc/"
# -- 底层 ---------------------------------------------------------------
def _post(self, path: str, payload: Any = None) -> str:
url = self.base_url.rstrip("/") + path
data = b"" if payload is None else json.dumps(payload).encode("utf-8")
last: Exception | None = None
for attempt in range(self.retries + 1):
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return resp.read().decode("utf-8-sig", "replace")
except urllib.error.HTTPError as exc:
raise QtError(self._extract_error(exc)) from None
except (urllib.error.URLError, TimeoutError) as exc: # 网络层可重试
last = exc
if attempt < self.retries:
time.sleep(1.5 * (attempt + 1))
continue
raise QtError(f"网络不可达:{exc}") from None
raise QtError(str(last))
@staticmethod
def _extract_error(exc: urllib.error.HTTPError) -> str:
body = exc.read().decode("utf-8-sig", "replace")
# WCF 返回 HTML 错误页,真正的错误在 "The exception message is '...'"
match = re.search(r"exception message is '(.*?)'.?\s*See server logs", body, re.S)
if match:
return f"HTTP {exc.code}: {match.group(1).strip()}"
plain = re.sub(r"<[^>]+>", " ", body)
plain = re.sub(r"\s+", " ", plain).strip()
return f"HTTP {exc.code}: {plain[-300:]}"
@staticmethod
def _decode(raw: str) -> Any:
"""WCF 返回值常是「JSON 字符串里再套一层 JSON」,最多解三层。"""
value: Any = raw.strip()
for _ in range(3):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
break
return value
def _exec(self, conn: str, sp: str, params: list[tuple[str, Any, str]]) -> Any:
"""调用存储过程。params: [(Name, Value, DBType), ...]"""
inner = {
"SPName": sp,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": conn,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
return self._decode(self._post(self.common_url + "ExecTable", payload))
def _exec_result(self, conn: str, sp: str, params: list[tuple[str, Any, str]]) -> Any:
"""同 _exec,但走 ExecResult(标量/返回值语义,如 Result、Column1)。"""
inner = {
"SPName": sp,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": conn,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
return self._decode(self._post(self.common_url + "ExecResult", payload))
# -- 业务语义 -----------------------------------------------------------
def login(self, username: str, password: str) -> dict:
digest = hashlib.md5(password.encode("utf-8")).hexdigest()
rows = self._exec(
"UserCenter",
"dbo.usp_Authentication",
[("UserName", username, "string"), ("Password", digest, "string")],
)
if not rows:
raise QtError("登录失败:账号或密码不匹配")
return rows[0]
def teams(self, user_id: str) -> list[dict]:
return self._exec("QuickTeam", "QuickTeam.usp_GetTeamsByUserId", [("UserId", user_id, "string")]) or []
def projects(self, team_id: str) -> list[dict]:
return self._exec(
"QuickTeam",
"QuickTeam.usp_GetProjectsByTeamId",
[("TeamId", team_id, "string"), ("IsTemplate", "1", "string")],
) or []
def worksheets(self, project_id: str, start: str, end: str) -> list[dict]:
return self._exec(
"QuickTeam",
"QuickTeam.usp_GetWeeklyWorkSheetsByProjectId",
[("ProjectId", project_id, "string"), ("Monday", start, "string"), ("Sunday", end, "string")],
) or []
def worksheet(self, worksheet_id: int) -> dict | None:
rows = self._exec("QuickTeam", "QuickTeam.usp_GetWorkSheetById", [("WorkSheetId", worksheet_id, "int")])
return rows[0] if rows else None
def project_users(self, project_id: str) -> list[dict]:
return self._exec(
"QuickTeam", "QuickTeam.usp_GetProjectUsersByProjectId", [("ProjectId", project_id, "string")]
) or []
# -- 文件通道 -----------------------------------------------------------
def upload(self, local_path: str, folder: str = "WorkSheetImport") -> str:
"""上传文件,返回服务器端绝对路径(BatchImportFeature 的 filePath)。"""
name = f"{int(time.time() * 1000)}{os.path.splitext(local_path)[1]}"
url = (
f"{self.base_url.rstrip('/')}{self.file_url}FileUpload"
f"?fileName={urllib.parse.quote(name)}&fileFolder={urllib.parse.quote(folder)}"
)
boundary = "----qtprobe" + hashlib.md5(name.encode()).hexdigest()
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
).encode("utf-8")
body += open(local_path, "rb").read()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
req = urllib.request.Request(
url, data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
data = self._decode(resp.read().decode("utf-8-sig", "replace"))
except urllib.error.HTTPError as exc:
raise QtError(self._extract_error(exc)) from None
path = (data or {}).get("FileUploadResult")
if not path:
raise QtError(f"上传未返回 FileUploadResult:{data!r}")
return path
# ---------------------------------------------------------------------------
# 报表
# ---------------------------------------------------------------------------
def ms_to_date(value: Any) -> str:
"""QuickTeam 日期为 /Date(1785081600000)/ 形式。"""
if not value:
return ""
match = re.search(r"\((\d+)", str(value))
if not match:
return str(value)
return time.strftime("%Y-%m-%d", time.localtime(int(match.group(1)) / 1000))
def list_xlsx(folder: str) -> list[str]:
if not os.path.isdir(folder):
raise SystemExit(f"目录不存在:{folder}")
names = []
for entry in sorted(os.listdir(folder)):
path = os.path.join(folder, entry)
if os.path.isfile(path) and entry.lower().endswith(".xlsx") and not entry.startswith("~$"):
names.append(path)
return names
def normalize(name: str) -> str:
return re.sub(r"\s+", "", name).replace("-", "-").replace("—", "-")
@dataclass
class Row:
project: str
file: str
project_id: str = ""
existing: int = 0
has_sprint: bool = False
worksheet_ids: list[int] = field(default_factory=list)
note: str = ""
def main() -> int:
parser = argparse.ArgumentParser(description="QuickTeam 接口探针(只读)")
parser.add_argument("--config", default="config/config.json", help="配置文件路径")
parser.add_argument("--sprint", help="覆盖配置中的 sprint 名称(SystemName)")
parser.add_argument("--dir", dest="folder", help="覆盖配置中的 xlsx 目录")
parser.add_argument("--team", help="覆盖配置中的团队名称")
parser.add_argument("--upload-test", action="store_true", help="额外验证上传通道(会留一个临时文件)")
parser.add_argument("--json-out", help="把结果另存为 JSON")
args = parser.parse_args()
if not os.path.exists(args.config):
raise SystemExit(f"缺少配置文件:{args.config}(可从 config/config.example.json 复制)")
with open(args.config, encoding="utf-8-sig") as handle:
cfg = json.load(handle)
sprint = args.sprint or cfg["sprint"]
folder = args.folder or cfg["work_dir"]
team_name = args.team or cfg.get("team_name", "当前项目")
start, end = cfg.get("start_time", ""), cfg.get("end_time", "")
if not start or not end:
today = time.strftime("%Y-%m-%d")
start, end = "2020-01-01", today
client = QtClient(cfg["base_url"], timeout=int(cfg.get("timeout", 60)))
t0 = time.time()
user = client.login(cfg["username"], cfg["password"])
t_login = time.time() - t0
print(f"[登录] {user['RealName']} <{user['UserName']}> UserId={user['GuidId']} {t_login:.2f}s")
t1 = time.time()
teams = client.teams(user["GuidId"])
team = next((x for x in teams if x.get("TeamName") == team_name), None)
if not team:
raise SystemExit(f"未找到团队「{team_name}」,可选:{[x.get('TeamName') for x in teams]}")
t_teams = time.time() - t1
t2 = time.time()
projects = client.projects(team["TeamId"])
t_projects = time.time() - t2
print(f"[团队] {team['TeamName']} ({team['TeamId']}) {t_teams:.2f}s")
print(f"[项目] 共 {len(projects)} 个 {t_projects:.2f}s")
by_name = {normalize(p["ProjectName"]): p for p in projects}
rows: list[Row] = []
t3 = time.time()
for path in list_xlsx(folder):
stem = os.path.splitext(os.path.basename(path))[0]
row = Row(project=stem, file=os.path.basename(path))
hit = by_name.get(normalize(stem))
if not hit:
row.note = "项目名在 QuickTeam 中不存在"
rows.append(row)
continue
row.project_id = hit["ProjectId"]
sheets = client.worksheets(row.project_id, start, end)
row.existing = len(sheets)
for sheet in sheets:
detail = client.worksheet(sheet["WorkSheetId"]) or {}
row.worksheet_ids.append(sheet["WorkSheetId"])
if detail.get("SystemName") == sprint:
row.has_sprint = True
rows.append(row)
t_scan = time.time() - t3
width = max([len(r.project) for r in rows] + [8]) + 2
print()
print(f"{'项目'.ljust(width)} {'工单数':>5} {'本Sprint':>8} 状态")
print("-" * (width + 34))
for row in rows:
if row.note:
state = f"!! {row.note}"
elif row.has_sprint:
state = "已存在(应跳过或续跑)"
else:
state = "待创建"
print(f"{row.project.ljust(width)} {row.existing:>5} {'是' if row.has_sprint else '否':>8} {state}")
todo = sum(1 for r in rows if not r.note and not r.has_sprint)
missing = sum(1 for r in rows if r.note)
print("-" * (width + 34))
print(f"合计 {len(rows)} 个文件:待创建 {todo},已存在 {len(rows) - todo - missing},异常 {missing}")
print(f"[耗时] 扫描 {t_scan:.2f}s 平均 {t_scan / max(len(rows), 1):.2f}s/项目")
if args.upload_test:
sample = next((p for p in list_xlsx(folder)), None)
if sample:
t4 = time.time()
remote = client.upload(sample)
print(f"\n[上传] {os.path.basename(sample)} -> {remote} {time.time() - t4:.2f}s(已上传但不解析)")
if args.json_out:
os.makedirs(os.path.dirname(os.path.abspath(args.json_out)), exist_ok=True)
with open(args.json_out, "w", encoding="utf-8") as handle:
json.dump(
{
"sprint": sprint,
"team": team["TeamName"],
"team_id": team["TeamId"],
"user": {"RealName": user["RealName"], "GuidId": user["GuidId"]},
"rows": [row.__dict__ for row in rows],
"timing": {
"login": round(t_login, 3),
"teams": round(t_teams, 3),
"projects": round(t_projects, 3),
"scan": round(t_scan, 3),
},
},
handle,
ensure_ascii=False,
indent=2,
)
print(f"\n结果已写入 {args.json_out}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except QtError as error:
print(f"[ERROR] {error}", file=sys.stderr)
sys.exit(2)
+109
View File
@@ -0,0 +1,109 @@
"""诊断工具:定位 BatchImportFeature 空引用异常。
只操作一张临时草稿工单,结束时删除,不影响业务数据。
用法:
python tools/diag_import.py # 建单→填单→读字段→试导入→删单
python tools/diag_import.py --keep # 保留草稿(便于在页面上比对)
"""
from __future__ import annotations
import argparse
import json
import sys
sys.path.insert(0, ".")
from qtimport.api import CONN_QUICKTEAM, SP_IMPORT_EXCEL, SP_WORKSHEET, QuickTeamApi # noqa: E402
from qtimport.config import Config # noqa: E402
from qtimport.transport import Transport # noqa: E402
def dump(title: str, rows) -> dict:
print(f"\n=== {title} ===")
if isinstance(rows, list) and rows:
print(json.dumps(rows[0], ensure_ascii=False, indent=2))
return rows[0]
print(json.dumps(rows, ensure_ascii=False, indent=2))
return {}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="config/config.json")
parser.add_argument("--project", default="资管 - 华泰保险资管")
parser.add_argument("--excel", default=r"D:\Work\QuickTeam\2026 Sprint 10\资管 - 华泰保险资管.xlsx")
parser.add_argument("--keep", action="store_true")
args = parser.parse_args()
cfg = Config.load(args.config)
t = Transport(cfg.base_url, timeout=cfg.timeout, retries=0)
api = QuickTeamApi(t)
user = api.login(cfg.username, cfg.password)
team = api.resolve_team(api.teams(user.user_id), cfg.team_name)
project = next(p for p in api.projects(team.team_id) if p.project_name == args.project)
print(f"登录:{user.real_name};项目:{project.project_name}")
worksheet_id = api.create_worksheet()
print(f"草稿工单:{worksheet_id}")
try:
dump("建单后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
[("WorkSheetId", worksheet_id, "int")], op="raw"))
evaluator, technical, customer = cfg.resolved_roles(user.real_name)
print(f"角色:评估={evaluator} 技术={technical} 客户={customer}")
result = api.fill_worksheet(
worksheet_id, project,
system_name=cfg.sprint, start_time=cfg.start_time, end_time=cfg.end_time,
evaluator=evaluator, technical_leader=technical, customer_leader=customer,
belong_team=cfg.belong_team, content=cfg.content_desc,
operator=user.real_name, service_type=cfg.service_type,
)
print(f"填单 Result={result}")
after = dump("填单后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
[("WorkSheetId", worksheet_id, "int")], op="raw"))
print(f"CurrentVersionId={after.get('CurrentVersionId')!r} VersionCode={after.get('VersionCode')!r}")
remote = api.upload_excel(args.excel)
print(f"\n上传成功:{remote}")
# 逐个尝试参数组合,看哪种不报空引用
variants = [
("VersionId=0", [("WorkSheetId", worksheet_id, "int"), ("VersionId", 0, "int")]),
("VersionId=-1", [("WorkSheetId", worksheet_id, "int"), ("VersionId", -1, "int")]),
("VersionId 省略", [("WorkSheetId", worksheet_id, "int")]),
("VersionId=空串", [("WorkSheetId", worksheet_id, "int"), ("VersionId", "", "string")]),
]
for label, params in variants:
print(f"\n--- 尝试 {label} ---")
try:
out = t.batch_import(SP_IMPORT_EXCEL, params, remote)
print("返回:", json.dumps(out, ensure_ascii=False))
if isinstance(out, list) and out and isinstance(out[0], dict) and int(out[0].get("Column1", -1)) == 0:
print(f"✔ {label} 成功")
break
except Exception as error: # noqa: BLE001
print(f"✘ {type(error).__name__}: {error}")
dump("导入后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
[("WorkSheetId", worksheet_id, "int")], op="raw"))
feats = api.features(worksheet_id)
print(f"功能模块数量:{len(feats)}")
for f in feats[:3]:
print(" -", f.feature_id, f.code, f.name)
finally:
if args.keep:
print(f"\n草稿保留:{worksheet_id}(请手工核对后删除)")
else:
try:
api.delete_worksheet(worksheet_id)
print(f"\n已删除草稿 {worksheet_id}")
except Exception as error: # noqa: BLE001
print(f"\n删除失败:{error}(请手工删除 {worksheet_id})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+59
View File
@@ -0,0 +1,59 @@
"""只读工具:导出 QuickTeam 存储过程源码。
网关 `ExecTable` 可以直接执行 `sys.sp_helptext`,因此无需数据库账号即可读到
SP 定义 —— 排查参数语义、确认字段来源时比猜快得多。
用法:
python tools/sp_source.py usp_VerifyWorkSheetById
python tools/sp_source.py usp_InsertWorkSheet usp_UpdateWorkSheetById --out docs/sp
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, ".")
from qtimport.config import Config # noqa: E402
from qtimport.transport import Transport # noqa: E402
def fetch(transport: Transport, name: str) -> str:
rows = transport.exec_table("QuickTeam", "sys.sp_helptext", [("objname", name, "string")], op="helptext")
if not isinstance(rows, list) or not rows:
return ""
return "".join(str(row.get("Text", "")) for row in rows)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("names", nargs="+")
parser.add_argument("--config", default="config/config.json")
parser.add_argument("--out", default="")
args = parser.parse_args()
cfg = Config.load(args.config)
transport = Transport(cfg.base_url, timeout=60, retries=1)
if args.out:
os.makedirs(args.out, exist_ok=True)
for name in args.names:
full = name if name.startswith("QuickTeam.") else f"QuickTeam.{name}"
text = fetch(transport, full)
if not text.strip():
print(f"[空] {full}(不存在或无权读取)")
continue
print(f"===== {full} =====")
print(text)
if args.out:
path = os.path.join(args.out, f"{name.split('.')[-1]}.sql")
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
print(f"→ 已保存 {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())