diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd05e4..85d4b90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ 本文件对应技能市场 **「更新日志」** Tab。 发布 CI 会按当前 tag 版本提取匹配的 `##` 小节,写入该版本的 `changelog`。 +## 1.0.2 + +- 状态标签修正:台账关键字段缺失且偏差未判定时正确标记「待补录」,优先级与需求口径一致 +- 月度播报任务中心可见逐项进度(按项目增量上报,批量播报不再"算完才动") +- 《经费执行简报》增强:新增独立「暂付款 / 结题提醒」章节,标题转义防注入,页脚口径统一 +- 播报记录回填项目关联与规则命中明细,简报重建不再丢失金额与提醒 +- 本地库旧版本自动迁移(补列安全重建),历史播报数据保留 +- 金标准测试补齐阈值恰界(±10%/20%)、序时钳位、提醒反例与 HTML 转义用例 + ## 1.0.1 - 首个版本:项目经费"智能进度播报"上线 diff --git a/SKILL.md b/SKILL.md index 3a8121c..571b9de 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 项目经费进度播报助手 description: "高校项目经费执行监测与播报:按项目台账自动计算实际支出率与序时进度,识别严重滞后/支出异常偏快,生成三段式播报摘要、暂付款冲销与结题报销提醒,支持月度定时播报并输出《经费执行简报》。" -version: 1.0.1 +version: 1.0.2 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/acceptance-reports/20260924-105401-acceptance.md b/acceptance-reports/20260924-105401-acceptance.md new file mode 100644 index 0000000..3ea0c4e --- /dev/null +++ b/acceptance-reports/20260924-105401-acceptance.md @@ -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: `、成功字段 `reports_brief`、`--all-status` 与 skill-detail 表外挂载补记、C1 level=warn、验收 1 明确三处版本号位置(开发文档 v1.1 + REQUIREMENTS v1.1 同步)。 diff --git a/development/REQUIREMENTS.md b/development/REQUIREMENTS.md index 59fde12..ad52f87 100644 --- a/development/REQUIREMENTS.md +++ b/development/REQUIREMENTS.md @@ -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::` + 统一日志) +- 失败可诊断(稳定错误码 `ERROR: ` + 统一日志) ## 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: <中文 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: `、状态标签优先级(C3→待补录)、`project_id`/`items_json` 落库、月度增量 emit、简报 TITLE 转义与 §10 章节对齐 | diff --git a/scripts/db/connection.py b/scripts/db/connection.py index 9a17076..07f0f41 100644 --- a/scripts/db/connection.py +++ b/scripts/db/connection.py @@ -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", diff --git a/scripts/service/fund_service.py b/scripts/service/fund_service.py index a939b99..a59e7d4 100644 --- a/scripts/service/fund_service.py +++ b/scripts/service/fund_service.py @@ -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() diff --git a/scripts/service/report_service.py b/scripts/service/report_service.py index c15fe52..b5659eb 100644 --- a/scripts/service/report_service.py +++ b/scripts/service/report_service.py @@ -100,6 +100,19 @@ def _project_info_table(result: dict[str, Any]) -> str: return f'{"".join(rows)}
' +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'
{_esc(suggestion)}' if suggestion else "" + rows.append(f"
  • {_esc(item.get('name'))}:{_esc(item.get('detail'))}{tail}
  • ") + return f'

    暂付款 / 结题提醒

      {"".join(rows)}
    ' + + 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: {mark} 批次 {_esc(batch_no or result.get("batch_no"))} · 评估基准日 {_esc(result.get("as_of"))} +

    项目信息

    {_project_info_table(result)}

    执行进度对比

    {_bar("实际支出率", result.get("spend_rate"), "fill-spend")} {_bar("序时进度", result.get("time_progress"), "fill-time")}

    偏差 = 实际支出率 − 序时进度 = {_esc(_pct(result.get("deviation")))}

    -

    项目信息

    {_project_info_table(result)}

    播报摘要

    {summary_html}

    建议

    {_suggestion_list(result.get("suggestions"))}
    -
    本简报由项目台账数据自动生成,仅作提醒;金额与执行进度请以财务系统账面为准。
    + {_reminder_section(result)} +
    播报由台账数据自动生成,请以财务系统账面为准。
    """ 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 diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py index c12a9d4..4892f92 100644 --- a/scripts/service/task_service.py +++ b/scripts/service/task_service.py @@ -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, diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 4faeeea..1de53b9 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -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" diff --git a/tests/test_actions_manifest.py b/tests/test_actions_manifest.py index c3703ea..d004ca1 100644 --- a/tests/test_actions_manifest.py +++ b/tests/test_actions_manifest.py @@ -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: diff --git a/tests/test_db_smoke.py b/tests/test_db_smoke.py index a30a34a..ce1499d 100644 --- a/tests/test_db_smoke.py +++ b/tests/test_db_smoke.py @@ -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() diff --git a/tests/test_fund_rules.py b/tests/test_fund_rules.py index abc3fea..5abd003 100644 --- a/tests/test_fund_rules.py +++ b/tests/test_fund_rules.py @@ -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="")]) + with open(result["reports"][0]["report_file"], encoding="utf-8") as f: + html_text = f.read() + self.assertNotIn("", 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()