From 58850314cf476a7186e9d168f48c75a240c133e6 Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Thu, 5 Nov 2026 19:14:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9AQu?= =?UTF-8?q?ickTeam=20=E5=B7=A5=E5=8D=95=E6=8E=A5=E5=8F=A3=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用「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 个单测通过。 --- .gitignore | 24 + README.md | 138 ++++ config/config.example.json | 27 + docs/QuickTeam-API导入-调研与设计.md | 724 ++++++++++++++++++ ...sp_BatchImportWorkFeaturesByFeatureIds.sql | 11 + .../sp/usp_GetWeeklyWorkSheetsByProjectId.sql | 38 + docs/sp/usp_GetWorkSheetById.sql | 12 + docs/sp/usp_GetWorkSheetsById.sql | 77 ++ docs/sp/usp_InsertWorkSheet.sql | 55 ++ docs/sp/usp_UpdateWorkSheetById.sql | 68 ++ docs/sp/usp_VerifyWorkSheetById.sql | 51 ++ pyproject.toml | 16 + qtimport/__init__.py | 3 + qtimport/__main__.py | 206 +++++ qtimport/api.py | 454 +++++++++++ qtimport/config.py | 93 +++ qtimport/domain.py | 131 ++++ qtimport/errors.py | 58 ++ qtimport/models.py | 104 +++ qtimport/report.py | 115 +++ qtimport/store.py | 207 +++++ qtimport/transport.py | 206 +++++ qtimport/workflow.py | 416 ++++++++++ tests/__init__.py | 0 tests/test_api.py | 333 ++++++++ tests/test_domain.py | 140 ++++ tests/test_store.py | 76 ++ tests/test_transport.py | 63 ++ tests/test_workflow.py | 211 +++++ tools/api_probe.py | 365 +++++++++ tools/diag_import.py | 109 +++ tools/sp_source.py | 59 ++ 32 files changed, 4590 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.example.json create mode 100644 docs/QuickTeam-API导入-调研与设计.md create mode 100644 docs/sp/usp_BatchImportWorkFeaturesByFeatureIds.sql create mode 100644 docs/sp/usp_GetWeeklyWorkSheetsByProjectId.sql create mode 100644 docs/sp/usp_GetWorkSheetById.sql create mode 100644 docs/sp/usp_GetWorkSheetsById.sql create mode 100644 docs/sp/usp_InsertWorkSheet.sql create mode 100644 docs/sp/usp_UpdateWorkSheetById.sql create mode 100644 docs/sp/usp_VerifyWorkSheetById.sql create mode 100644 pyproject.toml create mode 100644 qtimport/__init__.py create mode 100644 qtimport/__main__.py create mode 100644 qtimport/api.py create mode 100644 qtimport/config.py create mode 100644 qtimport/domain.py create mode 100644 qtimport/errors.py create mode 100644 qtimport/models.py create mode 100644 qtimport/report.py create mode 100644 qtimport/store.py create mode 100644 qtimport/transport.py create mode 100644 qtimport/workflow.py create mode 100644 tests/__init__.py create mode 100644 tests/test_api.py create mode 100644 tests/test_domain.py create mode 100644 tests/test_store.py create mode 100644 tests/test_transport.py create mode 100644 tests/test_workflow.py create mode 100644 tools/api_probe.py create mode 100644 tools/diag_import.py create mode 100644 tools/sp_source.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ed4054 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..76336ec --- /dev/null +++ b/README.md @@ -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": "" } +``` + +前端本身就是这套接口的客户端,复刻调用序列即可绕开浏览器与 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` 中。 diff --git a/config/config.example.json b/config/config.example.json new file mode 100644 index 0000000..16e705b --- /dev/null +++ b/config/config.example.json @@ -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" +} diff --git a/docs/QuickTeam-API导入-调研与设计.md b/docs/QuickTeam-API导入-调研与设计.md new file mode 100644 index 0000000..aa4b1c5 --- /dev/null +++ b/docs/QuickTeam-API导入-调研与设计.md @@ -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\.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=.xlsx&fileFolder=WorkSheetImport` | `multipart/form-data`,文件字段名不限(实测 `file`/`upload` 均可) | `{"FileUploadResult":"E:\\TSSWCFServices\\FileBaseFoloder\\WorkSheetImport\\.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:, 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//summary.md`:项目 / 文件 / 工单号 / 阶段 / 耗时 / 结论(create|skip|resume|fail); +- `reports//detail.csv`:逐阶段明细,便于 Excel 复核; +- `logs/.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 个文件): + +``` +[登录] 陈首林 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 分钟,且失败重跑可能重复建单。 diff --git a/docs/sp/usp_BatchImportWorkFeaturesByFeatureIds.sql b/docs/sp/usp_BatchImportWorkFeaturesByFeatureIds.sql new file mode 100644 index 0000000..0f5a5a6 --- /dev/null +++ b/docs/sp/usp_BatchImportWorkFeaturesByFeatureIds.sql @@ -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' diff --git a/docs/sp/usp_GetWeeklyWorkSheetsByProjectId.sql b/docs/sp/usp_GetWeeklyWorkSheetsByProjectId.sql new file mode 100644 index 0000000..f91e0f1 --- /dev/null +++ b/docs/sp/usp_GetWeeklyWorkSheetsByProjectId.sql @@ -0,0 +1,38 @@ +-- ============================================= +-- Author: +-- Create date: +-- 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 diff --git a/docs/sp/usp_GetWorkSheetById.sql b/docs/sp/usp_GetWorkSheetById.sql new file mode 100644 index 0000000..4b130fb --- /dev/null +++ b/docs/sp/usp_GetWorkSheetById.sql @@ -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 + + diff --git a/docs/sp/usp_GetWorkSheetsById.sql b/docs/sp/usp_GetWorkSheetsById.sql new file mode 100644 index 0000000..2709d7e --- /dev/null +++ b/docs/sp/usp_GetWorkSheetsById.sql @@ -0,0 +1,77 @@ +-- ============================================= +-- Author: +-- Create date: +-- 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 diff --git a/docs/sp/usp_InsertWorkSheet.sql b/docs/sp/usp_InsertWorkSheet.sql new file mode 100644 index 0000000..fe0e752 --- /dev/null +++ b/docs/sp/usp_InsertWorkSheet.sql @@ -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 diff --git a/docs/sp/usp_UpdateWorkSheetById.sql b/docs/sp/usp_UpdateWorkSheetById.sql new file mode 100644 index 0000000..c43c74a --- /dev/null +++ b/docs/sp/usp_UpdateWorkSheetById.sql @@ -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 diff --git a/docs/sp/usp_VerifyWorkSheetById.sql b/docs/sp/usp_VerifyWorkSheetById.sql new file mode 100644 index 0000000..19d86f4 --- /dev/null +++ b/docs/sp/usp_VerifyWorkSheetById.sql @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..55e240f --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/qtimport/__init__.py b/qtimport/__init__.py new file mode 100644 index 0000000..b3947f2 --- /dev/null +++ b/qtimport/__init__.py @@ -0,0 +1,3 @@ +"""QuickTeam 工单接口导入工具。""" + +__version__ = "0.1.0" diff --git a/qtimport/__main__.py b/qtimport/__main__.py new file mode 100644 index 0000000..1e13be1 --- /dev/null +++ b/qtimport/__main__.py @@ -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()) diff --git a/qtimport/api.py b/qtimport/api.py new file mode 100644 index 0000000..25981d0 --- /dev/null +++ b/qtimport/api.py @@ -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 "" + 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")), + ) diff --git a/qtimport/config.py b/qtimport/config.py new file mode 100644 index 0000000..b46523f --- /dev/null +++ b/qtimport/config.py @@ -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) diff --git a/qtimport/domain.py b/qtimport/domain.py new file mode 100644 index 0000000..e526994 --- /dev/null +++ b/qtimport/domain.py @@ -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, "" diff --git a/qtimport/errors.py b/qtimport/errors.py new file mode 100644 index 0000000..7abd02f --- /dev/null +++ b/qtimport/errors.py @@ -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] diff --git a/qtimport/models.py b/qtimport/models.py new file mode 100644 index 0000000..277640b --- /dev/null +++ b/qtimport/models.py @@ -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) diff --git a/qtimport/report.py b/qtimport/report.py new file mode 100644 index 0000000..093b3b7 --- /dev/null +++ b/qtimport/report.py @@ -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 diff --git a/qtimport/store.py b/qtimport/store.py new file mode 100644 index 0000000..46e79c9 --- /dev/null +++ b/qtimport/store.py @@ -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 diff --git a/qtimport/transport.py b/qtimport/transport.py new file mode 100644 index 0000000..baf9220 --- /dev/null +++ b/qtimport/transport.py @@ -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) diff --git a/qtimport/workflow.py b/qtimport/workflow.py new file mode 100644 index 0000000..51689ae --- /dev/null +++ b/qtimport/workflow.py @@ -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}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..400154f --- /dev/null +++ b/tests/test_api.py @@ -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() diff --git a/tests/test_domain.py b/tests/test_domain.py new file mode 100644 index 0000000..9951623 --- /dev/null +++ b/tests/test_domain.py @@ -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 = "" + "".join(f"{text}" for text in strings) + "" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("xl/workbook.xml", "") + 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() diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..fc037ca --- /dev/null +++ b/tests/test_store.py @@ -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() diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..f081c78 --- /dev/null +++ b/tests/test_transport.py @@ -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 +
+

Request Error

+

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.

+
""" + + +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(""), "") + + +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 = "

The exception message is 'Procedure or function usp_X has too many arguments " + body += "specified.'. See server logs

" + self.assertEqual(QtError.from_http(400, body).category, Category.PARAM) + + def test_server_error_retryable(self) -> None: + error = QtError.from_http(503, "

Service Unavailable

") + self.assertTrue(error.retryable) + + def test_html_unescaped(self) -> None: + body = "

The exception message is 'Parameter 'UserId' is missing.'. See server logs

" + self.assertIn("UserId", extract_exception_message(body)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000..d02c150 --- /dev/null +++ b/tests/test_workflow.py @@ -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() diff --git a/tools/api_probe.py b/tools/api_probe.py new file mode 100644 index 0000000..e23e596 --- /dev/null +++ b/tools/api_probe.py @@ -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) diff --git a/tools/diag_import.py b/tools/diag_import.py new file mode 100644 index 0000000..93dee3f --- /dev/null +++ b/tools/diag_import.py @@ -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()) diff --git a/tools/sp_source.py b/tools/sp_source.py new file mode 100644 index 0000000..570bfa4 --- /dev/null +++ b/tools/sp_source.py @@ -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())