初始提交: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:
+24
@@ -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
|
||||
@@ -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` 中。
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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␍
|
||||
@@ -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␍
|
||||
␍
|
||||
␍
|
||||
@@ -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␍
|
||||
@@ -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␍
|
||||
@@ -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␍
|
||||
@@ -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␍
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""QuickTeam 工单接口导入工具。"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,206 @@
|
||||
"""CLI:plan / apply / report / status。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
|
||||
from . import __version__
|
||||
from .config import DEFAULT_CONFIG, Config
|
||||
from .errors import QtError
|
||||
from .report import RunLog, write_report
|
||||
from .store import Store
|
||||
from .workflow import Importer, RunOptions
|
||||
|
||||
|
||||
def new_run_id() -> str:
|
||||
return time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
|
||||
|
||||
|
||||
def build_importer(args) -> tuple[Importer, RunLog, Store, str]:
|
||||
cfg = Config.load(args.config)
|
||||
overrides = {"sprint": args.sprint, "work_dir": args.folder, "team_name": args.team}
|
||||
for key, value in overrides.items():
|
||||
if value:
|
||||
setattr(cfg, key, value)
|
||||
if not cfg.sprint:
|
||||
raise SystemExit("配置缺少 sprint(或传 --sprint)")
|
||||
if not cfg.work_dir:
|
||||
raise SystemExit("配置缺少 work_dir(或传 --dir)")
|
||||
if getattr(args, "workers", None):
|
||||
cfg.workers = args.workers
|
||||
|
||||
run_id = args.run_id or new_run_id()
|
||||
store = Store(cfg.db_path)
|
||||
log = RunLog(run_id, verbose=not args.quiet)
|
||||
store.start_run(run_id, cfg.sprint, cfg.work_dir, dry_run=args.dry_run)
|
||||
options = RunOptions(
|
||||
adopt=args.adopt,
|
||||
skip_tfs=args.skip_tfs,
|
||||
rollback_on_fail=args.rollback_on_fail,
|
||||
limit=args.limit,
|
||||
projects=args.project or [],
|
||||
force=args.force,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
importer = Importer(cfg, store, run_id, log, options)
|
||||
return importer, log, store, run_id
|
||||
|
||||
|
||||
def cmd_plan(args) -> int:
|
||||
importer, log, store, run_id = build_importer(args)
|
||||
try:
|
||||
rows = importer.plan()
|
||||
finally:
|
||||
pass
|
||||
|
||||
width = max([len(row.project_name) for row in rows] + [8]) + 2
|
||||
print(f"\nSprint:{importer.cfg.sprint} 目录:{importer.cfg.work_dir}")
|
||||
print(f"{'项目'.ljust(width)}{'动作':<12}说明")
|
||||
print("-" * (width + 60))
|
||||
for row in rows:
|
||||
print(f"{row.project_name.ljust(width)}{str(row.action):<12}{row.reason}")
|
||||
print("-" * (width + 60))
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
counts[str(row.action)] = counts.get(str(row.action), 0) + 1
|
||||
print("合计 " + ",".join(f"{k} {v}" for k, v in sorted(counts.items())))
|
||||
|
||||
if args.json:
|
||||
payload = [
|
||||
{
|
||||
"project": row.project_name,
|
||||
"file": row.file_path,
|
||||
"action": str(row.action),
|
||||
"reason": row.reason,
|
||||
"worksheet_id": row.worksheet.worksheet_id if row.worksheet else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
path = args.json if isinstance(args.json, str) else f"reports/{run_id}/plan.json"
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
print(f"计划已写入 {path}")
|
||||
|
||||
log.close()
|
||||
store.finish_run(run_id, "PLANNED")
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_apply(args) -> int:
|
||||
if not args.dry_run and not args.yes:
|
||||
print("这会真实写入 QuickTeam(建单/导入/确认/推送)。确认后加 --yes 重跑;"
|
||||
"只想看会做什么请用 --dry-run。", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
importer, log, store, run_id = build_importer(args)
|
||||
started = time.time()
|
||||
status = "DONE"
|
||||
try:
|
||||
results = importer.apply()
|
||||
failed = [r for r in results if r.stage.startswith("FAILED")]
|
||||
if failed:
|
||||
status = "PARTIAL"
|
||||
print(f"\n执行完成:{len(results)} 个项目,失败 {len(failed)},"
|
||||
f"耗时 {time.time() - started:.1f}s")
|
||||
except QtError as error:
|
||||
print(f"[中止] {error}", file=sys.stderr)
|
||||
status = "ABORTED"
|
||||
return 2
|
||||
finally:
|
||||
log.close()
|
||||
store.finish_run(run_id, status)
|
||||
md, csv = write_report(run_id, store)
|
||||
print(f"报告:{md} / {csv}")
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_report(args) -> int:
|
||||
cfg = Config.load(args.config, need_password=False)
|
||||
store = Store(cfg.db_path)
|
||||
run_id = args.run_id or store.last_run()
|
||||
if not run_id:
|
||||
print("没有可用的运行记录", file=sys.stderr)
|
||||
return 1
|
||||
md, csv = write_report(run_id, store)
|
||||
print(f"已生成:{md}\n {csv}")
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args) -> int:
|
||||
cfg = Config.load(args.config, need_password=False)
|
||||
store = Store(cfg.db_path)
|
||||
run_id = args.run_id or store.last_run()
|
||||
if not run_id:
|
||||
print("没有可用的运行记录", file=sys.stderr)
|
||||
return 1
|
||||
jobs = store.jobs(run_id)
|
||||
print(f"run={run_id} 项目 {len(jobs)} 个")
|
||||
for job in jobs:
|
||||
flag = "!" if job["error"] else " "
|
||||
print(f"{flag} {job['project_name']:<28} {job['action']:<12} {job['stage']:<20} "
|
||||
f"ws={job['worksheet_id'] or '-'}")
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="qtimport", description="QuickTeam 工单接口导入")
|
||||
parser.add_argument("--version", action="version", version=f"qtimport {__version__}")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
def common(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument("--config", default=DEFAULT_CONFIG, help="配置文件路径")
|
||||
p.add_argument("--sprint", help="覆盖配置中的 Sprint 名称")
|
||||
p.add_argument("--dir", dest="folder", help="覆盖配置中的 xlsx 目录")
|
||||
p.add_argument("--team", help="覆盖配置中的团队名称")
|
||||
p.add_argument("--run-id", help="复用已有 run_id(续跑)")
|
||||
p.add_argument("--adopt", action="store_true", help="接管无台账记录的既有工单")
|
||||
p.add_argument("--skip-tfs", action="store_true", help="不推送 TFS")
|
||||
p.add_argument("--limit", type=int, help="最多执行几个项目")
|
||||
p.add_argument("--project", action="append", help="只处理指定项目(可多次)")
|
||||
p.add_argument("--force", action="store_true", help="跳过 Excel 模板预检")
|
||||
p.add_argument("--dry-run", action="store_true", help="演练:不产生任何服务端写入")
|
||||
p.add_argument("--quiet", action="store_true", help="只输出结果")
|
||||
|
||||
p_plan = sub.add_parser("plan", help="只读预演:列出每个项目会做什么")
|
||||
common(p_plan)
|
||||
p_plan.add_argument("--json", nargs="?", const=True, help="把计划写成 JSON")
|
||||
p_plan.set_defaults(func=cmd_plan, rollback_on_fail=False)
|
||||
|
||||
p_apply = sub.add_parser("apply", help="执行导入")
|
||||
common(p_apply)
|
||||
p_apply.add_argument("--yes", action="store_true", help="确认真实写入")
|
||||
p_apply.add_argument("--rollback-on-fail", action="store_true", help="失败时删除本次新建的半成品工单")
|
||||
p_apply.add_argument("--workers", type=int, help="覆盖并发数(默认取配置)")
|
||||
p_apply.set_defaults(func=cmd_apply, json=False)
|
||||
|
||||
p_report = sub.add_parser("report", help="生成报告")
|
||||
p_report.add_argument("--config", default=DEFAULT_CONFIG)
|
||||
p_report.add_argument("--run-id")
|
||||
p_report.set_defaults(func=cmd_report, quiet=False)
|
||||
|
||||
p_status = sub.add_parser("status", help="查看最近一次运行")
|
||||
p_status.add_argument("--config", default=DEFAULT_CONFIG)
|
||||
p_status.add_argument("--run-id")
|
||||
p_status.set_defaults(func=cmd_status, quiet=False)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except QtError as error:
|
||||
print(f"[ERROR] {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
"""api 层:把存储过程封装成业务语义。
|
||||
|
||||
调用序列来自 QuickTeam 前端构建产物(chunk-0cadb1de / 039a8b29 / 9b10aab2),
|
||||
每个方法对应的存储过程与参数见 docs/QuickTeam-API导入-调研与设计.md §2.4。
|
||||
|
||||
dry_run=True 时写操作只记录意图并返回桩值,不产生任何服务端副作用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any, Callable
|
||||
|
||||
from .errors import Category, QtError
|
||||
from .models import (
|
||||
Feature,
|
||||
Project,
|
||||
Team,
|
||||
TfsTeam,
|
||||
User,
|
||||
WorkSheet,
|
||||
WorkSheetVersion,
|
||||
ms_to_date,
|
||||
normalize_iteration,
|
||||
)
|
||||
from .transport import Transport
|
||||
|
||||
CONN_QUICKTEAM = "QuickTeam"
|
||||
CONN_USERCENTER = "UserCenter"
|
||||
|
||||
SP_LOGIN = "dbo.usp_Authentication"
|
||||
SP_TEAMS = "QuickTeam.usp_GetTeamsByUserId"
|
||||
SP_PROJECTS = "QuickTeam.usp_GetProjectsByTeamId"
|
||||
SP_WORKSHEET = "QuickTeam.usp_GetWorkSheetById"
|
||||
SP_PROJECT_USERS = "QuickTeam.usp_GetProjectUsersByProjectId"
|
||||
SP_INSERT_WORKSHEET = "QuickTeam.usp_InsertWorkSheet"
|
||||
SP_UPDATE_WORKSHEET = "QuickTeam.usp_UpdateWorkSheetById"
|
||||
SP_VERIFY_WORKSHEET = "QuickTeam.usp_VerifyWorkSheetById"
|
||||
SP_FEATURES = "QuickTeam.usp_GetWorkFeaturesByWorkSheetIds"
|
||||
SP_WORKSHEET_VERSIONS = "QuickTeam.usp_GetWorkSheetsById"
|
||||
SP_IMPORT_EXCEL = "QuickTeam.usp_BatchImportFeatureFromExcel"
|
||||
SP_PUSH_FEATURES = "[QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds]"
|
||||
SP_DELETE_WORKSHEET = "QuickTeam.usp_DeleteWorkSheetAllById"
|
||||
|
||||
|
||||
class QuickTeamApi:
|
||||
def __init__(
|
||||
self,
|
||||
transport: Transport,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
log: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.t = transport
|
||||
self.dry_run = dry_run
|
||||
self.log = log or (lambda message: None)
|
||||
self._projects: dict[str, list[Project]] = {}
|
||||
|
||||
# ------------------------------------------------------------------ 只读
|
||||
|
||||
def login(self, username: str, password: str) -> User:
|
||||
digest = hashlib.md5(password.encode("utf-8")).hexdigest()
|
||||
rows = self.t.exec_table(
|
||||
CONN_USERCENTER,
|
||||
SP_LOGIN,
|
||||
[("UserName", username, "string"), ("Password", digest, "string")],
|
||||
op="login",
|
||||
)
|
||||
if not rows:
|
||||
raise QtError("登录失败:账号或密码不匹配", Category.AUTH)
|
||||
row = rows[0]
|
||||
return User(user_id=row["GuidId"], real_name=row.get("RealName") or username, user_name=row["UserName"])
|
||||
|
||||
def teams(self, user_id: str) -> list[Team]:
|
||||
rows = self.t.exec_table(CONN_QUICKTEAM, SP_TEAMS, [("UserId", user_id, "string")], op="teams") or []
|
||||
return [Team(team_id=row["TeamId"], team_name=row.get("TeamName", "")) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def resolve_team(teams: list[Team], name: str) -> Team:
|
||||
for team in teams:
|
||||
if team.team_name == name:
|
||||
return team
|
||||
available = "、".join(t.team_name for t in teams)
|
||||
raise QtError(f"未找到团队「{name}」,可选:{available}", Category.PARAM)
|
||||
|
||||
def projects(self, team_id: str) -> list[Project]:
|
||||
if team_id not in self._projects:
|
||||
rows = (
|
||||
self.t.exec_table(
|
||||
CONN_QUICKTEAM,
|
||||
SP_PROJECTS,
|
||||
[("TeamId", team_id, "string"), ("IsTemplate", "1", "string")],
|
||||
op="projects",
|
||||
)
|
||||
or []
|
||||
)
|
||||
self._projects[team_id] = [
|
||||
Project(project_id=row["ProjectId"], project_name=row.get("ProjectName", "")) for row in rows
|
||||
]
|
||||
return self._projects[team_id]
|
||||
|
||||
def worksheet(self, worksheet_id: int) -> WorkSheet:
|
||||
rows = self.t.exec_table(
|
||||
CONN_QUICKTEAM, SP_WORKSHEET, [("WorkSheetId", worksheet_id, "int")], op="worksheet"
|
||||
)
|
||||
if not rows:
|
||||
raise QtError(f"工单 {worksheet_id} 不存在", Category.PARAM)
|
||||
return self._to_worksheet(rows[0])
|
||||
|
||||
def project_users(self, project_id: str) -> list[dict]:
|
||||
return (
|
||||
self.t.exec_table(
|
||||
CONN_QUICKTEAM, SP_PROJECT_USERS, [("ProjectId", project_id, "string")], op="project_users"
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
def work_sheets(self, project_id: str) -> list[WorkSheet]:
|
||||
"""项目下全部工单(一次查全,含 SystemName / Status / 当前版本)。
|
||||
|
||||
比「按窗口查 + 逐单读详情」少 N 次往返,也不受 `usp_GetWeeklyWorkSheetsByProjectId`
|
||||
按季度过滤的坑影响 —— plan 的幂等判定就靠它。
|
||||
"""
|
||||
rows = (
|
||||
self.t.exec_table(
|
||||
CONN_QUICKTEAM,
|
||||
SP_WORKSHEET_VERSIONS,
|
||||
[("ProjectId", project_id, "string"), ("isReclaimed", 0, "int")],
|
||||
op="work_sheets",
|
||||
)
|
||||
or []
|
||||
)
|
||||
# 该 SP 用 union 拼了两部分:工单本体行(VersionId 为空)+ 每个版本一行。
|
||||
# 版本行的 WorkSheetCode 被覆盖成了 VersionCode,所以字段优先取本体行。
|
||||
base: dict[int, dict] = {}
|
||||
other: dict[int, dict] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
worksheet_id = int(row.get("WorkSheetId"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
is_base = row.get("VersionId") in (None, "", 0)
|
||||
(base if is_base else other).setdefault(worksheet_id, row)
|
||||
|
||||
result: list[WorkSheet] = []
|
||||
for worksheet_id in sorted(set(base) | set(other)):
|
||||
row = base.get(worksheet_id) or other[worksheet_id]
|
||||
result.append(
|
||||
WorkSheet(
|
||||
worksheet_id=worksheet_id,
|
||||
code=row.get("WorkSheetCode", ""),
|
||||
status=str(row.get("Status") or ""),
|
||||
version_id=row.get("CurrentVersionId"),
|
||||
version_code=str(row.get("VersionCode") or ""),
|
||||
system_name=str(row.get("SystemName") or ""),
|
||||
start_time=ms_to_date(row.get("StartTime")),
|
||||
end_time=ms_to_date(row.get("EndTime")),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def worksheet_versions(self, project_id: str, worksheet_id: int | None = None) -> list[WorkSheetVersion]:
|
||||
"""项目下所有工单的版本行(列表页用的那个 SP,一行 = 一个版本)。"""
|
||||
rows = (
|
||||
self.t.exec_table(
|
||||
CONN_QUICKTEAM,
|
||||
SP_WORKSHEET_VERSIONS,
|
||||
[("ProjectId", project_id, "string"), ("isReclaimed", 0, "int")],
|
||||
op="worksheet_versions",
|
||||
)
|
||||
or []
|
||||
)
|
||||
result: list[WorkSheetVersion] = []
|
||||
seen: set[int] = set()
|
||||
for row in rows:
|
||||
raw_id = row.get("VersionId")
|
||||
if raw_id in (None, "", 0):
|
||||
continue # 该 SP 用 union 拼了「无版本」的占位行,跳过
|
||||
try:
|
||||
version_id = int(raw_id)
|
||||
ws_id = int(row.get("WorkSheetId"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if version_id in seen:
|
||||
continue
|
||||
if worksheet_id is not None and ws_id != worksheet_id:
|
||||
continue
|
||||
seen.add(version_id)
|
||||
result.append(
|
||||
WorkSheetVersion(
|
||||
version_id=version_id,
|
||||
worksheet_id=ws_id,
|
||||
version_code=str(row.get("VersionCode") or ""),
|
||||
version_status=str(row.get("VersionStatus") or ""),
|
||||
next_operator=str(row.get("NextOperator") or ""),
|
||||
)
|
||||
)
|
||||
result.sort(key=lambda item: item.version_id)
|
||||
return result
|
||||
|
||||
def pending_version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion | None:
|
||||
"""取「待确认版本」。导入明细和确认工单都必须落到它上面。"""
|
||||
versions = self.worksheet_versions(project_id, worksheet_id)
|
||||
if not versions:
|
||||
return None
|
||||
pending = [item for item in versions if item.pending]
|
||||
if pending:
|
||||
return max(pending, key=lambda item: item.version_id)
|
||||
# 全部已确认:退回最后一个版本,交给上层按 version_code 判断流程是否走完
|
||||
return versions[-1]
|
||||
|
||||
def features(self, worksheet_id: int) -> list[Feature]:
|
||||
rows = (
|
||||
self.t.exec_table(CONN_QUICKTEAM, SP_FEATURES, [("WorkSheetIds", str(worksheet_id), "string")], op="features")
|
||||
or []
|
||||
)
|
||||
return [
|
||||
Feature(feature_id=row["FeatureId"], code=row.get("FeatureCode", ""), name=row.get("FeatureName", ""))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------ 写入
|
||||
|
||||
def create_worksheet(self) -> int:
|
||||
"""新增空工单,返回 WorkSheetId。"""
|
||||
if self.dry_run:
|
||||
self.log(" [dry-run] InsertWorkSheet() → 桩 WorkSheetId=0")
|
||||
return 0
|
||||
rows = self.t.exec_table(CONN_QUICKTEAM, SP_INSERT_WORKSHEET, [], op="insert_worksheet", write=True)
|
||||
if not rows or not isinstance(rows, list) or "WorkSheetId" not in rows[0]:
|
||||
raise QtError(f"InsertWorkSheet 返回异常:{rows!r}", Category.PARAM)
|
||||
return int(rows[0]["WorkSheetId"])
|
||||
|
||||
def fill_worksheet(
|
||||
self,
|
||||
worksheet_id: int,
|
||||
project: Project,
|
||||
*,
|
||||
system_name: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
evaluator: str,
|
||||
technical_leader: str,
|
||||
customer_leader: str,
|
||||
belong_team: str,
|
||||
content: str,
|
||||
operator: str,
|
||||
service_type: str = "DevelopService",
|
||||
status: str = "00",
|
||||
other_instructions: str = "",
|
||||
) -> int:
|
||||
"""填工单基本信息(Operation=new)。返回 Result(>0 视为成功)。"""
|
||||
params: list[tuple[str, Any, str]] = [
|
||||
("WorkSheetId", worksheet_id, "string"),
|
||||
("ProjectId", project.project_id, "string"),
|
||||
("ProjectName", project.project_name, "string"),
|
||||
("SystemName", system_name, "string"),
|
||||
("Evaluator", evaluator, "string"),
|
||||
("TechnicalLeader", technical_leader, "string"),
|
||||
("CustomerLeader", customer_leader, "string"),
|
||||
("StartTime", start_time, "string"),
|
||||
("EndTime", end_time, "string"),
|
||||
("Status", status, "string"),
|
||||
("ServiceType", service_type, "string"),
|
||||
("ImplementContent", content, "string"),
|
||||
("OtherInstructions", other_instructions, "string"),
|
||||
("Operator", operator, "string"),
|
||||
("DeleteFlag", "0", "string"),
|
||||
("Operation", "new", "string"),
|
||||
("BelongTeam", belong_team, "string"),
|
||||
]
|
||||
if self.dry_run:
|
||||
self.log(f" [dry-run] UpdateWorkSheetById(ws={worksheet_id}, sprint={system_name}, "
|
||||
f"{start_time}~{end_time}, 负责人={technical_leader}) → 桩 Result=1")
|
||||
return 1
|
||||
rows = self.t.exec_table(CONN_QUICKTEAM, SP_UPDATE_WORKSHEET, params, op="update_worksheet", write=True)
|
||||
result = rows[0].get("Result") if rows and isinstance(rows, list) else None
|
||||
|
||||
# 实测结论:这个 SP 的 Result 恒为 0,但数据确实写入了(WorkSheetCode/ProjectId/
|
||||
# SystemName/StartTime/Status/BelongTeam 全部落库)。前端 `Result>0` 的提示判断
|
||||
# 也不可靠,所以这里以「重新读取工单」为准,而不是信返回值。
|
||||
if result is not None and int(result) > 0:
|
||||
return int(result)
|
||||
detail = self.worksheet(worksheet_id)
|
||||
if detail.system_name != system_name:
|
||||
raise QtError(
|
||||
f"保存后校验失败:SystemName={detail.system_name!r},期望 {system_name!r}"
|
||||
f"(Result={result!r})",
|
||||
Category.PARAM,
|
||||
)
|
||||
return int(result or 0)
|
||||
|
||||
def upload_excel(self, local_path: str, folder: str = "WorkSheetImport") -> str:
|
||||
if self.dry_run:
|
||||
self.log(f" [dry-run] FileUpload({local_path}) → 桩路径")
|
||||
return "<dry-run>"
|
||||
return self.t.upload(local_path, folder)
|
||||
|
||||
def import_excel(self, worksheet_id: int, version_id: int, remote_path: str) -> None:
|
||||
"""服务器端解析 Excel 写明细;Column1 == 0 为成功。"""
|
||||
params: list[tuple[str, Any, str]] = [
|
||||
("WorkSheetId", worksheet_id, "int"),
|
||||
("VersionId", version_id, "int"),
|
||||
]
|
||||
if self.dry_run:
|
||||
self.log(f" [dry-run] BatchImportFeature(ws={worksheet_id}, version={version_id}, {remote_path})")
|
||||
return
|
||||
result = self.t.batch_import(SP_IMPORT_EXCEL, params, remote_path)
|
||||
code = None
|
||||
if isinstance(result, list) and result and isinstance(result[0], dict):
|
||||
code = result[0].get("Column1")
|
||||
if code is None:
|
||||
raise QtError(f"Excel 解析未返回结果:{result!r}", Category.EXCEL)
|
||||
if int(code) != 0:
|
||||
raise QtError(f"Excel 解析失败(Column1={code}),模板或内容不符", Category.EXCEL)
|
||||
|
||||
def delete_worksheet(self, worksheet_id: int) -> None:
|
||||
"""删除整单(回滚半成品用)。"""
|
||||
if self.dry_run:
|
||||
self.log(f" [dry-run] DeleteWorkSheetAllById(ws={worksheet_id})")
|
||||
return
|
||||
self.t.exec_table(
|
||||
CONN_QUICKTEAM, SP_DELETE_WORKSHEET, [("WorkSheetId", worksheet_id, "int")], op="delete_worksheet", write=True
|
||||
)
|
||||
|
||||
def verify_worksheet(
|
||||
self,
|
||||
worksheet_id: int,
|
||||
version_id: int,
|
||||
*,
|
||||
next_operator: str,
|
||||
next_version_code: str,
|
||||
operator: str,
|
||||
percent: int,
|
||||
) -> None:
|
||||
"""确认工单(推进版本)。返回 0 为成功。"""
|
||||
params: list[tuple[str, Any, str]] = [
|
||||
("WorkSheetId", worksheet_id, "int"),
|
||||
("VersionId", version_id, "int"),
|
||||
("NextOperator", next_operator, "string"),
|
||||
("NextVersionCode", next_version_code, "string"),
|
||||
("Percent", percent, "int"),
|
||||
("Operator", operator, "string"),
|
||||
]
|
||||
if self.dry_run:
|
||||
self.log(f" [dry-run] VerifyWorkSheetById(ws={worksheet_id}, v={version_id}, "
|
||||
f"next={next_operator}/{next_version_code}, percent={percent})")
|
||||
return
|
||||
result = self.t.exec_result(CONN_QUICKTEAM, SP_VERIFY_WORKSHEET, params, op="verify_worksheet")
|
||||
if isinstance(result, list) and result and isinstance(result[0], dict):
|
||||
result = result[0].get("Result", result[0].get("Column1"))
|
||||
if result is not None and str(result).strip() not in {"0", "0.0"}:
|
||||
raise QtError(f"确认工单失败,返回 {result!r}", Category.UNKNOWN)
|
||||
|
||||
# ------------------------------------------------------------------ TFS
|
||||
|
||||
def tfs_projects(self) -> list[dict]:
|
||||
return self.t.tfs("GetTeamProjects", op="TFS.GetTeamProjects") or []
|
||||
|
||||
def tfs_teams(self, project_name: str, project_id: str) -> list[TfsTeam]:
|
||||
rows = (
|
||||
self.t.tfs(
|
||||
"GetTeamByProject",
|
||||
{"projectName": project_name, "projectId": project_id},
|
||||
op="TFS.GetTeamByProject",
|
||||
)
|
||||
or []
|
||||
)
|
||||
teams: list[TfsTeam] = []
|
||||
for row in rows:
|
||||
area = row.get("AreaPath") or []
|
||||
teams.append(
|
||||
TfsTeam(
|
||||
team_name=row.get("TeamName", ""),
|
||||
area_path=area[0] if area else "",
|
||||
iterations=row.get("Iterations") or [],
|
||||
)
|
||||
)
|
||||
return teams
|
||||
|
||||
@staticmethod
|
||||
def resolve_tfs_context(
|
||||
teams: list[TfsTeam], team_name: str, sprint: str
|
||||
) -> tuple[str, str]:
|
||||
"""按团队名与 Sprint 名解析 (areaPath, iterationPath)。匹配不到直接报错,不猜。"""
|
||||
team = next((t for t in teams if t.team_name == team_name), None)
|
||||
if team is None:
|
||||
available = "、".join(t.team_name for t in teams)
|
||||
raise QtError(f"TFS 团队「{team_name}」不存在,可选:{available}", Category.TFS)
|
||||
if not team.area_path:
|
||||
raise QtError(f"TFS 团队「{team_name}」没有 AreaPath", Category.TFS)
|
||||
|
||||
target = normalize_iteration(sprint)
|
||||
hits = [item for item in team.iterations if normalize_iteration(str(item.get("Name", ""))) == target]
|
||||
if not hits: # 退化:用完整路径的最后一段匹配
|
||||
hits = [
|
||||
item
|
||||
for item in team.iterations
|
||||
if normalize_iteration(str(item.get("Path", "")).split("\\")[-1]) == target
|
||||
]
|
||||
if len(hits) != 1:
|
||||
sample = "、".join(str(i.get("Path")) for i in team.iterations[-3:]) or "(空)"
|
||||
raise QtError(
|
||||
f"TFS 迭代「{sprint}」在团队「{team_name}」下匹配到 {len(hits)} 条(最近:{sample})",
|
||||
Category.TFS,
|
||||
)
|
||||
path = str(hits[0].get("Path", "")).replace("/", "\\")
|
||||
return team.area_path, path
|
||||
|
||||
def push_tfs(self, feature_ids: list[int], project_name: str, area_path: str, iteration_path: str) -> Any:
|
||||
joined = ",".join(str(i) for i in feature_ids)
|
||||
if self.dry_run:
|
||||
self.log(
|
||||
f" [dry-run] BatchImportWorkItemsToTfs(项目={project_name}, "
|
||||
f"area={area_path}, iter={iteration_path}, features={joined or '-'})"
|
||||
)
|
||||
return []
|
||||
if not feature_ids:
|
||||
raise QtError("没有可推送的功能模块", Category.TFS)
|
||||
inner = {
|
||||
"SPName": SP_PUSH_FEATURES,
|
||||
"SQLParams": [{"Name": "FeatureIds", "Value": joined, "DBType": "string"}],
|
||||
}
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
payload = {
|
||||
"connectionName": CONN_QUICKTEAM,
|
||||
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
|
||||
"projectName": project_name,
|
||||
"areaPath": area_path,
|
||||
"iterationPath": iteration_path,
|
||||
}
|
||||
result = self.t.tfs("BatchImportWorkItemsToTfs", payload, op="TFS.BatchImportWorkItemsToTfs", write=True)
|
||||
# 这个接口返回的是**状态码**不是数量:0 = 成功,-1 = 失败(实测传不存在的项目名
|
||||
# 返回 -1;正常返回 0)。别拿 >0 当成功,否则会把一批已推送的工单记成失败。
|
||||
if isinstance(result, int) and not isinstance(result, bool) and result != 0:
|
||||
raise QtError(f"TFS 推送失败(返回 {result})", Category.TFS)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------ 内部
|
||||
|
||||
@staticmethod
|
||||
def _to_worksheet(row: dict) -> WorkSheet:
|
||||
return WorkSheet(
|
||||
worksheet_id=int(row.get("WorkSheetId", 0)),
|
||||
code=row.get("WorkSheetCode", ""),
|
||||
status=str(row.get("Status", "")),
|
||||
version_id=row.get("CurrentVersionId"),
|
||||
version_code=row.get("VersionCode", "") or "",
|
||||
system_name=row.get("SystemName", ""),
|
||||
start_time=ms_to_date(row.get("StartTime")),
|
||||
end_time=ms_to_date(row.get("EndTime")),
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""配置加载。
|
||||
|
||||
凭据解析优先级:环境变量 QT_PASSWORD > Windows DPAPI 文件 > 配置文件明文 > 交互输入。
|
||||
配置文件默认 config/config.json(已在 .gitignore 中)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_CONFIG = "config/config.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
# 连接
|
||||
base_url: str
|
||||
username: str
|
||||
password: str = field(default="", repr=False)
|
||||
timeout: int = 60
|
||||
# 业务
|
||||
team_name: str = "当前项目"
|
||||
work_dir: str = ""
|
||||
sprint: str = ""
|
||||
start_time: str = ""
|
||||
end_time: str = ""
|
||||
content_desc: str = "需求开发测试"
|
||||
service_type: str = "DevelopService"
|
||||
belong_team: str = ""
|
||||
# 三个责任人;留空则用登录用户的真实姓名
|
||||
evaluator: str = ""
|
||||
technical_leader: str = ""
|
||||
customer_leader: str = ""
|
||||
# TFS
|
||||
tfs_project: str = "Tools_Dev"
|
||||
tfs_team: str = "DataTechnologies"
|
||||
area_path: str = ""
|
||||
iteration_path: str = ""
|
||||
# 运行
|
||||
workers: int = 2
|
||||
db_path: str = "state/qtimport.db"
|
||||
|
||||
def resolved_roles(self, operator: str) -> tuple[str, str, str]:
|
||||
"""三个责任人,缺省都落到操作人(与现有脚本一致)。"""
|
||||
return (
|
||||
self.evaluator or operator,
|
||||
self.technical_leader or operator,
|
||||
self.customer_leader or operator,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | os.PathLike = DEFAULT_CONFIG, *, need_password: bool = True) -> "Config":
|
||||
file_path = Path(path)
|
||||
if not file_path.exists():
|
||||
raise SystemExit(f"缺少配置文件:{file_path}(可从 config/config.example.json 复制)")
|
||||
with open(file_path, encoding="utf-8-sig") as handle:
|
||||
raw = json.load(handle)
|
||||
|
||||
allowed = {f for f in cls.__dataclass_fields__}
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise SystemExit(f"配置文件含未知字段:{sorted(unknown)}")
|
||||
cfg = cls(**raw)
|
||||
|
||||
cfg.password = resolve_password(cfg.password, need=need_password)
|
||||
if not cfg.base_url or not cfg.username:
|
||||
raise SystemExit("配置缺少 base_url 或 username")
|
||||
if need_password and not cfg.password:
|
||||
raise SystemExit("未提供密码:设置 QT_PASSWORD 环境变量或在配置中填写")
|
||||
return cfg
|
||||
|
||||
|
||||
def resolve_password(configured: str, *, need: bool = True) -> str:
|
||||
env = os.environ.get("QT_PASSWORD")
|
||||
if env:
|
||||
return env
|
||||
if configured and configured != "REPLACE_ME":
|
||||
return configured
|
||||
if not need:
|
||||
return ""
|
||||
if not os.isatty(0): # 非交互(计划任务/CI)
|
||||
return ""
|
||||
return getpass.getpass("QuickTeam 密码:")
|
||||
|
||||
|
||||
def load_raw(path: str | os.PathLike = DEFAULT_CONFIG) -> dict:
|
||||
"""读原始 JSON(plan 阶段不需要密码时用)。"""
|
||||
with open(Path(path), encoding="utf-8-sig") as handle:
|
||||
return json.load(handle)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""domain 层:判定「该做什么」,不含 HTTP、不含 SQL。
|
||||
|
||||
这里集中三个最容易被写散的规则:
|
||||
1. 文件 ↔ 项目 的匹配(精确,绝不猜)
|
||||
2. 工单是否已存在 / 是否可续跑(幂等的判断源)
|
||||
3. 版本推进(确认工单)的下一步参数
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from .models import Project, WorkSheet, normalize_name
|
||||
|
||||
|
||||
class Action(StrEnum):
|
||||
CREATE = "create"
|
||||
RESUME = "resume"
|
||||
SKIP_DONE = "skip-done"
|
||||
SKIP_EXISTS = "skip-exists"
|
||||
SKIP_PUSHED = "skip-pushed"
|
||||
ERROR = "error"
|
||||
|
||||
@property
|
||||
def writes(self) -> bool:
|
||||
return self in {Action.CREATE, Action.RESUME}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanRow:
|
||||
project_name: str
|
||||
file_path: str
|
||||
action: Action
|
||||
reason: str = ""
|
||||
project: Project | None = None
|
||||
worksheet: WorkSheet | None = None
|
||||
existing_count: int = 0
|
||||
|
||||
|
||||
# 版本链(前端 EffortListVersionInfo 的等价物)
|
||||
# version_code -> (next_operator, next_version_code, percent);None 表示流程结束
|
||||
VERSION_TRANSITIONS: dict[str, tuple[str, str, int] | None] = {
|
||||
"": ("CustomerLeader", "客户责任人确认版", 20), # 新建草稿视同「技术责任人确认版」
|
||||
"技术责任人确认版": ("CustomerLeader", "客户责任人确认版", 20),
|
||||
"客户责任人确认版": ("CustomerLeader", "甲方确认版", 20),
|
||||
"甲方确认版": None,
|
||||
}
|
||||
|
||||
|
||||
def next_transition(version_code: str) -> tuple[str, str, int]:
|
||||
"""返回 (NextOperator, NextVersionCode, Percent)。到终点则报错。"""
|
||||
if version_code not in VERSION_TRANSITIONS:
|
||||
raise ValueError(f"未知版本「{version_code}」,无法推导下一步")
|
||||
step = VERSION_TRANSITIONS[version_code]
|
||||
if step is None:
|
||||
raise ValueError(f"版本「{version_code}」已是流程终点,无需确认")
|
||||
return step
|
||||
|
||||
|
||||
def decide(existing: list[WorkSheet], *, ledger_stage: str | None, adopt: bool) -> tuple[Action, str, WorkSheet | None]:
|
||||
"""根据「服务器现状 + 台账记录」决定动作。"""
|
||||
if not existing:
|
||||
return Action.CREATE, "本项目该 Sprint 无工单", None
|
||||
|
||||
# 优先看同一 Sprint 的工单(调用方已按 SystemName 过滤)
|
||||
worksheet = existing[0]
|
||||
if worksheet.done:
|
||||
return Action.SKIP_DONE, f"工单 {worksheet.code} 已确认完成(Status={worksheet.status})", worksheet
|
||||
|
||||
if ledger_stage == "TFS_PUSHED":
|
||||
return Action.SKIP_PUSHED, f"工单 {worksheet.code} 已推送 TFS", worksheet
|
||||
|
||||
if ledger_stage:
|
||||
return Action.RESUME, f"台账记录停在 {ledger_stage},续跑", worksheet
|
||||
|
||||
if adopt:
|
||||
return Action.RESUME, "工单存在但无台账记录,--adopt 接管续跑", worksheet
|
||||
|
||||
return Action.SKIP_EXISTS, f"工单 {worksheet.code} 已存在(无台账记录,未接管)", worksheet
|
||||
|
||||
|
||||
def list_xlsx(folder: str) -> list[str]:
|
||||
if not os.path.isdir(folder):
|
||||
raise SystemExit(f"目录不存在:{folder}")
|
||||
result = []
|
||||
for name in sorted(os.listdir(folder)):
|
||||
path = os.path.join(folder, name)
|
||||
if os.path.isfile(path) and name.lower().endswith(".xlsx") and not name.startswith("~$"):
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
def match_project(stem: str, projects: list[Project]) -> tuple[Project | None, str]:
|
||||
"""精确匹配项目;命中多个或零个都算失败,不做「包含即命中」的猜测。"""
|
||||
target = normalize_name(stem)
|
||||
exact = [p for p in projects if normalize_name(p.project_name) == target]
|
||||
if len(exact) == 1:
|
||||
return exact[0], ""
|
||||
if len(exact) > 1:
|
||||
names = "、".join(p.project_name for p in exact)
|
||||
return None, f"项目名歧义({names})"
|
||||
|
||||
# 只用于提示,不给结果
|
||||
near = [p.project_name for p in projects if target in normalize_name(p.project_name)]
|
||||
hint = f",相近项目:{'、'.join(near[:3])}" if near else ""
|
||||
return None, f"QuickTeam 中没有该项目{hint}"
|
||||
|
||||
|
||||
def precheck_xlsx(path: str) -> tuple[bool, str]:
|
||||
"""上传前的轻量自检:确认是「工作量评估表」模板,避免白跑一趟服务器解析。"""
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = archive.namelist()
|
||||
if "xl/workbook.xml" not in names:
|
||||
return False, "不是 xlsx(缺少 xl/workbook.xml)"
|
||||
if "xl/sharedStrings.xml" not in names:
|
||||
return False, "文件为空表或已损坏"
|
||||
text = archive.read("xl/sharedStrings.xml").decode("utf-8", "ignore")
|
||||
except zipfile.BadZipFile:
|
||||
return False, "不是合法的 xlsx(可能仍是 Excel 打开中的临时文件)"
|
||||
except OSError as exc:
|
||||
return False, f"读取失败:{exc}"
|
||||
|
||||
if "工作量评估表" not in text:
|
||||
return False, "未找到「工作量评估表」标题,可能不是工单模板"
|
||||
if "服务模块" not in text:
|
||||
return False, "未找到明细表头(服务模块),模板可能已变更"
|
||||
return True, ""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""异常与错误分类。
|
||||
|
||||
WCF 的错误是 HTML 页面,真正的信息藏在 "The exception message is '...'" 里。
|
||||
分类的目的是决定「重试 / 跳过 / 整批中止」,而不是把所有失败都当网络问题重试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class Category(StrEnum):
|
||||
NET = "NET" # 网络层:可重试
|
||||
SP_MISSING = "SP_MISSING" # 存储过程不存在:配置/版本问题,不重试
|
||||
PARAM = "PARAM" # 参数错误/类型转换:数据问题,不重试
|
||||
EXCEL = "EXCEL" # Excel 解析失败(Column1 != 0)
|
||||
TFS = "TFS" # 推送 TFS 失败
|
||||
AUTH = "AUTH" # 登录失败
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class QtError(RuntimeError):
|
||||
"""带分类的接口错误。"""
|
||||
|
||||
def __init__(self, message: str, category: Category = Category.UNKNOWN, *, retryable: bool = False):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.category = category
|
||||
self.retryable = retryable
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - 直通
|
||||
return f"[{self.category}] {self.message}"
|
||||
|
||||
@classmethod
|
||||
def from_http(cls, status: int, body: str) -> "QtError":
|
||||
"""把 WCF 的 HTML 错误页转成可读错误。"""
|
||||
message = extract_exception_message(body) or f"HTTP {status}"
|
||||
lowered = message.lower()
|
||||
if "could not find stored procedure" in lowered:
|
||||
return cls(message, Category.SP_MISSING)
|
||||
if "missing parameter" in lowered or "conversion" in lowered or "procedure or function" in lowered:
|
||||
return cls(message, Category.PARAM)
|
||||
if status >= 500:
|
||||
return cls(message, Category.NET, retryable=True)
|
||||
return cls(message, Category.UNKNOWN)
|
||||
|
||||
|
||||
def extract_exception_message(body: str) -> str:
|
||||
"""从 WCF 错误页里抠出真正的异常文本。"""
|
||||
import html
|
||||
import re
|
||||
|
||||
match = re.search(r"exception message is '(.*?)'\s*\.?\s*See server logs", body, re.S)
|
||||
if match:
|
||||
return html.unescape(match.group(1)).strip()
|
||||
plain = re.sub(r"<[^>]+>", " ", body)
|
||||
plain = html.unescape(re.sub(r"\s+", " ", plain)).strip()
|
||||
return plain[:400]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""数据结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
user_id: str
|
||||
real_name: str
|
||||
user_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Team:
|
||||
team_id: str
|
||||
team_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
project_id: str
|
||||
project_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkSheet:
|
||||
"""工单(每项目每 Sprint 一张)。"""
|
||||
|
||||
worksheet_id: int
|
||||
code: str = ""
|
||||
status: str = ""
|
||||
version_id: int | None = None
|
||||
version_code: str = ""
|
||||
system_name: str = ""
|
||||
start_time: str = ""
|
||||
end_time: str = ""
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
"""03/04 = 甲方已确认,流程闭环。"""
|
||||
return self.status in {"03", "04"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Feature:
|
||||
feature_id: int
|
||||
code: str = ""
|
||||
name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkSheetVersion:
|
||||
"""工单版本(审批链上的一环)。
|
||||
|
||||
`usp_UpdateWorkSheetById` 在第一次保存时插入「技术责任人确认版」(VersionStatus='N'),
|
||||
但**不回填** `WorkSheet.CurrentVersionId` —— 那要等 `usp_VerifyWorkSheetById` 才写。
|
||||
所以导入明细/确认工单都必须用「待确认版本」的 VersionId,不能用 CurrentVersionId。
|
||||
"""
|
||||
|
||||
version_id: int
|
||||
worksheet_id: int
|
||||
version_code: str = ""
|
||||
version_status: str = "" # N=待处理(当前),Y=已确认
|
||||
next_operator: str = ""
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.version_status.strip().upper() == "N"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TfsTeam:
|
||||
team_name: str
|
||||
area_path: str
|
||||
iterations: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def ms_to_date(value: object) -> str:
|
||||
"""`/Date(1785081600000)/` → `2026-07-26`。"""
|
||||
if not value:
|
||||
return ""
|
||||
if isinstance(value, str) and value.startswith("/Date("):
|
||||
match = re.search(r"\((-?\d+)", value)
|
||||
if match:
|
||||
return time.strftime("%Y-%m-%d", time.localtime(int(match.group(1)) / 1000))
|
||||
return str(value)
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""项目/迭代名归一化:去空白、统一破折号、忽略大小写。用于精确匹配,避免 contains 误命中。"""
|
||||
text = re.sub(r"\s+", "", name or "")
|
||||
for dash in ("-", "—", "–", "−"):
|
||||
text = text.replace(dash, "-")
|
||||
return text.casefold()
|
||||
|
||||
|
||||
def normalize_iteration(name: str) -> str:
|
||||
"""迭代名比对:在 normalize_name 基础上忽略数字前导零(`2026 Sprint 06` ≈ `2026 Sprint 6`)。"""
|
||||
text = normalize_name(name)
|
||||
return re.sub(r"(?<=\D)0+(?=\d)", "", text)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""日志与报告。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .store import Store
|
||||
|
||||
|
||||
class RunLog:
|
||||
"""控制台摘要 + JSONL 明细。"""
|
||||
|
||||
def __init__(self, run_id: str, log_dir: str = "logs", *, verbose: bool = True) -> None:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
self.path = os.path.join(log_dir, f"{run_id}.jsonl")
|
||||
self.verbose = verbose
|
||||
self.run_id = run_id
|
||||
self._handle = open(self.path, "a", encoding="utf-8")
|
||||
|
||||
def close(self) -> None:
|
||||
self._handle.close()
|
||||
|
||||
def _write(self, record: dict[str, Any]) -> None:
|
||||
record.setdefault("ts", time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
record.setdefault("run", self.run_id)
|
||||
self._handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
self._handle.flush()
|
||||
|
||||
def info(self, message: str, **fields: Any) -> None:
|
||||
self._write({"level": "info", "message": message, **fields})
|
||||
if self.verbose:
|
||||
print(message)
|
||||
|
||||
def event(self, stage: str, action: str, *, project: str = "", detail: str = "", elapsed_ms: int = 0) -> None:
|
||||
self._write(
|
||||
{
|
||||
"level": "event",
|
||||
"stage": stage,
|
||||
"action": action,
|
||||
"project": project,
|
||||
"detail": detail,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
)
|
||||
|
||||
def warn(self, message: str, **fields: Any) -> None:
|
||||
self._write({"level": "warn", "message": message, **fields})
|
||||
if self.verbose:
|
||||
print(f" ! {message}")
|
||||
|
||||
|
||||
def write_report(run_id: str, store: Store, out_dir: str = "reports") -> tuple[str, str]:
|
||||
"""生成 summary.md 与 detail.csv。"""
|
||||
target = os.path.join(out_dir, run_id)
|
||||
os.makedirs(target, exist_ok=True)
|
||||
|
||||
jobs = store.jobs(run_id)
|
||||
events = store.events(run_id)
|
||||
|
||||
md_path = os.path.join(target, "summary.md")
|
||||
csv_path = os.path.join(target, "detail.csv")
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for job in jobs:
|
||||
counts[job["action"]] = counts.get(job["action"], 0) + 1
|
||||
failures = [job for job in jobs if job["error"]]
|
||||
|
||||
with open(csv_path, "w", encoding="utf-8-sig", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(
|
||||
["项目", "Sprint", "文件", "动作", "阶段", "工单号", "版本", "耗时(ms)", "错误"]
|
||||
)
|
||||
for job in jobs:
|
||||
elapsed = sum(
|
||||
e["elapsed_ms"] or 0
|
||||
for e in events
|
||||
if e["project_id"] == job["project_id"] and e["elapsed_ms"]
|
||||
)
|
||||
writer.writerow(
|
||||
[
|
||||
job["project_name"],
|
||||
job["system_name"],
|
||||
os.path.basename(job["source_file"]),
|
||||
job["action"],
|
||||
job["stage"],
|
||||
job["worksheet_id"] or "",
|
||||
job["version_id"] or "",
|
||||
elapsed,
|
||||
(job["error"] or "").replace("\n", " ")[:300],
|
||||
]
|
||||
)
|
||||
|
||||
info = store.run_info(run_id) or {}
|
||||
mode = "演练(未写入任何数据)" if info.get("dry_run") else "实际执行"
|
||||
|
||||
with open(md_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(f"# 导入报告 {run_id}\n\n")
|
||||
handle.write(f"- 模式:{mode}\n")
|
||||
handle.write(f"- Sprint:{info.get('sprint', '')}\n")
|
||||
handle.write(f"- 项目数:{len(jobs)}\n")
|
||||
handle.write(f"- 失败:{len(failures)}\n")
|
||||
for action, count in sorted(counts.items()):
|
||||
handle.write(f"- {action}:{count}\n")
|
||||
handle.write("\n| 项目 | 动作 | 阶段 | 工单号 | 错误 |\n| --- | --- | --- | --- | --- |\n")
|
||||
for job in jobs:
|
||||
error = (job["error"] or "").replace("\n", " ")[:120].replace("|", "\\|")
|
||||
handle.write(
|
||||
f"| {job['project_name']} | {job['action']} | {job['stage']} | "
|
||||
f"{job['worksheet_id'] or '-'} | {error or ''} |\n"
|
||||
)
|
||||
return md_path, csv_path
|
||||
@@ -0,0 +1,207 @@
|
||||
"""SQLite 台账:断点、幂等判断与审计的唯一事实源。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
sprint TEXT NOT NULL,
|
||||
work_dir TEXT NOT NULL,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'RUNNING'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
run_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
project_name TEXT NOT NULL,
|
||||
system_name TEXT NOT NULL,
|
||||
source_file TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
worksheet_id INTEGER,
|
||||
version_id INTEGER,
|
||||
reason TEXT,
|
||||
error TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (run_id, project_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
stage TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
elapsed_ms INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs (project_id, system_name, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_run ON events (run_id, id);
|
||||
"""
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
directory = os.path.dirname(os.path.abspath(db_path))
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
self._conn.executescript(SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.close()
|
||||
|
||||
# ------------------------------------------------------------------ runs
|
||||
|
||||
def start_run(self, run_id: str, sprint: str, work_dir: str, dry_run: bool) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO runs (run_id, started_at, sprint, work_dir, dry_run, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'RUNNING')",
|
||||
(run_id, now(), sprint, work_dir, int(dry_run)),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def finish_run(self, run_id: str, status: str = "DONE") -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE runs SET finished_at = ?, status = ? WHERE run_id = ?", (now(), status, run_id)
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------ jobs
|
||||
|
||||
def upsert_job(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
project_id: str,
|
||||
project_name: str,
|
||||
system_name: str,
|
||||
source_file: str,
|
||||
action: str,
|
||||
stage: str,
|
||||
worksheet_id: int | None = None,
|
||||
version_id: int | None = None,
|
||||
reason: str = "",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (run_id, project_id, project_name, system_name, source_file, action, stage,
|
||||
worksheet_id, version_id, reason, error, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id, project_id) DO UPDATE SET
|
||||
action = excluded.action,
|
||||
stage = excluded.stage,
|
||||
worksheet_id = COALESCE(excluded.worksheet_id, jobs.worksheet_id),
|
||||
version_id = COALESCE(excluded.version_id, jobs.version_id),
|
||||
reason = excluded.reason,
|
||||
error = excluded.error,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
run_id,
|
||||
project_id,
|
||||
project_name,
|
||||
system_name,
|
||||
source_file,
|
||||
str(action),
|
||||
stage,
|
||||
worksheet_id,
|
||||
version_id,
|
||||
reason,
|
||||
error,
|
||||
now(),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def latest_stage(self, project_id: str, system_name: str) -> str | None:
|
||||
"""跨 run 取该项目该 Sprint 最近一次落库的阶段,用于断点续跑。"""
|
||||
job = self.latest_job(project_id, system_name)
|
||||
return job["stage"] if job else None
|
||||
|
||||
def latest_job(self, project_id: str, system_name: str) -> dict[str, Any] | None:
|
||||
"""最近一次**真实**执行记录。dry-run 不参与幂等判断,否则会把演练当成已完成。
|
||||
|
||||
`SKIPPED` 行也不参与:它只表示「这一轮不用做」,不带任何进度信息。若把它当成
|
||||
最近记录,下次 plan 会把「已推送 TFS」看成「台账停在 SKIPPED」,误判 resume 并
|
||||
再推一次 —— 而 TFS 推送接口没有服务端去重,重复推送就是重复工作项。
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT j.* FROM jobs j
|
||||
JOIN runs r ON r.run_id = j.run_id
|
||||
WHERE j.project_id = ? AND j.system_name = ? AND r.dry_run = 0
|
||||
AND j.stage <> 'SKIPPED'
|
||||
ORDER BY j.updated_at DESC LIMIT 1
|
||||
""",
|
||||
(project_id, system_name),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def jobs(self, run_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM jobs WHERE run_id = ? ORDER BY project_name", (run_id,)
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
# ------------------------------------------------------------------ events
|
||||
|
||||
def add_event(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
stage: str,
|
||||
action: str,
|
||||
project_id: str = "",
|
||||
detail: str = "",
|
||||
elapsed_ms: int = 0,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT INTO events (run_id, project_id, stage, action, detail, elapsed_ms, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(run_id, project_id, stage, action, detail[:2000], elapsed_ms, now()),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def events(self, run_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM events WHERE run_id = ? ORDER BY id", (run_id,)
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def run_info(self, run_id: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
row = self._conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def last_run(self) -> str | None:
|
||||
with self._lock:
|
||||
row = self._conn.execute("SELECT run_id FROM runs ORDER BY started_at DESC LIMIT 1").fetchone()
|
||||
return row["run_id"] if row else None
|
||||
@@ -0,0 +1,206 @@
|
||||
"""传输层:WCF + 通用存储过程网关。
|
||||
|
||||
只负责 HTTP 与协议细节:
|
||||
- 双层 JSON 解码(返回值常是「JSON 字符串里再套一层 JSON」)
|
||||
- HTML 错误页 → 可读异常
|
||||
- 网络层重试(仅幂等调用)
|
||||
- multipart 上传
|
||||
业务语义一律放在 api 层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
from .errors import Category, QtError
|
||||
|
||||
CallHook = Callable[[str, int, bool, str], None]
|
||||
|
||||
# 服务路径(来自前端 app.js 的配置模块 a078)
|
||||
COMMON_URL = "/Services/CommonService.svc/"
|
||||
FILE_URL = "/Services/FileOperationService.svc/"
|
||||
TFS_URL = "/Services/TFSOperationService.svc/"
|
||||
|
||||
|
||||
def _content_type(path: str) -> str:
|
||||
return mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
timeout: int = 60,
|
||||
retries: int = 2,
|
||||
on_call: CallHook | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
self.on_call = on_call
|
||||
|
||||
# ------------------------------------------------------------------ 内部
|
||||
|
||||
def _record(self, op: str, started: float, ok: bool, detail: str = "") -> None:
|
||||
if self.on_call:
|
||||
self.on_call(op, int((time.time() - started) * 1000), ok, detail)
|
||||
|
||||
def _post(
|
||||
self,
|
||||
path: str,
|
||||
payload: Any = None,
|
||||
*,
|
||||
op: str = "",
|
||||
retry: bool = False,
|
||||
raw_body: bytes | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
url = self.base_url + path
|
||||
if raw_body is not None:
|
||||
body = raw_body
|
||||
else:
|
||||
body = b"" if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
hdrs = {"Accept": "application/json"}
|
||||
if raw_body is None:
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
|
||||
attempts = self.retries + 1 if retry else 1
|
||||
started = time.time()
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(attempts):
|
||||
request = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
text = response.read().decode("utf-8-sig", "replace")
|
||||
self._record(op or path, started, True)
|
||||
return text
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8-sig", "replace")
|
||||
error = QtError.from_http(exc.code, detail)
|
||||
self._record(op or path, started, False, str(error))
|
||||
# 只有网络/服务端 5xx 才值得重试,业务错误重试无意义
|
||||
if error.retryable and attempt < attempts - 1:
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
raise error from None
|
||||
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as exc:
|
||||
last_error = exc
|
||||
self._record(op or path, started, False, f"网络错误:{exc}")
|
||||
if attempt < attempts - 1:
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
raise QtError(f"网络不可达:{exc}", Category.NET, retryable=True) from None
|
||||
raise QtError(f"请求失败:{last_error}", Category.NET)
|
||||
|
||||
@staticmethod
|
||||
def decode(text: str) -> Any:
|
||||
"""WCF 返回值可能是「JSON 字符串里再套一层 JSON」,最多解三层。"""
|
||||
value: Any = text.strip()
|
||||
for _ in range(3):
|
||||
if not isinstance(value, str):
|
||||
break
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
return value
|
||||
|
||||
# ------------------------------------------------------------------ 网关
|
||||
|
||||
def exec_table(
|
||||
self, connection: str, procedure: str, params: list[tuple[str, Any, str]], *, op: str = "", write: bool = False
|
||||
) -> Any:
|
||||
"""ExecTable:结果集语义。"""
|
||||
return self._exec("ExecTable", connection, procedure, params, op=op, write=write)
|
||||
|
||||
def exec_result(
|
||||
self, connection: str, procedure: str, params: list[tuple[str, Any, str]], *, op: str = ""
|
||||
) -> Any:
|
||||
"""ExecResult:标量/返回值语义(Result、Column1 等)。"""
|
||||
return self._exec("ExecResult", connection, procedure, params, op=op, write=True)
|
||||
|
||||
def _exec(
|
||||
self,
|
||||
method: str,
|
||||
connection: str,
|
||||
procedure: str,
|
||||
params: list[tuple[str, Any, str]],
|
||||
*,
|
||||
op: str,
|
||||
write: bool,
|
||||
) -> Any:
|
||||
inner = {
|
||||
"SPName": procedure,
|
||||
"SQLParams": [
|
||||
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
|
||||
for name, value, dbtype in params
|
||||
],
|
||||
}
|
||||
payload = {
|
||||
"connectionName": connection,
|
||||
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
|
||||
}
|
||||
text = self._post(f"{COMMON_URL}{method}", payload, op=op or procedure, retry=not write)
|
||||
return self.decode(text)
|
||||
|
||||
# ------------------------------------------------------------------ 文件
|
||||
|
||||
def upload(self, local_path: str, folder: str, *, op: str = "FileUpload") -> str:
|
||||
"""上传文件,返回服务器端绝对路径。
|
||||
|
||||
注意:必须发**裸字节**,不能套 multipart。前端把原生 File 对象直接交给
|
||||
axios(`data: e`,e 是 upload-file,`send(File)` 走的是 file type),WCF 侧
|
||||
按 `Stream` 接收后原样落盘。发 multipart 时服务器把整段 multipart 包体写进
|
||||
.xlsx,文件损坏 → 服务端解析出空 DataTable → SqlBulkCopyToDB 抛
|
||||
「Object reference not set to an instance of an object」。
|
||||
"""
|
||||
name = f"{int(time.time() * 1000)}{os.path.splitext(local_path)[1]}"
|
||||
query = urllib.parse.urlencode({"fileName": name, "fileFolder": folder})
|
||||
with open(local_path, "rb") as handle:
|
||||
body = handle.read()
|
||||
text = self._post(
|
||||
f"{FILE_URL}FileUpload?{query}",
|
||||
raw_body=body,
|
||||
headers={"Content-Type": _content_type(local_path)},
|
||||
op=op,
|
||||
retry=True, # 同名覆盖无副作用,可重试
|
||||
)
|
||||
result = self.decode(text)
|
||||
path = (result or {}).get("FileUploadResult") if isinstance(result, dict) else None
|
||||
if not path:
|
||||
raise QtError(f"上传未返回 FileUploadResult:{result!r}", Category.UNKNOWN)
|
||||
return path
|
||||
|
||||
def batch_import(self, procedure: str, params: list[tuple[str, Any, str]], file_path: str) -> Any:
|
||||
"""BatchImportFeature:服务器端解析 Excel 并写库。"""
|
||||
inner = {
|
||||
"SPName": procedure,
|
||||
"SQLParams": [
|
||||
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
|
||||
for name, value, dbtype in params
|
||||
],
|
||||
}
|
||||
payload = {
|
||||
"connectionName": "QuickTeam",
|
||||
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
|
||||
"filePath": file_path,
|
||||
}
|
||||
text = self._post(f"{FILE_URL}BatchImportFeature", payload, op="BatchImportFeature", retry=False)
|
||||
return self.decode(text)
|
||||
|
||||
# ------------------------------------------------------------------ TFS
|
||||
|
||||
def tfs(self, operation: str, payload: Any = None, *, op: str = "", write: bool = False) -> Any:
|
||||
text = self._post(f"{TFS_URL}{operation}", payload, op=op or f"TFS.{operation}", retry=not write)
|
||||
return self.decode(text)
|
||||
@@ -0,0 +1,416 @@
|
||||
"""workflow 层:单项目编排、阶段推进、补偿与并发。
|
||||
|
||||
阶段顺序固定,台账里记到哪一步,下次就从下一步开始 —— 这是断点续跑的唯一依据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from . import domain
|
||||
from .api import QuickTeamApi
|
||||
from .config import Config
|
||||
from .domain import Action, PlanRow
|
||||
from .errors import Category, QtError
|
||||
from .models import Project, User, WorkSheet, WorkSheetVersion
|
||||
from .report import RunLog
|
||||
from .store import Store
|
||||
|
||||
STAGES = ["WS_CREATED", "WS_FILLED", "EXCEL_IMPORTED", "WS_VERIFIED", "TFS_PUSHED"]
|
||||
STAGE_INDEX = {stage: index for index, stage in enumerate(STAGES)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunOptions:
|
||||
adopt: bool = False # 接管无台账记录的既有工单
|
||||
skip_tfs: bool = False # 只建单+导入+确认,不推送
|
||||
rollback_on_fail: bool = False
|
||||
limit: int | None = None
|
||||
projects: list[str] = field(default_factory=list)
|
||||
force: bool = False # 跳过 Excel 模板预检
|
||||
dry_run: bool = False # 只演练:不产生任何服务端写入
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectProgress:
|
||||
row: PlanRow
|
||||
stage: str
|
||||
worksheet_id: int | None = None
|
||||
version_id: int | None = None
|
||||
elapsed_ms: int = 0
|
||||
|
||||
|
||||
class Importer:
|
||||
def __init__(self, cfg: Config, store: Store, run_id: str, log: RunLog, options: RunOptions) -> None:
|
||||
self.cfg = cfg
|
||||
self.store = store
|
||||
self.run_id = run_id
|
||||
self.log = log
|
||||
self.options = options
|
||||
|
||||
self._thread_state = threading.local()
|
||||
self.api = QuickTeamApi(
|
||||
self._build_transport(),
|
||||
dry_run=options.dry_run,
|
||||
log=lambda message: self.log.info(message, level="dry"),
|
||||
)
|
||||
self._tfs_lock = threading.Lock()
|
||||
self._tfs_context: tuple[str, str] | None = None
|
||||
self.user: User | None = None
|
||||
self.team_name = ""
|
||||
|
||||
# ------------------------------------------------------------------ 装配
|
||||
|
||||
def _build_transport(self):
|
||||
from .transport import Transport
|
||||
|
||||
def on_call(op: str, elapsed: int, ok: bool, detail: str) -> None:
|
||||
project = getattr(self._thread_state, "project", "")
|
||||
self.store.add_event(
|
||||
self.run_id,
|
||||
stage="API",
|
||||
action="ok" if ok else "fail",
|
||||
project_id=getattr(self._thread_state, "project_id", ""),
|
||||
detail=f"{op} {detail}".strip(),
|
||||
elapsed_ms=elapsed,
|
||||
)
|
||||
self.log.event("API", "ok" if ok else "fail", project=project, detail=op, elapsed_ms=elapsed)
|
||||
|
||||
return Transport(
|
||||
self.cfg.base_url, timeout=self.cfg.timeout, retries=2, on_call=on_call
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ plan
|
||||
|
||||
def connect(self) -> User:
|
||||
if not self.user:
|
||||
self.user = self.api.login(self.cfg.username, self.cfg.password)
|
||||
teams = self.api.teams(self.user.user_id)
|
||||
team = self.api.resolve_team(teams, self.cfg.team_name)
|
||||
self.team_name = team.team_name
|
||||
self.team_id = team.team_id
|
||||
self.log.info(f"已登录:{self.user.real_name}({self.user.user_name}),团队:{team.team_name}")
|
||||
return self.user
|
||||
|
||||
def plan(self) -> list[PlanRow]:
|
||||
self.connect()
|
||||
projects = self.api.projects(self.team_id)
|
||||
files = domain.list_xlsx(self.cfg.work_dir)
|
||||
rows: list[PlanRow] = []
|
||||
|
||||
for path in files:
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
project, reason = domain.match_project(stem, projects)
|
||||
if project is None:
|
||||
rows.append(PlanRow(project_name=stem, file_path=path, action=Action.ERROR, reason=reason))
|
||||
continue
|
||||
|
||||
if not self.options.force:
|
||||
ok, why = domain.precheck_xlsx(path)
|
||||
if not ok:
|
||||
rows.append(
|
||||
PlanRow(project_name=project.project_name, file_path=path, action=Action.ERROR,
|
||||
project=project, reason=f"Excel 预检失败:{why}")
|
||||
)
|
||||
continue
|
||||
|
||||
ledger = self.store.latest_job(project.project_id, self.cfg.sprint)
|
||||
existing = [
|
||||
sheet for sheet in self.api.work_sheets(project.project_id)
|
||||
if sheet.system_name == self.cfg.sprint
|
||||
]
|
||||
|
||||
action, why, worksheet = domain.decide(
|
||||
existing, ledger_stage=ledger["stage"] if ledger else None, adopt=self.options.adopt
|
||||
)
|
||||
rows.append(
|
||||
PlanRow(
|
||||
project_name=project.project_name,
|
||||
file_path=path,
|
||||
action=action,
|
||||
reason=why,
|
||||
project=project,
|
||||
worksheet=worksheet,
|
||||
existing_count=len(existing),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
# ------------------------------------------------------------------ apply
|
||||
|
||||
def apply(self) -> list[ProjectProgress]:
|
||||
rows = self.plan()
|
||||
write_rows = [row for row in rows if row.action.writes]
|
||||
if self.options.projects:
|
||||
wanted = {domain.normalize_name(name) for name in self.options.projects}
|
||||
write_rows = [row for row in write_rows if domain.normalize_name(row.project_name) in wanted]
|
||||
if self.options.limit:
|
||||
write_rows = write_rows[: self.options.limit]
|
||||
|
||||
# 非执行行也落台账,报告里才能看到「为什么跳过」
|
||||
for row in rows:
|
||||
if row.action.writes:
|
||||
continue
|
||||
project_id = row.project.project_id if row.project else row.project_name
|
||||
if self.store.latest_job(project_id, self.cfg.sprint):
|
||||
# 已经有真实进度记录,别用 SKIPPED 覆盖它(否则下次会把「已推送」当成「无进度」)
|
||||
self.log.event(str(row.action), "skip", project=row.project_name, detail=row.reason)
|
||||
continue
|
||||
self.store.upsert_job(
|
||||
self.run_id,
|
||||
project_id=project_id,
|
||||
project_name=row.project_name,
|
||||
system_name=self.cfg.sprint,
|
||||
source_file=row.file_path,
|
||||
action=str(row.action),
|
||||
stage="SKIPPED" if row.action != Action.ERROR else "FAILED",
|
||||
worksheet_id=row.worksheet.worksheet_id if row.worksheet else None,
|
||||
reason=row.reason,
|
||||
error=row.reason if row.action == Action.ERROR else "",
|
||||
)
|
||||
self.log.event(str(row.action), "skip", project=row.project_name, detail=row.reason)
|
||||
|
||||
if not write_rows:
|
||||
self.log.info("没有需要执行的项目")
|
||||
return []
|
||||
|
||||
self.log.info(f"开始执行 {len(write_rows)} 个项目(并发 {self.cfg.workers})")
|
||||
results: list[ProjectProgress] = []
|
||||
with ThreadPoolExecutor(max_workers=max(1, self.cfg.workers)) as pool:
|
||||
futures = {pool.submit(self._run_project, row): row for row in write_rows}
|
||||
for future in as_completed(futures):
|
||||
row = futures[future]
|
||||
try:
|
||||
results.append(future.result())
|
||||
except QtError as error:
|
||||
if error.category in {Category.AUTH, Category.SP_MISSING}:
|
||||
self.log.warn(f"中止:{error}")
|
||||
raise
|
||||
results.append(ProjectProgress(row=row, stage="FAILED"))
|
||||
self.log.warn(f"{row.project_name} 失败:{error}")
|
||||
except Exception as error: # 兜底,避免一个项目的异常带崩整批
|
||||
results.append(ProjectProgress(row=row, stage="FAILED"))
|
||||
self.log.warn(f"{row.project_name} 未预期异常:{error!r}")
|
||||
return results
|
||||
|
||||
def _run_project(self, row: PlanRow) -> ProjectProgress:
|
||||
assert row.project is not None
|
||||
started = time.time()
|
||||
self._thread_state.project = row.project_name
|
||||
self._thread_state.project_id = row.project.project_id
|
||||
|
||||
ledger = self.store.latest_job(row.project.project_id, self.cfg.sprint)
|
||||
if row.action == Action.CREATE:
|
||||
# plan 已经确认服务端没有该 Sprint 的工单,台账里的旧记录可能来自已被回滚的
|
||||
# 半成品(工单已删除)。此时必须忽略台账,否则会去填一张不存在的工单。
|
||||
ledger = None
|
||||
done_upto = self._completed_index(ledger["stage"] if ledger else "")
|
||||
worksheet_id = (ledger or {}).get("worksheet_id") or (
|
||||
row.worksheet.worksheet_id if row.worksheet else None
|
||||
)
|
||||
version_id: int | None = (ledger or {}).get("version_id")
|
||||
|
||||
def need(stage: str) -> bool:
|
||||
return STAGE_INDEX[stage] > done_upto
|
||||
|
||||
stage = "PLANNED"
|
||||
created_here = row.action == Action.CREATE
|
||||
try:
|
||||
if need("WS_CREATED"):
|
||||
stage = "WS_CREATED"
|
||||
worksheet_id = self.api.create_worksheet()
|
||||
self._mark(row, stage, worksheet_id, None)
|
||||
|
||||
if need("WS_FILLED"):
|
||||
stage = "WS_FILLED"
|
||||
assert worksheet_id is not None
|
||||
evaluator, technical, customer = self.cfg.resolved_roles(self.user.real_name)
|
||||
self.api.fill_worksheet(
|
||||
worksheet_id,
|
||||
row.project,
|
||||
system_name=self.cfg.sprint,
|
||||
start_time=self.cfg.start_time,
|
||||
end_time=self.cfg.end_time,
|
||||
evaluator=evaluator,
|
||||
technical_leader=technical,
|
||||
customer_leader=customer,
|
||||
belong_team=self.cfg.belong_team,
|
||||
content=self.cfg.content_desc,
|
||||
operator=self.user.real_name,
|
||||
service_type=self.cfg.service_type,
|
||||
)
|
||||
self._mark(row, stage, worksheet_id, None)
|
||||
|
||||
if need("EXCEL_IMPORTED"):
|
||||
stage = "EXCEL_IMPORTED"
|
||||
assert worksheet_id is not None
|
||||
version = self._version(row.project.project_id, worksheet_id)
|
||||
version_id = version.version_id
|
||||
remote = self.api.upload_excel(row.file_path)
|
||||
self.api.import_excel(worksheet_id, version_id, remote)
|
||||
self._mark(row, stage, worksheet_id, version_id)
|
||||
|
||||
if need("WS_VERIFIED"):
|
||||
stage = "WS_VERIFIED"
|
||||
assert worksheet_id is not None
|
||||
detail = self._detail(worksheet_id)
|
||||
version = self._version(row.project.project_id, worksheet_id)
|
||||
if detail.done:
|
||||
self.log.event(stage, "skip", project=row.project_name, detail="工单已完成,无需确认")
|
||||
elif version is None:
|
||||
raise QtError(f"工单 {worksheet_id} 没有版本记录,无法确认", Category.PARAM)
|
||||
elif domain.VERSION_TRANSITIONS.get(version.version_code) is None:
|
||||
self.log.event(stage, "skip", project=row.project_name, detail="已是流程终点,无需确认")
|
||||
else:
|
||||
try:
|
||||
next_operator, next_code, percent = domain.next_transition(version.version_code)
|
||||
except ValueError as error:
|
||||
raise QtError(str(error), Category.PARAM) from None
|
||||
self.api.verify_worksheet(
|
||||
worksheet_id,
|
||||
version.version_id,
|
||||
next_operator=next_operator,
|
||||
next_version_code=next_code,
|
||||
operator=self.user.real_name,
|
||||
percent=percent,
|
||||
)
|
||||
self._mark(row, stage, worksheet_id, version.version_id if version else version_id)
|
||||
|
||||
if need("TFS_PUSHED"):
|
||||
assert worksheet_id is not None
|
||||
if self.options.skip_tfs:
|
||||
self.log.event("TFS_PUSHED", "skip", project=row.project_name, detail="--skip-tfs")
|
||||
else:
|
||||
stage = "TFS_PUSHED"
|
||||
area, iteration = self._resolve_tfs()
|
||||
features = [] if self.api.dry_run else [
|
||||
item.feature_id for item in self.api.features(worksheet_id)
|
||||
]
|
||||
if not features and not self.api.dry_run:
|
||||
raise QtError("工单没有功能模块,跳过推送(请检查 Excel 是否为空表)", Category.EXCEL)
|
||||
created = self.api.push_tfs(features, self.cfg.tfs_project, area, iteration)
|
||||
self.log.event(
|
||||
"TFS_PUSHED", "ok", project=row.project_name,
|
||||
detail=f"features={len(features)} 创建={created}",
|
||||
)
|
||||
self._mark(row, stage, worksheet_id, version_id)
|
||||
|
||||
elapsed = int((time.time() - started) * 1000)
|
||||
self.log.info(f" ✓ {row.project_name} → {stage} {elapsed / 1000:.1f}s")
|
||||
return ProjectProgress(row=row, stage=stage, worksheet_id=worksheet_id,
|
||||
version_id=version_id, elapsed_ms=elapsed)
|
||||
|
||||
except QtError as error:
|
||||
elapsed = int((time.time() - started) * 1000)
|
||||
self.store.upsert_job(
|
||||
self.run_id,
|
||||
project_id=row.project.project_id,
|
||||
project_name=row.project_name,
|
||||
system_name=self.cfg.sprint,
|
||||
source_file=row.file_path,
|
||||
action=str(row.action),
|
||||
stage=f"FAILED:{stage}",
|
||||
worksheet_id=worksheet_id,
|
||||
version_id=version_id,
|
||||
reason=row.reason,
|
||||
error=str(error),
|
||||
)
|
||||
self.store.add_event(
|
||||
self.run_id, stage=stage, action="fail", project_id=row.project.project_id,
|
||||
detail=str(error), elapsed_ms=elapsed,
|
||||
)
|
||||
self.log.warn(f"{row.project_name} 在 {stage} 失败:{error}")
|
||||
|
||||
if self.options.rollback_on_fail and created_here and STAGE_INDEX[stage] < STAGE_INDEX["WS_VERIFIED"]:
|
||||
self._rollback(row, worksheet_id, stage)
|
||||
if error.category in {Category.AUTH, Category.SP_MISSING}:
|
||||
raise
|
||||
return ProjectProgress(row=row, stage=f"FAILED:{stage}", worksheet_id=worksheet_id,
|
||||
version_id=version_id, elapsed_ms=elapsed)
|
||||
finally:
|
||||
self._thread_state.project = ""
|
||||
self._thread_state.project_id = ""
|
||||
|
||||
# ------------------------------------------------------------------ 辅助
|
||||
|
||||
@staticmethod
|
||||
def _completed_index(stage: str) -> int:
|
||||
"""台账阶段 → 已完成的阶段下标。
|
||||
|
||||
`FAILED:EXCEL_IMPORTED` 表示「导入这一步失败了」,所以它之前的都算完成,
|
||||
重跑要从 EXCEL_IMPORTED 本身重试,而不是从头再来(否则会重复建单)。
|
||||
"""
|
||||
if not stage:
|
||||
return -1
|
||||
if stage.startswith("FAILED:"):
|
||||
failed_at = stage.split(":", 1)[1]
|
||||
return STAGE_INDEX.get(failed_at, 0) - 1
|
||||
return STAGE_INDEX.get(stage, -1)
|
||||
|
||||
def _detail(self, worksheet_id: int) -> WorkSheet:
|
||||
if self.api.dry_run:
|
||||
return WorkSheet(worksheet_id=worksheet_id, version_id=0, version_code="")
|
||||
return self.api.worksheet(worksheet_id)
|
||||
|
||||
def _version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion | None:
|
||||
"""当前待确认版本。导入明细与确认工单都挂在它上面。
|
||||
|
||||
`WorkSheet.CurrentVersionId` 在第一次保存后仍为 NULL(要等确认才回填),
|
||||
所以不能拿它当 VersionId 用 —— 那会把 WorkSheetTask 插到不存在的版本上,
|
||||
报 FK_WorkSheetTask_WorkSheetVersion 冲突。
|
||||
"""
|
||||
if self.api.dry_run:
|
||||
return WorkSheetVersion(version_id=0, worksheet_id=worksheet_id,
|
||||
version_code="技术责任人确认版", version_status="N")
|
||||
return self.api.pending_version(project_id, worksheet_id)
|
||||
|
||||
def _resolve_tfs(self) -> tuple[str, str]:
|
||||
if self.cfg.area_path and self.cfg.iteration_path:
|
||||
return self.cfg.area_path, self.cfg.iteration_path
|
||||
with self._tfs_lock:
|
||||
if self._tfs_context:
|
||||
return self._tfs_context
|
||||
projects = self.api.tfs_projects()
|
||||
target = next((p for p in projects if p.get("Name") == self.cfg.tfs_project), None)
|
||||
if target is None:
|
||||
raise QtError(f"TFS 项目「{self.cfg.tfs_project}」不存在", Category.TFS)
|
||||
teams = self.api.tfs_teams(self.cfg.tfs_project, target["ProjectId"])
|
||||
area, iteration = QuickTeamApi.resolve_tfs_context(teams, self.cfg.tfs_team, self.cfg.sprint)
|
||||
self._tfs_context = (area, iteration)
|
||||
self.log.info(f"TFS 上下文:area={area} iteration={iteration}")
|
||||
return self._tfs_context
|
||||
|
||||
def _mark(
|
||||
self, row: PlanRow, stage: str, worksheet_id: int | None, version_id: int | None
|
||||
) -> None:
|
||||
assert row.project is not None
|
||||
self.store.upsert_job(
|
||||
self.run_id,
|
||||
project_id=row.project.project_id,
|
||||
project_name=row.project_name,
|
||||
system_name=self.cfg.sprint,
|
||||
source_file=row.file_path,
|
||||
action=str(row.action),
|
||||
stage=stage,
|
||||
worksheet_id=worksheet_id,
|
||||
version_id=version_id,
|
||||
reason=row.reason,
|
||||
)
|
||||
self.log.event(stage, "ok", project=row.project_name, detail=f"ws={worksheet_id}")
|
||||
|
||||
def _rollback(self, row: PlanRow, worksheet_id: int | None, stage: str) -> None:
|
||||
if not worksheet_id:
|
||||
return
|
||||
try:
|
||||
self.api.delete_worksheet(worksheet_id)
|
||||
self.store.add_event(
|
||||
self.run_id, stage=stage, action="rollback", project_id=row.project.project_id,
|
||||
detail=f"已删除半成品工单 {worksheet_id}",
|
||||
)
|
||||
self.log.warn(f"{row.project_name} 已回滚(删除工单 {worksheet_id})")
|
||||
except QtError as error:
|
||||
self.log.warn(f"{row.project_name} 回滚失败:{error}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 'UserId' is missing.'. See server logs</p>"
|
||||
self.assertIn("UserId", extract_exception_message(body))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user