fix: 对照《财务处Skill002》验收整改并升版 v1.0.2
All checks were successful
技能自动化发布 / release (push) Successful in 9s

- 状态标签:C3 触发且 D 未判 → 待补录,优先级对齐需求 §6.2
- broadcast-monthly 逐项目增量 emit,任务中心可见进行中进度
- progress_reports 回填 project_id;新增 items_json/updated_at;旧库重建式安全迁移(含回归测试)
- 简报:TITLE 转义防注入、章节对齐 §10(独立「暂付款/结题提醒」)、页脚逐字统一
- 金标准测试 +17:±10%/20% 阈值恰界、C2 钳位、N2 反例、C3 逐字段、HTML 转义、async+cron 定位守护、迁移回归
- 文档 v1.1(开发文档 + REQUIREMENTS):ERROR:<CODE> <message>、reports_brief、--all-status、skill-detail 表外挂载、C1 level=warn
- 落盘验收报告 acceptance-reports/20260924-105401-acceptance.md(338 测试全绿)
- 版本 1.0.1 → 1.0.2(constants / SKILL.md / CHANGELOG 三处对齐)
This commit is contained in:
chenfuqiang
2026-09-24 17:19:30 +08:00
parent a91ead3862
commit f90f742c1e
12 changed files with 490 additions and 25 deletions

View File

@@ -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:

View File

@@ -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()

View File

@@ -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("&lt;script&gt;", 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()