Files
track-fund-progress-default/tests/test_fund_rules.py
chenfuqiang a91ead3862
All checks were successful
技能自动化发布 / release (push) Successful in 7s
feat: 项目经费进度播报助手首版 v1.0.1(track-fund-progress-default)
2026-09-13 14:25:23 +08:00

263 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""经费播报计算内核金标准用例(离线、确定性,as_of 固定日期)。"""
from __future__ import annotations
import unittest
from datetime import date
from _support import IsolatedDataRoot
AS_OF = date(2026, 9, 13) # 基准日:2026-01-01 起止 2026-12-31 的项目序时进度 ≈ 0.7005
def _project(**overrides) -> dict:
base = {
"projectNo": "TEST0001",
"projectName": "测试项目",
"projectType": "纵向",
"piName": "张三",
"department": "化材学院",
"totalApproved": 600000.0,
"totalReceived": 300000.0,
"totalSpent": 60000.0,
"frozenAmount": 0.0,
"startDate": "2026-01-01",
"endDate": "2026-12-31",
"status": "在研",
}
base.update(overrides)
return base
def _compute(rules: dict | None = None, **overrides) -> dict:
from service.fund_service import _DEFAULT_RULES, compute_project
return compute_project(_project(**overrides), dict(_DEFAULT_RULES if rules is None else rules), AS_OF)
class TestStep1Calculation(unittest.TestCase):
def test_c2_time_progress_by_days(self) -> None:
result = _compute()
# 2026-01-01 ~ 2026-12-31 共 364 天,至 2026-09-13 过 255 天
self.assertAlmostEqual(result["time_progress"], 255 / 364, places=4)
def test_c1_no_fund_marks_unknown(self) -> None:
result = _compute(totalReceived=0, totalSpent=0)
self.assertEqual(result["status_tag"], "未到账")
self.assertIsNone(result["spend_rate"])
def test_c3_incomplete_ledger(self) -> None:
result = _compute(totalSpent=None, endDate=None)
self.assertEqual(result["status_tag"], "待补录")
c3 = next(i for i in result["items"] if i["rule_id"] == "C3")
self.assertIn("已报销金额", c3["detail"])
def test_spend_rate_rounded(self) -> None:
result = _compute()
self.assertEqual(result["spend_rate"], 0.2)
class TestStep2Diagnosis(unittest.TestCase):
def test_d1_lag(self) -> None:
result = _compute()
self.assertEqual(result["status_tag"], "严重滞后")
d1 = next(i for i in result["items"] if i["rule_id"] == "D1")
self.assertIn("低于序时进度", d1["detail"])
def test_d3_normal(self) -> None:
result = _compute(totalReceived=400000.0, totalSpent=350000.0)
self.assertEqual(result["status_tag"], "正常") # dev ≈ +0.175
def test_d2_overshoot(self) -> None:
result = _compute(totalReceived=400000.0, totalSpent=380000.0)
self.assertEqual(result["status_tag"], "支出异常偏快") # dev ≈ +0.2495
def test_threshold_boundary_not_lag_when_equal(self) -> None:
# 偏差恰等于 -0.10 时不判滞后(严格小于)
result = _compute(rules={"lag_threshold": 0.5005, "overshoot_threshold": 5.0})
self.assertEqual(result["status_tag"], "正常")
def test_rules_override_changes_verdict(self) -> None:
result = _compute(rules={"lag_threshold": 0.60, "overshoot_threshold": 0.20})
# dev ≈ -0.5005 未超过 -0.60 → 正常
self.assertEqual(result["status_tag"], "正常")
class TestStep3Reminders(unittest.TestCase):
def test_n1_pending_aging_overdue(self) -> None:
result = _compute(frozenAmount=15000.0, frozenSince="2026-04-10")
n1 = next(i for i in result["items"] if i["rule_id"] == "N1")
self.assertEqual((AS_OF - date(2026, 4, 10)).days, 156)
self.assertIn("156", n1["detail"])
self.assertIn("冲销", n1["suggestion"])
def test_n1_pending_within_window_silent(self) -> None:
result = _compute(frozenAmount=15000.0, frozenSince="2026-08-01")
self.assertFalse(any(i["rule_id"] == "N1" for i in result["items"]))
def test_n1_pending_since_missing_asks_backfill(self) -> None:
result = _compute(frozenAmount=15000.0)
n1 = next(i for i in result["items"] if i["rule_id"] == "N1")
self.assertIn("补录", n1["suggestion"])
def test_n2_closing_soon_warns(self) -> None:
result = _compute(endDate="2026-10-15", totalReceived=100000.0, totalSpent=5000.0)
n2 = next(i for i in result["items"] if i["rule_id"] == "N2")
self.assertIn("32", n2["detail"])
def test_n2_period_over_asks_settlement(self) -> None:
result = _compute(endDate="2026-08-31", totalReceived=100000.0, totalSpent=90000.0)
n2 = next(i for i in result["items"] if i["rule_id"] == "N2")
self.assertIn("结题决算", n2["name"])
class TestSummaryText(unittest.TestCase):
def test_three_part_summary_no_double_period(self) -> None:
result = _compute(frozenAmount=15000.0, frozenSince="2026-04-10")
lines = result["summary"].split("\n")
self.assertEqual(len(lines), 3)
self.assertTrue(lines[0].startswith("您负责的《测试项目》"))
self.assertIn("执行状态严重滞后", lines[0])
self.assertTrue(lines[1].startswith("诊断:"))
self.assertTrue(lines[2].startswith("建议:"))
self.assertNotIn("。。", result["summary"])
self.assertNotIn(";。", result["summary"])
def test_summary_numbers_from_ledger_only(self) -> None:
result = _compute()
self.assertIn("60.00 万元", result["summary"])
self.assertIn("20.0%", result["summary"])
class TestBroadcastKernel(unittest.TestCase):
def setUp(self) -> None:
self._root = IsolatedDataRoot()
self._root.__enter__()
from db.connection import init_db
init_db()
def tearDown(self) -> None:
self._root.__exit__(None, None, None)
def _run(self, projects):
from service.fund_service import run_broadcast
return run_broadcast(projects, AS_OF)
def test_broadcast_writes_reports_and_brief(self) -> None:
result = self._run([
_project(),
_project(projectNo="TEST0002", totalReceived=400000.0, totalSpent=350000.0),
])
self.assertEqual(result["count"], 2)
self.assertEqual(result["status_counts"], {"严重滞后": 1, "正常": 1})
self.assertTrue(result["brief_file"].endswith(".html"))
for report in result["reports"]:
self.assertTrue(report["report_file"].endswith(".html"))
with open(report["report_file"], encoding="utf-8") as f:
html_text = f.read()
self.assertIn("经费执行简报", html_text)
self.assertIn("以财务系统账面为准", html_text)
def test_empty_projects_raises(self) -> None:
with self.assertRaises(ValueError):
self._run([])
def test_monthly_brief_contains_status_rows(self) -> None:
result = self._run([_project()])
with open(result["brief_file"], encoding="utf-8") as f:
html_text = f.read()
self.assertIn("月度", html_text)
self.assertIn("bar-fill fill-spend", html_text)
def test_status_ranking_in_summary(self) -> None:
result = self._run([
_project(),
_project(projectNo="TEST0002", totalReceived=400000.0, totalSpent=350000.0),
])
brief_html = open(result["brief_file"], encoding="utf-8").read()
self.assertLess(brief_html.find("严重滞后"), brief_html.find("正常"))
class TestProjectService(unittest.TestCase):
def setUp(self) -> None:
self._root = IsolatedDataRoot()
self._root.__enter__()
from db.connection import init_db
init_db()
def tearDown(self) -> None:
self._root.__exit__(None, None, None)
def _upsert(self, **overrides):
from service.project_service import upsert_project
fields = {"project_no": "TEST0001", "total_received": 300000.0, "total_spent": 60000.0}
fields.update(overrides)
return upsert_project(fields)
def test_upsert_create_then_partial_update(self) -> None:
payload = self._upsert()
self.assertEqual(payload["action"], "created")
payload = self._upsert(total_spent=90000.0)
self.assertEqual(payload["action"], "updated")
from service.project_service import list_projects
row = list_projects()[0]
self.assertEqual(row["total_spent"], 90000.0)
self.assertEqual(row["total_received"], 300000.0) # 未提供字段不覆盖
self.assertEqual(row["project_name"], None) # 从未提供
def test_upsert_validations(self) -> None:
with self.assertRaises(ValueError):
self._upsert(project_no="")
with self.assertRaises(ValueError):
self._upsert(total_received=-1)
with self.assertRaises(ValueError):
self._upsert(start_date="2026/13/40")
with self.assertRaises(ValueError):
self._upsert(start_date="2026-01-01", end_date="2025-01-01")
with self.assertRaises(ValueError):
self._upsert(status="草稿")
def test_import_projects(self) -> None:
from service.project_service import import_projects
payload = import_projects([
_project(),
_project(projectNo="TEST0002"),
])
self.assertEqual(payload["created"], 2)
payload = import_projects([_project(totalSpent=80000.0)])
self.assertEqual(payload["updated"], 1)
def test_set_rule_and_effect(self) -> None:
from service.project_service import set_rule, view_rules
payload = set_rule("lag_threshold", 0.60)
self.assertEqual(payload["threshold_value"], 0.60)
rules = {r["rule_key"]: r["threshold_value"] for r in view_rules()}
self.assertEqual(rules["lag_threshold"], 0.60)
with self.assertRaises(ValueError):
set_rule("unknown_key", 0.5)
with self.assertRaises(ValueError):
set_rule("lag_threshold", -1)
def test_stats(self) -> None:
from service.project_service import import_projects, stats
import_projects([_project()])
from service.fund_service import run_broadcast
run_broadcast([_project()], AS_OF)
payload = stats()
self.assertEqual(payload["project_total"], 1)
self.assertEqual(payload["reports_by_tag"].get("严重滞后"), 1)
self.assertTrue(payload["lag_top"])
if __name__ == "__main__":
unittest.main()