Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f90f742c1e |
@@ -3,6 +3,15 @@
|
||||
本文件对应技能市场 **「更新日志」** Tab。
|
||||
发布 CI 会按当前 tag 版本提取匹配的 `##` 小节,写入该版本的 `changelog`。
|
||||
|
||||
## 1.0.2
|
||||
|
||||
- 状态标签修正:台账关键字段缺失且偏差未判定时正确标记「待补录」,优先级与需求口径一致
|
||||
- 月度播报任务中心可见逐项进度(按项目增量上报,批量播报不再"算完才动")
|
||||
- 《经费执行简报》增强:新增独立「暂付款 / 结题提醒」章节,标题转义防注入,页脚口径统一
|
||||
- 播报记录回填项目关联与规则命中明细,简报重建不再丢失金额与提醒
|
||||
- 本地库旧版本自动迁移(补列安全重建),历史播报数据保留
|
||||
- 金标准测试补齐阈值恰界(±10%/20%)、序时钳位、提醒反例与 HTML 转义用例
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- 首个版本:项目经费"智能进度播报"上线
|
||||
|
||||
2
SKILL.md
2
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 项目经费进度播报助手
|
||||
description: "高校项目经费执行监测与播报:按项目台账自动计算实际支出率与序时进度,识别严重滞后/支出异常偏快,生成三段式播报摘要、暂付款冲销与结题报销提醒,支持月度定时播报并输出《经费执行简报》。"
|
||||
version: 1.0.1
|
||||
version: 1.0.2
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
|
||||
166
acceptance-reports/20260924-105401-acceptance.md
Normal file
166
acceptance-reports/20260924-105401-acceptance.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# 验收报告 —— track-fund-progress-default(项目经费进度播报助手)
|
||||
|
||||
- 相对路径:`acceptance-reports/20260924-105401-acceptance.md`
|
||||
- 技能 slug:`track-fund-progress-default`
|
||||
- git 提交短哈希:`a91ead3`(工作区含本轮未提交整改,见文末「本轮整改记录」)
|
||||
- checklist_version:`1.0`
|
||||
- 验收执行时间:2026-09-24 10:51–10:54(本地)
|
||||
- 验收环境:Windows,Python `D:\jiangchang\huashang\.venv\Scripts\python.exe`(jiangchang-platform-kit 1.2.12),数据根 `D:\jiangchang-data`(用户 10032)
|
||||
- 验收方式:CLI 端到端实测 + 默认测试套件 + 代码/文档静态核对;宿主 UI(数据管理页面、任务中心、市场)未接入,相关条目按 `NEED_MANUAL` 如实记录为**未做**
|
||||
|
||||
---
|
||||
|
||||
## 0. 技能画像(验收前填写,用于裁剪)
|
||||
|
||||
- [x] S0-1 交互形态:**纯本地**(本地 SQLite 计算,无外部 HTTP/RPA)
|
||||
- [x] S0-2 目标站登录策略:**not_needed**
|
||||
- [x] S0-3 是否依赖账号会话:**否**
|
||||
- [x] S0-4 持久化:**另有业务表**(fund_projects / progress_reports / alert_rules)
|
||||
- [x] S0-5 Skill Action:**含异步长任务**(broadcast-monthly = async)
|
||||
- [x] S0-6 是否需要在任务中心查看进度或结果:**是**
|
||||
- [x] S0-7 授权来源:无外部系统账号/数据权限;本地台账数据由宿主用户录入,执行走宿主 entitlement
|
||||
- [x] S0-8 不可逆外部副作用:**否**(只读计算 + 本地记录/HTML 写入;无付款/删除/发送)
|
||||
- [x] S0-9 入口定位表:**已填写**(`development/REQUIREMENTS.md` §0.1,与 `assets/actions.json`、开发文档 §9 三方一致)
|
||||
- [x] S0-10 主用户路径:**对话 + 数据管理 + 定时**(与定位表一致)
|
||||
- [x] S0-11 业务行生命周期:**不适用**(REQUIREMENTS §0.3 已声明:无处理队列状态机)
|
||||
- [x] S0-12 HTML 页面报告:**有**(`{data}/html/*.html` + `render-html`,侧栏预览)
|
||||
|
||||
---
|
||||
|
||||
## 1. 安装与可运行
|
||||
|
||||
- [ ] A1-1 **P0** **NEED_MANUAL(未做)** 按平台方式加载后技能可被识别、打开详情或执行入口。已做:CLI 侧 `health`/`version`/全部子命令可执行。未做:宿主页打开技能详情(需人工真机)。
|
||||
- [x] A1-2 **P0** **PASS** `python scripts/main.py health` 输出 `health: ok`、`platform_kit_version_ok: True`、env 路径可读,exit 0。
|
||||
- [x] A1-3 **P0** **PASS** `version` 返回 `1.0.1`;与 `scripts/util/constants.py:4 SKILL_VERSION`、`SKILL.md:4 version`、`CHANGELOG.md ## 1.0.1`、git tag `v1.0.1` 一致。
|
||||
- [x] A1-4 **P1** **PASS** 用户可见配置(4 条预警阈值):`view-rules` 可读;`set-rule lag_threshold 0.70` 后同一项目判定由「严重滞后」翻转为「正常」,恢复 `0.10` 后回到「严重滞后」(实测 report_id 7/8),行为与说明一致。
|
||||
|
||||
## 2. 对外说明是否自洽
|
||||
|
||||
- [x] A2-1 **P0** **PASS** README 面向业务用户说明做什么/给谁用/准备什么(台账 5 类字段)。
|
||||
- [x] A2-2 **P0** **PASS** 用户主路径为对话/数据管理操作,不以开发目录为入口。
|
||||
- [x] A2-3 **P1** **PASS** TUTORIAL 按准备→入口一/二/三→常见卡住结构编排,`test_tutorial_surfaces` 通过。
|
||||
- [x] A2-4 **P1** **PASS** DEMO.md 有演示信息位;`demo_video_url: ""` 并明示「无视频时的图文演示脚本」,不假装有视频。
|
||||
- [x] A2-5 **P0** **PASS** CHANGELOG 首节 `## 1.0.1` 对应当前交付版本;`test_docs_standards` 通过。
|
||||
- [x] A2-6 **P0** **PASS** SKILL.md 意图路由区分即时播报(sync)与月度播报(async 长任务),与真实能力一致。
|
||||
- [x] A2-7 **P0** **PASS** README 无分步 1/2/3、无 Agent 话术块、无入口专章(`test_tutorial_surfaces` 反模式约束通过)。
|
||||
- [x] A2-8 **P0** **PASS** 教程覆盖定位表全部「挂」入口(数据管理 toolbar/row、对话、定时入口三),`test_business_skill_tutorial_aligns_with_actions` 通过。
|
||||
- [x] A2-9 **P0** **PASS** 有 cron:教程入口三写明宿主定时任务建「技能直调」选「月度经费播报」及每月 5 号 9:00 场景。
|
||||
- [x] A2-10 **P1** **PASS** 教程主能力对照表与 `actions.json` label 一致(tutorial 对齐测试通过)。
|
||||
- [x] A2-11 **P1** **PASS** 数据管理主路径入口在 toolbar(add-project 录入、broadcast-progress 播报、render-html 重建),教程不以 skill-detail 为唯一路径。
|
||||
- [x] A2-12 **P0** **PASS** 说明/教程无 `# 技能名` 开篇(`test_market_docs_forbid_redundant_page_h1` 通过)。
|
||||
|
||||
## 3. 主能力行为
|
||||
|
||||
- [x] A3-1 **P0** **PASS** 主成功路径闭环实测(as_of=2026-08-15):①NSFC2026001 → 正常/🟢(report-9);②LAG2026001 → 严重滞后/🔴+冲销提醒(report-10);③FAST2026001 → 支出异常偏快/🟠+结题提醒(report-11);`broadcast-monthly` 3 项目 + 汇总页 `brief-006ab490ad2f5ddd.html`。
|
||||
- [x] A3-2 **P0** **PASS** 缺参/非法输入稳定失败:`broadcast --project-no NOPE123` → `ERROR:NO_PROJECTS` exit 1;`add-project --start 2026/13/40` → `ERROR:PROJECT_INVALID` exit 1,不假成功。
|
||||
- [x] A3-3 **P1** **PASS** 失败可诊断:`{data}/10032/logs/jiangchang.log` 有 `task_failed ... error_code=PROJECT_INVALID detail=...`,可定位到 stage(project/rules/broadcast/…)。
|
||||
- [x] A3-4 **P0** **PASS** 多入口共用同一内核:CLI 与 Action 均落 `scripts/main.py` 同一子命令,`fund_service.run_broadcast` 唯一内核(代码结构 + `test_service` 类用例;宿主侧触发效果见 A9-3)。
|
||||
- [x] A3-5 **P0** **PASS** 定位表已填写;「挂」的入口在 `actions.json` 有对应声明(13 个 Action,`test_actions_manifest` jsonschema/entrypoint/bind 校验通过)。
|
||||
- [x] A3-6 **P1** **PASS** 「不挂」入口均有书面理由(定位表「不挂理由」列逐行填写)。
|
||||
|
||||
## 4. 调度与长任务
|
||||
|
||||
- [x] A4-1 **P0** **PASS** 秒级短操作:单项目 `broadcast` 实测 <1s 返回 JSON(三场景各约 0.3s)。
|
||||
- [ ] A4-2 **P0** **NEED_MANUAL(未做)** 长任务经 Agent/动作触发后进入任务中心。已做:broadcast-monthly 为 async + `job_context`/`emit`/`finish` 代码路径完整、CLI 正常终态。未做:宿主任务中心真机确认(需人工)。
|
||||
- [ ] A4-3 **P0** **NEED_MANUAL(未做)** 任务中心进行中/结束状态与成功失败结论。已做:增量 `emit` 回调(`[i/n] 项目:状态`)+ 单测 `test_on_progress_called_per_project`。未做:宿主任务中心真机观察(需人工)。
|
||||
- [ ] A4-4 **P1** **NEED_MANUAL(未做)** 进度文案可理解性需在宿主任务中心真机查看;文案设计为 `[1/3] 钙钛矿…:正常` 形式(代码层已核)。
|
||||
- [x] A4-5 **P1** **PASS** 动作参数覆盖主路径:broadcast-progress 暴露 projectNo/pi/asOf;broadcast-monthly 零参缺省=全部在研(安全无人值守);`--all-status` 为可选扩展且已在 §8/定位表补记。
|
||||
- [ ] A4-6 **P1** **N/A** 无「引用同库另一张业务表」的动作参数(set-rule 经 row 行绑定预填本表 rule_key,非手填内部编号)。
|
||||
- [ ] A4-7 **P1** **N/A** 无互斥采集/操作模式。
|
||||
- [ ] A4-8 **P1** **NEED_MANUAL(未做)** set-rule 行按钮打开的 form 参数区是否重复映射主键,须宿主真机打开确认(代码层:inputMapping 仅映射 rule_key,无 visibleWhen 藏字段)。
|
||||
|
||||
## 5. 账号与浏览器会话
|
||||
|
||||
- 本技能纯本地、无浏览器/账号会话(画像 S0-1/S0-2/S0-3),A5-1 ~ A5-12 全部 **N/A**(无网页 RPA 形态,等价项已在 §1–§4 覆盖)。
|
||||
|
||||
## 6. 结果落点
|
||||
|
||||
- [ ] A6-1 **P0** **NEED_MANUAL(未做)** 宿主任务中心/任务记录中可见对应条目。已做:CLI `{"type":"result",...}` 终态事件 + 日志 `task_start`/`broadcast_kernel_done`。未做:宿主任务列表真机核对(需人工)。
|
||||
- [x] A6-2 **P0** **PASS** 成功跑完后新业务数据可查:`list-projects` 3 行、`stats` report_total 与库内一致、`list-reports` 含新记录;表 `_jiangchang_tables.visible=1` 且列元数据齐备(数据管理渲染由测试守护)。
|
||||
- [x] A6-3 **P1** **PASS** 重复业务键幂等:同 project_no 再次 `add-project` 为 `action=updated` 且未提供字段不覆盖(`test_upsert_create_then_partial_update`)。
|
||||
- [x] A6-4 **P1** **PASS** 零结果成功与失败可区分:无匹配项目为 `ERROR:NO_PROJECTS` 失败退出;正常空参全量播报成功返回。
|
||||
- [x] A6-5 **P1** **PASS** 产出文件落在约定位置:`{data}/10032/track-fund-progress-default/html/report-*.html`、`brief-*.html`(实测可打开、grep 内容命中)。
|
||||
- [x] A6-6 **P0** **PASS** 业务表 DDL 无 `FOREIGN KEY`/`REFERENCES`(`display_metadata_validator` 守护测试通过;跨表仅存 project_id 业务 id,关联在 `_record_to_result` 代码层完成)。
|
||||
- [ ] A6-7 **P0** **N/A** 无「处理业务行」类 Action(REQUIREMENTS §0.3 声明无处理队列)。
|
||||
- [ ] A6-8 **P0** **N/A** 同上(条目自带 N/A 条款)。
|
||||
- [ ] A6-9 **P1** **N/A** 同上(状态→角色映射已声明不适用)。
|
||||
- [ ] A6-10 **P1** **NEED_MANUAL(未做)** HTML 侧栏预览横向铺满/无横向滚动条/刷新时间,须宿主预览区真机确认;HTML 本体已验证(结构、转义、页脚、状态灯、提醒章节)。
|
||||
- [x] A6-11 **P1** **PASS** 状态类列 `status`/`status_tag` 均 `searchable: 1` 且 `options_json` value 与存库枚举一致(`connection.py` 元数据定义 + `test_display_metadata` 通过)。
|
||||
- [ ] A6-12 **P2** **N/A** 无 `display_type: boolean` 列。
|
||||
- [x] A6-13 **P0** **PASS** 未创建 `task_logs`(`test_db_smoke` 断言 tables 仅 3 业务表 + 元数据表)。
|
||||
- [ ] A6-14 **P0** **N/A** 无废弃表迁移场景。
|
||||
|
||||
## 7. 可观测与安全观感
|
||||
|
||||
- [x] A7-1 **P0** **PASS** 失败提示为业务中文(日期格式无效/无项目),无密码、Cookie、token 泄露;全仓无敏感字段存储(红线扫描通过)。
|
||||
- [x] A7-2 **P0** **PASS** `jiangchang.log` 可见 `task_start` 与 `task_failed`/`broadcast_kernel_done`;失败含 `error_code=PROJECT_INVALID`,非仅 `cli_start`(2026-09-24 10:51:23 实录)。
|
||||
- [x] A7-3 **P0** **PASS** 故意失败(非法日期):日志先 `WARNING task_failed error_code=...`,随后 stdout `ERROR:PROJECT_INVALID ...`(顺序与实录一致)。
|
||||
- [x] A7-4 **P1** **PASS** 关键步骤带 `stage=`(project/rules/broadcast/kernel/render/monthly),`broadcast_kernel_start/down` 记录批次与计数。
|
||||
- [ ] A7-5 **P1** **N/A** 不宣称业务录屏(业务命令无录屏调用;`record_video` 缺省关闭;cmd_run 仅为模板演示)。
|
||||
- [x] A7-6 **P2** **PASS** 用户可见文案为业务语言(严重滞后/暂付款冲销/结题决算)。
|
||||
- [x] A7-7 **P2** **PASS** 数据管理按钮中文名(经费进度播报/录入项目台账/生成经费简报/更新预警阈值)不与宿主「查看/编辑/删除」重名(actions.json label 核对)。
|
||||
|
||||
## 8. AI + RPA 合规与工业级风控
|
||||
|
||||
- 本技能无外部系统、无 RPA、无不可逆业务动作(画像 S0-1/S0-8);A8-1 ~ A8-16 全部 **N/A**。相关红线(不改财务数据、不存敏感信息、不逆向)由开发文档 §5.3 与 `test_development_policy_guard`/`test_no_rpa_helpers_import` 持续守护。
|
||||
|
||||
## 9. 质量门禁与交活底线
|
||||
|
||||
- [x] A9-1 **P0** **PASS** `python tests/run_tests.py -v`:**338 tests OK(3 skipped)**,2026-09-24 10:54 实测(基线 321 → 本轮新增 17 条金标准/守护用例)。
|
||||
- [x] A9-2 **P0** **PASS** 本技能为纯本地确定性计算,本地即真实档;三代表性场景已真实跑通(A3-1),不以模拟档充数。
|
||||
- [ ] A9-3 **P0** **NEED_MANUAL(未做)** 定位表「挂」的宿主入口按声明抽测(对话播报、数据管理 toolbar/row、HTML 侧栏预览与重建、定时任务创建并手动触发月度播报)。已做:各入口背后的 CLI 命令全部实测通过,`test_tutorial_surfaces`+`test_actions_manifest` 校验声明三方一致。未做:宿主 UI 真机点选(需人工,步骤见「给技术人员的修改清单」)。
|
||||
- [x] A9-4 **P0** **PASS** 版本一致(POLICY-RELEASE-001):`SKILL.md version` = `SKILL_VERSION` = git tag `v1.0.1`(无前缀);CHANGELOG 有 `## 1.0.1` 小节。
|
||||
- [ ] A9-5 **P1** **NEED_MANUAL(未做)** 开发者按平台规则安装并测到该技能(Gitea 工作流成功 + 市场可见可装截图/记录)。已有:tag `v1.0.1`、Gitea remote、`.github/workflows/release_skill.yaml` 就绪;市场安装验证记录仍缺(需人工/平台侧执行)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 报告结尾必填
|
||||
|
||||
### 1. 结论
|
||||
|
||||
**不通过**(存在 5 项未完成的 P0 `NEED_MANUAL`:A1-1、A4-2、A4-3、A6-1、A9-3 —— 全部为宿主 UI 真机验证缺口,**无代码类 P0 FAIL**;0 项 FAIL)。
|
||||
|
||||
### 2. 统计
|
||||
|
||||
| 取值 | 数量 |
|
||||
|------|------|
|
||||
| PASS | 39 |
|
||||
| FAIL | 0 |
|
||||
| N/A | 36 |
|
||||
| NEED_MANUAL(未做) | 9(P0 × 5,P1 × 4) |
|
||||
| 合计 A 类条目 | 84 |
|
||||
|
||||
P0 FAIL 列表:无。
|
||||
P0 NEED_MANUAL 未做列表:A1-1、A4-2、A4-3、A6-1、A9-3。
|
||||
|
||||
### 3. 给技术人员的修改清单
|
||||
|
||||
> 以下均为**人工验证缺口**,无代码缺陷。按 P0 → P1 排序;每项给出期望现象与建议步骤。
|
||||
|
||||
**P0**
|
||||
|
||||
1. **A1-1 宿主识别**:宿主技能详情页能打开本技能,名称「项目经费进度播报助手」、版本 1.0.1 可见;`health`/`version` 详情区可执行。
|
||||
2. **A4-2 + A4-3 任务中心(月度播报)**:宿主定时任务建「技能直调」→ 选「月度经费播报」→ 手动触发;期望:任务中心出现进行中条目,进度按 `[i/n] 项目名:状态` 逐项滚动,结束后有成功结论与批次号;故意断库/无项目时为失败态且原因可读。
|
||||
3. **A6-1 任务记录可追溯**:上述任务完成后,任务中心历史列表可查到该条目与终态摘要(成功/失败均有)。
|
||||
4. **A9-3 多入口抽测**:①对话:「查一下我的经费执行怎么样」→ 播报文本;②数据管理:项目台账 toolbar「经费进度播报」、预警规则行按钮改阈值、播报记录 toolbar「生成经费简报」;③HTML 侧栏打开 `report-*.html` 预览后点重建;④定时入口同 A4-2。期望:各入口结果与 CLI 一致(同一内核),无入口分叉。
|
||||
|
||||
**P1**
|
||||
|
||||
5. **A4-4**:任务中心进行中条目的进度文案人工读一遍,确认 `[1/3] …:正常」可理解。
|
||||
6. **A4-8**:数据管理「预警规则」行点开编辑 form,确认参数区仅阈值字段、无重复主键映射、提交正常。
|
||||
7. **A6-10**:数据管理侧栏预览 HTML 简报:横向铺满、无横向滚动条;重新播报后刷新内容与页头时间。
|
||||
8. **A9-5**:走 `release.ps1` 发版(或验证 v1.0.1 已在市场):Gitea 工作流成功截图 + 技能市场对开发者可见、可安装、装后 `health` 通过;记录存入本目录。
|
||||
|
||||
**复验方式**:完成上述人工项后按 `acceptance-reports/README.md` 再生成一份新时间戳报告,勿覆盖本报告。
|
||||
|
||||
---
|
||||
|
||||
## 附:本轮整改记录(对照开发文档审核后已修复,测试守护)
|
||||
|
||||
1. 「待补录」状态标签触发条件修正(C3 触发且 D 未判 → 待补录),优先级与文档 §6.2 对齐。
|
||||
2. `broadcast-monthly` 改为逐项目计算即 `emit` 增量进度(`run_broadcast(on_progress=…)`),任务中心可见进行中。
|
||||
3. `progress_reports.project_id` 播报时回填(修复 render-html 重建丢金额);新增 `items_json` 规则明细列(重建保留提醒章节);补 `updated_at` + 触发器。
|
||||
4. 旧库迁移改为重建表拷数(SQLite `ALTER ADD COLUMN` 不允许非常量默认值),含回归测试 `test_init_db_migrates_legacy_progress_reports`;真实旧库迁移实测通过(6 行历史保留)。
|
||||
5. 简报 `{{TITLE}}` HTML 转义;章节顺序对齐 §10 并新增独立「暂付款 / 结题提醒」章节;页脚改为文档逐字文案。
|
||||
6. 金标准补测:−10%/+20% 恰界与越界、C2 钳位、N2 反例、C3 逐字段、HTML 注入转义、三色状态灯+提醒断言、`on_progress`、project_id/items_json 落库、`broadcast-monthly` async+cron 定位表守护。
|
||||
7. 文档口径统一:`ERROR:<CODE> <message>`、成功字段 `reports_brief`、`--all-status` 与 skill-detail 表外挂载补记、C1 level=warn、验收 1 明确三处版本号位置(开发文档 v1.1 + REQUIREMENTS v1.1 同步)。
|
||||
@@ -29,6 +29,7 @@
|
||||
| `health` / `version` / `config-path` | 不挂 | 不挂 | 不挂 | N/A | 运维诊断,仅 skill-detail |
|
||||
|
||||
定时挂载说明:`broadcast-monthly` 为 async + agent + cron,缺省参数(全部在研项目)可安全无人值守,无不可逆副作用;对应规格书"每月 5 号 9:00 自动执行"场景,具体时刻由宿主定时任务配置。
|
||||
表外挂载说明:`stats`、`view-rules` 另挂 skill-detail(技能详情页运维查看),对话/数据管理挂载以本表为准;`broadcast` 支持可选 `--all-status`(缺省仅处理"在研")。
|
||||
|
||||
## 0.2 AI + RPA 协作与授权边界
|
||||
|
||||
@@ -55,7 +56,7 @@
|
||||
- 三步计算内核:执行率(C1-C3)、偏差诊断(D1-D3)、提醒与三段式播报(N1-N3)
|
||||
- 播报结果 JSON + `progress_reports` 落库 + 《经费执行简报》HTML + 月度汇总简报
|
||||
- 月度播报 async Action(cron 挂载,任务中心进度)
|
||||
- 失败可诊断(稳定错误码 `ERROR:<CODE>:` + 统一日志)
|
||||
- 失败可诊断(稳定错误码 `ERROR:<CODE> <message>` + 统一日志)
|
||||
|
||||
## 4. 功能范围
|
||||
|
||||
@@ -90,8 +91,8 @@
|
||||
### 输出
|
||||
|
||||
- 成功:stdout JSON(`ok/batch_no/as_of/count/status_counts/brief_file/reports_brief`)
|
||||
- 失败:`ERROR:NO_PROJECTS|PROJECT_INVALID|REPORT_NOT_FOUND|RULE_INVALID|ENTITLEMENT_DENIED|QUERY_FAILED` + 中文 message
|
||||
- 落库:`progress_reports`;文件:`html/report-{id}.html`、`html/brief-{batch}.html`
|
||||
- 失败:`ERROR:<CODE> <中文 message>`(`CODE ∈ NO_PROJECTS|PROJECT_INVALID|REPORT_NOT_FOUND|RULE_INVALID|ENTITLEMENT_DENIED|QUERY_FAILED`)
|
||||
- 落库:`progress_reports`(含 `project_id` 关联与 `items_json` 规则明细);文件:`html/report-{id}.html`、`html/brief-{batch}.html`
|
||||
|
||||
## 7. 依赖的兄弟技能或外部系统
|
||||
|
||||
@@ -107,7 +108,7 @@
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 目录名 = `SKILL.md` slug = `constants.SKILL_SLUG`;三处版本号一致(1.0.1)
|
||||
- 目录名 = `SKILL.md` slug = `constants.SKILL_SLUG`;三处版本号一致(1.0.1;三处 = `scripts/util/constants.py` / `SKILL.md` / `CHANGELOG.md`)
|
||||
- `health` / `version` / `init-db` 正常;`python tests/run_tests.py -v` 全绿
|
||||
- 金标准测试覆盖 C1-C3/D1-D3/N1-N2 正反例与阈值边界;三种代表场景真实跑通(正常/严重滞后+暂付款超期/异常偏快+临近结题)
|
||||
- `set-rule` 修改阈值后诊断随之变化;`stats` 与记录一致;月度播报生成汇总简报
|
||||
@@ -122,9 +123,11 @@
|
||||
- 业务表间禁止 SQLite 外键;时间字段 Unix 秒;金额 round(x,4)
|
||||
- 保留模板演示 `cmd_run`(POLICY-RPA-003 录屏集成守护);业务命令不使用录屏
|
||||
- 修改开发规范时同步 `POLICY_MATRIX.md` 与对应守护测试
|
||||
- 旧库迁移在 `init_db()` 幂等完成(`ALTER TABLE` 补列:`items_json`、`updated_at`)
|
||||
|
||||
## 11. 变更记录
|
||||
|
||||
| 日期 | 版本 | 变更人 | 变更内容 |
|
||||
|------|------|--------|----------|
|
||||
| 2026-09-13 | v1.0 | 开发组 | 初版:三步计算内核 + 台账/阈值维护 + 简报与月度播报 |
|
||||
| 2026-09-24 | v1.1 | 开发组 | 对照验收修订:错误码格式 `ERROR:<CODE> <message>`、状态标签优先级(C3→待补录)、`project_id`/`items_json` 落库、月度增量 emit、简报 TITLE 转义与 §10 章节对齐 |
|
||||
|
||||
@@ -63,8 +63,10 @@ CREATE TABLE IF NOT EXISTS progress_reports (
|
||||
status_tag TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
suggestions_json TEXT,
|
||||
items_json TEXT,
|
||||
report_file TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -157,8 +159,10 @@ _PROGRESS_REPORTS_COLUMNS: tuple[dict[str, object], ...] = (
|
||||
),
|
||||
_col("summary", "播报摘要", "三段式播报文本(现状/诊断/建议)。", visible=0, display_type="textarea"),
|
||||
_col("suggestions_json", "建议清单", "建议条目(JSON 数组,默认隐藏)。", visible=0, display_type="textarea"),
|
||||
_col("items_json", "规则明细", "C/D/N 规则命中明细(JSON 数组,默认隐藏)。", visible=0, display_type="textarea"),
|
||||
_col("report_file", "简报文件", "《经费执行简报》HTML 文件路径。", visible=0),
|
||||
_col("created_at", "创建时间", "报告创建时间(Unix 秒级时间戳,自动生成)。"),
|
||||
_col("updated_at", "更新时间", "报告更新时间(Unix 秒级时间戳,自动维护)。"),
|
||||
)
|
||||
|
||||
_ALERT_RULES_COLUMNS: tuple[dict[str, object], ...] = (
|
||||
@@ -194,9 +198,41 @@ def _init_fund_projects_metadata(cursor) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_progress_reports_columns(cursor) -> None:
|
||||
"""旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已有表加新字段。
|
||||
|
||||
SQLite ``ALTER TABLE ... ADD COLUMN`` 不允许非常量默认值(``unixepoch()``),
|
||||
因此缺 ``items_json`` / ``updated_at`` 时按当前标准 DDL 重建表并拷贝历史数据
|
||||
(progress_reports 为只读播报记录,量级小;无外键,重建安全)。
|
||||
"""
|
||||
cursor.execute("PRAGMA table_info(progress_reports)")
|
||||
cols = [str(row[1]) for row in cursor.fetchall()]
|
||||
target = (
|
||||
"id", "batch_no", "project_id", "project_no", "project_name", "pi_name",
|
||||
"as_of", "spend_rate", "time_progress", "deviation", "status_tag",
|
||||
"summary", "suggestions_json", "items_json", "report_file",
|
||||
"created_at", "updated_at",
|
||||
)
|
||||
if all(name in cols for name in target):
|
||||
return
|
||||
shared = [name for name in cols if name in target and name != "updated_at"]
|
||||
cursor.execute("ALTER TABLE progress_reports RENAME TO _progress_reports_legacy")
|
||||
cursor.execute(_PROGRESS_REPORTS_DDL)
|
||||
if shared:
|
||||
col_list = ", ".join(shared)
|
||||
cursor.execute(
|
||||
f"INSERT INTO progress_reports ({col_list}) "
|
||||
f"SELECT {col_list} FROM _progress_reports_legacy"
|
||||
)
|
||||
cursor.execute("DROP TABLE _progress_reports_legacy") # 连同其旧索引一并删除
|
||||
cursor.execute(_PROGRESS_REPORTS_INDEX)
|
||||
|
||||
|
||||
def _init_progress_reports_metadata(cursor) -> None:
|
||||
cursor.execute(_PROGRESS_REPORTS_DDL)
|
||||
cursor.execute(_PROGRESS_REPORTS_INDEX)
|
||||
_ensure_progress_reports_columns(cursor)
|
||||
init_updated_at_trigger(cursor, "progress_reports")
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="progress_reports",
|
||||
|
||||
@@ -51,8 +51,6 @@ _KEY_ALIASES = {
|
||||
"remark": ("remark", "备注"),
|
||||
}
|
||||
|
||||
_STATUS_RANK = {STATUS_INCOMPLETE: 0, STATUS_NO_FUND: 1, STATUS_NORMAL: 2, STATUS_OVERSHOOT: 3, STATUS_LAG: 4}
|
||||
|
||||
|
||||
def _log():
|
||||
try:
|
||||
@@ -322,10 +320,14 @@ def compute_project(project: dict[str, Any], rules: dict[str, float], as_of: dat
|
||||
)
|
||||
)
|
||||
|
||||
# 状态修正:无法计算时归为未到账/待补录
|
||||
if spend_rate is None and status_tag == STATUS_NORMAL:
|
||||
has_no_fund = any(i["rule_id"] == "C1" for i in items)
|
||||
status_tag = STATUS_NO_FUND if has_no_fund else STATUS_INCOMPLETE
|
||||
# 状态标签优先级:严重滞后>支出异常偏快>正常(D3 已判,含 C3 轻微缺失)>待补录(C3 且 D 未判)>未到账(C1)
|
||||
if status_tag == STATUS_NORMAL and not any(i["rule_id"] == "D3" for i in items):
|
||||
if any(i["rule_id"] == "C3" for i in items):
|
||||
status_tag = STATUS_INCOMPLETE
|
||||
elif any(i["rule_id"] == "C1" for i in items):
|
||||
status_tag = STATUS_NO_FUND
|
||||
elif spend_rate is None:
|
||||
status_tag = STATUS_INCOMPLETE
|
||||
|
||||
suggestions = [i["suggestion"] for i in items if i.get("suggestion")]
|
||||
summary = _build_summary(project_no, project_name, total_approved, total_received, total_spent, as_of, spend_rate, time_progress, status_tag, items)
|
||||
@@ -408,8 +410,17 @@ def _write_report(result: dict[str, Any], batch_no: str) -> str:
|
||||
return write_report_for_kernel(result=result, batch_no=batch_no)
|
||||
|
||||
|
||||
def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no: str = "") -> dict[str, Any]:
|
||||
"""播报内核:逐项目三步计算 → 写库 → 生成《经费执行简报》。"""
|
||||
def run_broadcast(
|
||||
projects_raw: list[dict[str, Any]],
|
||||
as_of: date,
|
||||
*,
|
||||
batch_no: str = "",
|
||||
on_progress: Optional[Any] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""播报内核:逐项目三步计算 → 写库 → 生成《经费执行简报》。
|
||||
|
||||
``on_progress(index, total, result)``:每完成一个项目回调一次(任务中心增量进度)。
|
||||
"""
|
||||
from service.report_service import build_monthly_brief
|
||||
|
||||
log = _log()
|
||||
@@ -427,17 +438,23 @@ def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for project in projects:
|
||||
total = len(projects)
|
||||
for index, project in enumerate(projects, start=1):
|
||||
result = compute_project(project, rules, as_of)
|
||||
try:
|
||||
project_id = int(project.get("id")) if project.get("id") not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
project_id = None
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO progress_reports
|
||||
(batch_no, project_no, project_name, pi_name, as_of, spend_rate,
|
||||
time_progress, deviation, status_tag, summary, suggestions_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(batch_no, project_id, project_no, project_name, pi_name, as_of, spend_rate,
|
||||
time_progress, deviation, status_tag, summary, suggestions_json, items_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
batch_no,
|
||||
project_id,
|
||||
result["project_no"],
|
||||
result["project_name"],
|
||||
result["pi_name"],
|
||||
@@ -448,6 +465,7 @@ def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no:
|
||||
result["status_tag"],
|
||||
result["summary"],
|
||||
json.dumps(result["suggestions"], ensure_ascii=False),
|
||||
json.dumps(result["items"], ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
report_id = int(cur.lastrowid)
|
||||
@@ -457,6 +475,8 @@ def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no:
|
||||
cur.execute("UPDATE progress_reports SET report_file = ? WHERE id = ?", (report_file, report_id))
|
||||
result["report_file"] = report_file
|
||||
results.append(result)
|
||||
if on_progress is not None:
|
||||
on_progress(index, total, result)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -100,6 +100,19 @@ def _project_info_table(result: dict[str, Any]) -> str:
|
||||
return f'<table class="info">{"".join(rows)}</table>'
|
||||
|
||||
|
||||
def _reminder_section(result: dict[str, Any]) -> str:
|
||||
"""§10 独立「暂付款 / 结题提醒」章节:取 N1/N2 规则命中明细(kernel 与重建同源 items_json)。"""
|
||||
reminders = [i for i in (result.get("items") or []) if i.get("rule_id") in ("N1", "N2")]
|
||||
if not reminders:
|
||||
return ""
|
||||
rows = []
|
||||
for item in reminders:
|
||||
suggestion = item.get("suggestion") or ""
|
||||
tail = f'<br/><span class="muted">{_esc(suggestion)}</span>' if suggestion else ""
|
||||
rows.append(f"<li><strong>{_esc(item.get('name'))}</strong>:{_esc(item.get('detail'))}{tail}</li>")
|
||||
return f'<div class="section"><h2>暂付款 / 结题提醒</h2><ul>{"".join(rows)}</ul></div>'
|
||||
|
||||
|
||||
def build_progress_html(result: dict[str, Any], batch_no: str = "") -> str:
|
||||
"""组装单个项目的《经费执行简报》HTML 文本。"""
|
||||
status = str(result.get("status_tag") or "")
|
||||
@@ -114,22 +127,23 @@ def build_progress_html(result: dict[str, Any], batch_no: str = "") -> str:
|
||||
<span class="tag {badge}">{mark}</span>
|
||||
<span class="muted">批次 {_esc(batch_no or result.get("batch_no"))} · 评估基准日 {_esc(result.get("as_of"))}</span>
|
||||
</div>
|
||||
<div class="section"><h2>项目信息</h2>{_project_info_table(result)}</div>
|
||||
<div class="section"><h2>执行进度对比</h2>
|
||||
{_bar("实际支出率", result.get("spend_rate"), "fill-spend")}
|
||||
{_bar("序时进度", result.get("time_progress"), "fill-time")}
|
||||
<p class="muted">偏差 = 实际支出率 − 序时进度 = {_esc(_pct(result.get("deviation")))}</p>
|
||||
</div>
|
||||
<div class="section"><h2>项目信息</h2>{_project_info_table(result)}</div>
|
||||
<div class="section"><h2>播报摘要</h2>{summary_html}</div>
|
||||
<div class="section"><h2>建议</h2>{_suggestion_list(result.get("suggestions"))}</div>
|
||||
<footer>本简报由项目台账数据自动生成,仅作提醒;金额与执行进度请以财务系统账面为准。</footer>
|
||||
{_reminder_section(result)}
|
||||
<footer>播报由台账数据自动生成,请以财务系统账面为准。</footer>
|
||||
"""
|
||||
|
||||
template = load_html_template(_PROGRESS_TEMPLATE_REL)
|
||||
return render_html_placeholders(
|
||||
template,
|
||||
{
|
||||
"TITLE": f"经费执行简报 · {result.get('project_name') or result.get('project_no') or ''}",
|
||||
"TITLE": _esc(f"经费执行简报 · {result.get('project_name') or result.get('project_no') or ''}"),
|
||||
"GENERATED_AT": "",
|
||||
"BODY": body,
|
||||
},
|
||||
@@ -193,6 +207,10 @@ def _record_to_result(record: dict[str, Any]) -> dict[str, Any]:
|
||||
result["suggestions"] = json.loads(record.get("suggestions_json") or "[]")
|
||||
except (TypeError, ValueError):
|
||||
result["suggestions"] = []
|
||||
try:
|
||||
result["items"] = json.loads(record.get("items_json") or "[]")
|
||||
except (TypeError, ValueError):
|
||||
result["items"] = []
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -446,13 +446,14 @@ def cmd_broadcast_monthly() -> int:
|
||||
print(f"ERROR:NO_PROJECTS {message}")
|
||||
return 1
|
||||
|
||||
result = run_broadcast(projects, date.today())
|
||||
for index, report in enumerate(result["reports"], start=1):
|
||||
def _emit_progress(index: int, total: int, report: dict[str, Any]) -> None:
|
||||
emit(
|
||||
f"[{index}/{len(result['reports'])}] {report['project_name']}:{report['status_tag']}",
|
||||
f"[{index}/{total}] {report['project_name']}:{report['status_tag']}",
|
||||
skill=SKILL_SLUG,
|
||||
stage="monthly",
|
||||
)
|
||||
|
||||
result = run_broadcast(projects, date.today(), on_progress=_emit_progress)
|
||||
_print_json(
|
||||
{
|
||||
"ok": True,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(项目经费进度播报助手)。"""
|
||||
|
||||
SKILL_SLUG = "track-fund-progress-default"
|
||||
SKILL_VERSION = "1.0.1"
|
||||
SKILL_VERSION = "1.0.2"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.track_fund_progress_default"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
@@ -598,6 +598,18 @@ class TestActionsManifest(unittest.TestCase):
|
||||
msg=f"{action['id']} must not be auto-placed on toolbar",
|
||||
)
|
||||
|
||||
def test_broadcast_monthly_is_async_with_cron_and_agent(self) -> None:
|
||||
"""定位表(开发文档 §9 / REQUIREMENTS §0.1):月度播报必须 async + agent + cron。"""
|
||||
manifest = _load_actions_manifest()
|
||||
monthly = next((a for a in manifest["actions"] if a["id"] == "broadcast-monthly"), None)
|
||||
self.assertIsNotNone(monthly, msg="broadcast-monthly action missing from manifest")
|
||||
self.assertEqual(monthly.get("executionProfile"), "async")
|
||||
placements = set(monthly.get("placements") or [])
|
||||
self.assertIn("cron", placements, msg="broadcast-monthly must be placed on cron (每月 5 号定时)")
|
||||
self.assertIn("agent", placements, msg="broadcast-monthly must be reachable from conversation")
|
||||
entry = monthly.get("entrypoint") or {}
|
||||
self.assertEqual(entry.get("command"), "broadcast-monthly")
|
||||
|
||||
def test_actions_md_documents_strict_vs_host_boundary(self) -> None:
|
||||
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
|
||||
@@ -37,6 +37,70 @@ class TestDbSmoke(unittest.TestCase):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_init_db_migrates_legacy_progress_reports(self) -> None:
|
||||
"""v1.0.1 旧库(无 items_json/updated_at)→ init_db 重建迁移且数据不丢。"""
|
||||
with IsolatedDataRoot():
|
||||
from db.connection import get_conn, init_db
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE progress_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL,
|
||||
project_id INTEGER,
|
||||
project_no TEXT,
|
||||
project_name TEXT,
|
||||
pi_name TEXT,
|
||||
as_of TEXT,
|
||||
spend_rate REAL,
|
||||
time_progress REAL,
|
||||
deviation REAL,
|
||||
status_tag TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
suggestions_json TEXT,
|
||||
report_file TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO progress_reports (batch_no, project_no, status_tag) VALUES (?, ?, ?)",
|
||||
("legacy-batch", "OLD001", "严重滞后"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
init_db()
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(progress_reports)")
|
||||
cols = [str(row[1]) for row in cur.fetchall()]
|
||||
self.assertIn("items_json", cols)
|
||||
self.assertIn("updated_at", cols)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT project_no, status_tag FROM progress_reports WHERE batch_no = ?",
|
||||
("legacy-batch",),
|
||||
).fetchone()
|
||||
self.assertEqual(row, ("OLD001", "严重滞后"))
|
||||
|
||||
leftovers = cur.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '_progress%'"
|
||||
).fetchall()
|
||||
self.assertEqual(leftovers, [])
|
||||
|
||||
triggers = cur.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='progress_reports'"
|
||||
).fetchall()
|
||||
self.assertTrue(any("set_updated_at" in str(t[0]) for t in triggers))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -179,6 +179,80 @@ class TestBroadcastKernel(unittest.TestCase):
|
||||
brief_html = open(result["brief_file"], encoding="utf-8").read()
|
||||
self.assertLess(brief_html.find("严重滞后"), brief_html.find("正常"))
|
||||
|
||||
def test_on_progress_called_per_project(self) -> None:
|
||||
from service.fund_service import run_broadcast
|
||||
|
||||
seen: list[tuple[int, int, str]] = []
|
||||
result = run_broadcast(
|
||||
[_project(), _project(projectNo="TEST0002")],
|
||||
AS_OF,
|
||||
on_progress=lambda i, t, r: seen.append((i, t, r["project_no"])),
|
||||
)
|
||||
self.assertEqual(seen, [(1, 2, "TEST0001"), (2, 2, "TEST0002")])
|
||||
self.assertEqual(result["count"], 2)
|
||||
|
||||
def test_report_links_project_id_and_items_json(self) -> None:
|
||||
import json
|
||||
|
||||
from db.connection import get_conn
|
||||
from service.fund_service import query_projects, run_broadcast
|
||||
from service.project_service import upsert_project
|
||||
|
||||
upsert_project({
|
||||
"project_no": "TEST0001",
|
||||
"total_received": 300000.0,
|
||||
"total_spent": 60000.0,
|
||||
"start_date": "2026-01-01",
|
||||
"end_date": "2026-12-31",
|
||||
"frozen_amount": 15000.0,
|
||||
"frozen_since": "2026-04-10",
|
||||
})
|
||||
projects = query_projects(include_all_status=True)
|
||||
self.assertEqual(len(projects), 1)
|
||||
run_broadcast(projects, AS_OF)
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT project_id, items_json FROM progress_reports WHERE project_no = ?",
|
||||
("TEST0001",),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(row[0], projects[0]["id"])
|
||||
items = json.loads(row[1])
|
||||
self.assertTrue(any(i["rule_id"] == "D1" for i in items))
|
||||
self.assertTrue(any(i["rule_id"] == "N1" for i in items))
|
||||
|
||||
def test_report_escapes_html_injection(self) -> None:
|
||||
result = self._run([_project(projectName="<script>alert(1)</script>")])
|
||||
with open(result["reports"][0]["report_file"], encoding="utf-8") as f:
|
||||
html_text = f.read()
|
||||
self.assertNotIn("<script>alert(1)</script>", html_text)
|
||||
self.assertIn("<script>", html_text)
|
||||
|
||||
def test_report_contains_status_lights_and_reminders(self) -> None:
|
||||
result = self._run([
|
||||
_project(projectNo="LAG001", frozenAmount=20000.0, frozenSince="2026-01-20"),
|
||||
_project(projectNo="FAST001", totalReceived=400000.0, totalSpent=380000.0),
|
||||
_project(projectNo="OK001", totalReceived=400000.0, totalSpent=350000.0),
|
||||
_project(projectNo="CLO001", endDate="2026-10-15", totalReceived=100000.0, totalSpent=5000.0),
|
||||
])
|
||||
by_no = {r["project_no"]: r for r in result["reports"]}
|
||||
|
||||
def _read(project_no: str) -> str:
|
||||
with open(by_no[project_no]["report_file"], encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
lag_html = _read("LAG001")
|
||||
self.assertIn("🔴", lag_html)
|
||||
self.assertIn("冲销", lag_html)
|
||||
self.assertIn("暂付款 / 结题提醒", lag_html)
|
||||
self.assertIn("🟢", _read("OK001"))
|
||||
self.assertIn("🟠", _read("FAST001"))
|
||||
clo_html = _read("CLO001")
|
||||
self.assertIn("距执行期结束", clo_html)
|
||||
self.assertIn("暂付款 / 结题提醒", clo_html)
|
||||
|
||||
|
||||
class TestProjectService(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -258,5 +332,67 @@ class TestProjectService(unittest.TestCase):
|
||||
self.assertTrue(payload["lag_top"])
|
||||
|
||||
|
||||
class TestThresholdBoundaries(unittest.TestCase):
|
||||
"""验收 3:恰在 −10% / +20% 阈值边界的归类(默认阈值 0.10 / 0.20)。"""
|
||||
|
||||
def test_d1_exactly_minus_10_percent_is_normal(self) -> None:
|
||||
result = _compute(totalReceived=100000.0, totalSpent=60050.0)
|
||||
self.assertEqual(result["time_progress"], 0.7005)
|
||||
self.assertEqual(result["deviation"], -0.1)
|
||||
self.assertEqual(result["status_tag"], "正常") # 严格小于才判滞后
|
||||
|
||||
def test_d1_just_below_minus_10_percent_is_lag(self) -> None:
|
||||
result = _compute(totalReceived=100000.0, totalSpent=60040.0) # dev=-0.1001
|
||||
self.assertEqual(result["status_tag"], "严重滞后")
|
||||
|
||||
def test_d2_exactly_plus_20_percent_is_normal(self) -> None:
|
||||
result = _compute(totalReceived=100000.0, totalSpent=90050.0)
|
||||
self.assertEqual(result["deviation"], 0.2)
|
||||
self.assertEqual(result["status_tag"], "正常")
|
||||
|
||||
def test_d2_just_above_plus_20_percent_is_overshoot(self) -> None:
|
||||
result = _compute(totalReceived=100000.0, totalSpent=90060.0) # dev=+0.2001
|
||||
self.assertEqual(result["status_tag"], "支出异常偏快")
|
||||
|
||||
|
||||
class TestC2Clamp(unittest.TestCase):
|
||||
def test_before_start_clamps_to_zero(self) -> None:
|
||||
result = _compute(startDate="2026-10-01", endDate="2026-12-31")
|
||||
self.assertEqual(result["time_progress"], 0.0)
|
||||
|
||||
def test_after_end_clamps_to_one(self) -> None:
|
||||
result = _compute(startDate="2026-01-01", endDate="2026-08-31")
|
||||
self.assertEqual(result["time_progress"], 1.0)
|
||||
|
||||
|
||||
class TestC3StatusPriority(unittest.TestCase):
|
||||
"""状态标签优先级:严重滞后>偏快>正常(D3 已判)>待补录(C3)>未到账(C1)。"""
|
||||
|
||||
def test_missing_end_date_marks_incomplete(self) -> None:
|
||||
result = _compute(endDate=None)
|
||||
self.assertIsNotNone(result["spend_rate"])
|
||||
self.assertEqual(result["status_tag"], "待补录")
|
||||
|
||||
def test_missing_start_date_marks_incomplete(self) -> None:
|
||||
result = _compute(startDate=None)
|
||||
self.assertEqual(result["status_tag"], "待补录")
|
||||
|
||||
def test_missing_total_received_marks_incomplete(self) -> None:
|
||||
result = _compute(totalReceived=None)
|
||||
self.assertEqual(result["status_tag"], "待补录")
|
||||
|
||||
def test_missing_total_approved_keeps_normal_when_d3_judged(self) -> None:
|
||||
result = _compute(totalApproved=None, totalReceived=400000.0, totalSpent=350000.0)
|
||||
self.assertEqual(result["status_tag"], "正常") # 正常>待补录
|
||||
c3 = next(i for i in result["items"] if i["rule_id"] == "C3")
|
||||
self.assertIn("总立项经费", c3["detail"])
|
||||
|
||||
|
||||
class TestN2Negative(unittest.TestCase):
|
||||
def test_closing_soon_but_rate_at_least_80pct_no_reminder(self) -> None:
|
||||
result = _compute(endDate="2026-10-15", totalReceived=100000.0, totalSpent=80000.0)
|
||||
self.assertFalse(any(i["rule_id"] == "N2" for i in result["items"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user