Files
Hermes CI Fix a13a080381 test: CMA自动化测试补覆盖 226→452用例, 覆盖率36%→60%
- 新增8个测试文件(bot_bridge/kpi_causality/cash/predict/reports/tax_compliance/expenses/probe_cost)
- 增强 budget/auth/users + conftest账套模式适配
- 测试驱动修复: bot_bridge导入batch_id→source_batch; cash_forecast extra空dict
- 全量: 451 passed, 1 xfailed; 报告 docs/cma-test-coverage-report.md
2026-08-20 06:57:24 +08:00

486 lines
23 KiB
Python

"""费用审核智能体模块测试 — 规则CRUD + 报销单自动校验 + 审批流 + 看板统计
覆盖 expenses.py 全部13个端点:
rules(CRUD+seed) / reimbursements(submit/list/detail/approve/reject/return/resubmit) / stats
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from tests.conftest import create_test_user, get_token_for_user, auth_header
from app.models import ExpenseRule, ExpenseReimbursement, User
import hashlib
def mk_rule(db: Session, **kwargs) -> ExpenseRule:
defaults = {
"rule_name": "测试规则", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
"cycle": "single", "status": "active", "remark": "",
}
defaults.update(kwargs)
r = ExpenseRule(**defaults)
db.add(r)
db.commit()
db.refresh(r)
return r
def mk_reimb(db: Session, **kwargs) -> ExpenseReimbursement:
defaults = {
"reimb_no": "BX_TEST001", "applicant": "张三", "department": "销售部",
"expense_type": "entertainment", "title": "客户招待", "amount": 500.0,
"status": "pending", "check_result": "pass", "created_by": "test",
}
defaults.update(kwargs)
r = ExpenseReimbursement(**defaults)
db.add(r)
db.commit()
db.refresh(r)
return r
class TestRules:
"""费用规则 CRUD"""
BASE = "/api/cma/expenses/rules"
def test_list_empty(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(self.BASE, headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["data"] == []
assert resp.json()["total"] == 0
def test_list_filters(self, client: TestClient, db: Session):
"""expense_type / status 过滤"""
create_test_user(db)
token = get_token_for_user(client)
mk_rule(db, rule_name="招待限额", expense_type="entertainment")
mk_rule(db, rule_name="差旅限额", expense_type="travel")
mk_rule(db, rule_name="停用规则", expense_type="office", status="inactive")
r = client.get(f"{self.BASE}?expense_type=travel", headers=auth_header(token))
assert [x["rule_name"] for x in r.json()["data"]] == ["差旅限额"]
r = client.get(f"{self.BASE}?status=inactive", headers=auth_header(token))
assert [x["rule_name"] for x in r.json()["data"]] == ["停用规则"]
# 标签映射
r = client.get(f"{self.BASE}?expense_type=entertainment", headers=auth_header(token))
assert r.json()["data"][0]["expense_type_label"] == "招待费"
def test_create_rule(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token),
json={"rule_name": "招待费单笔限额", "expense_type": "entertainment",
"limit_type": "single", "limit_amount": 2000.0})
assert resp.status_code == 200
assert resp.json()["message"] == "规则已创建"
assert resp.json()["id"] > 0
def test_create_rule_missing_params(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token), json={"rule_name": "缺参数"})
assert resp.status_code == 400
assert "缺少必要参数" in resp.json()["detail"]
def test_create_rule_invalid_expense_type(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token),
json={"rule_name": "非法类型", "expense_type": "gambling",
"limit_amount": 100})
assert resp.status_code == 400
assert "无效费用类型" in resp.json()["detail"]
def test_create_rule_invalid_limit_type(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token),
json={"rule_name": "非法限额", "expense_type": "office",
"limit_type": "per_month", "limit_amount": 100})
assert resp.status_code == 400
assert "无效限额类型" in resp.json()["detail"]
def test_update_rule(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
rule = mk_rule(db)
resp = client.put(f"{self.BASE}/{rule.id}", headers=auth_header(token),
json={"rule_name": "新规则名", "limit_amount": 5000.0, "status": "inactive"})
assert resp.status_code == 200
assert resp.json()["message"] == "规则已更新"
r = client.get(self.BASE, headers=auth_header(token))
updated = r.json()["data"][0]
assert updated["rule_name"] == "新规则名"
assert updated["limit_amount"] == 5000.0
assert updated["status"] == "inactive"
def test_update_rule_not_found(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.put(f"{self.BASE}/99999", headers=auth_header(token), json={"rule_name": "x"})
assert resp.status_code == 404
def test_update_rule_invalid_type(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
rule = mk_rule(db)
resp = client.put(f"{self.BASE}/{rule.id}", headers=auth_header(token),
json={"expense_type": "bogus"})
assert resp.status_code == 400
def test_delete_rule(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
rule = mk_rule(db)
resp = client.delete(f"{self.BASE}/{rule.id}", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["message"] == "规则已删除"
r = client.get(self.BASE, headers=auth_header(token))
assert r.json()["data"] == []
def test_delete_rule_not_found(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.delete(f"{self.BASE}/99999", headers=auth_header(token))
assert resp.status_code == 404
def test_seed_rules_idempotent(self, client: TestClient, db: Session):
"""预置规则幂等:首次7条,再次0条"""
create_test_user(db)
token = get_token_for_user(client)
r1 = client.post(f"{self.BASE}/seed", headers=auth_header(token))
assert r1.status_code == 200
assert "新增 7 条" in r1.json()["message"]
r2 = client.post(f"{self.BASE}/seed", headers=auth_header(token))
assert "新增 0 条" in r2.json()["message"]
r = client.get(self.BASE, headers=auth_header(token))
assert r.json()["total"] == 7
def test_rule_permission_business_denied(self, client: TestClient, db: Session):
"""business角色不能创建规则 → 403"""
u = User(username="biz_expense", password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
name="业务员", role="business")
db.add(u)
db.commit()
token = get_token_for_user(client, username="biz_expense", password="pass123")
resp = client.post(self.BASE, headers=auth_header(token),
json={"rule_name": "越权规则", "expense_type": "office", "limit_amount": 10})
assert resp.status_code == 403
class TestReimbursements:
"""报销单提交/校验/审批"""
BASE = "/api/cma/expenses/reimbursements"
def test_submit_pass_no_rules(self, client: TestClient, db: Session):
"""无规则 → 提交通过,状态 pending"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "entertainment", "title": "客户餐费",
"amount": 500.0, "expense_date": "2026-06-10"})
assert resp.status_code == 200
data = resp.json()
assert data["message"] == "报销单已提交"
assert data["data"]["auto_check_passed"] is True
d = data["data"]
assert d["status"] == "pending"
assert d["reimb_no"].startswith("BX")
assert d["expense_type_label"] == "招待费"
assert d["applicant"] == "测试管理员" # 默认取当前用户name
def test_submit_over_single_limit_returned(self, client: TestClient, db: Session):
"""单笔超限 → 自动打回 returned + check_result fail"""
create_test_user(db)
token = get_token_for_user(client)
mk_rule(db, rule_name="招待单笔≤100", expense_type="entertainment",
limit_type="single", limit_amount=100.0)
resp = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "entertainment", "title": "大额招待",
"amount": 500.0})
assert resp.status_code == 200
data = resp.json()
assert data["message"] == "报销单超限,已自动打回"
assert data["data"]["auto_check_passed"] is False
d = data["data"]
assert d["status"] == "returned"
assert d["check_result"] == "fail"
assert "超限" in d["check_reason"]
assert d["check_detail"][0]["passed"] is False
def test_submit_within_single_limit(self, client: TestClient, db: Session):
"""单笔限额内 → 通过,check_detail 有记录"""
create_test_user(db)
token = get_token_for_user(client)
mk_rule(db, rule_name="招待单笔≤2000", expense_type="entertainment",
limit_type="single", limit_amount=2000.0)
resp = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "entertainment", "title": "正常招待",
"amount": 500.0})
assert resp.status_code == 200
d = resp.json()["data"]
assert d["status"] == "pending"
assert d["check_result"] == "pass"
assert d["check_detail"][0]["passed"] is True
def test_submit_monthly_limit(self, client: TestClient, db: Session):
"""月度累计超限 → 第二次自动打回"""
create_test_user(db)
token = get_token_for_user(client)
mk_rule(db, rule_name="招待月限额1000", expense_type="entertainment",
limit_type="monthly", limit_amount=1000.0, cycle="monthly")
r1 = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "entertainment", "title": "第一批",
"amount": 500.0, "expense_date": "2026-06-10"})
assert r1.json()["data"]["auto_check_passed"] is True
r2 = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "entertainment", "title": "第二批",
"amount": 800.0, "expense_date": "2026-06-20"})
assert r2.status_code == 200
assert r2.json()["data"]["auto_check_passed"] is False
assert r2.json()["data"]["status"] == "returned"
# 累计明细:500 + 800 = 1300
assert r2.json()["data"]["check_detail"][0]["actual"] == 1300.0
def test_submit_missing_params(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token),
json={"title": "没类型没金额"})
assert resp.status_code == 400
assert "缺少必要参数" in resp.json()["detail"]
def test_submit_invalid_type(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "bogus", "title": "x", "amount": 100})
assert resp.status_code == 400
def test_submit_zero_amount(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "office", "title": "x", "amount": 0})
assert resp.status_code == 400
assert "必须大于0" in resp.json()["detail"]
def test_submit_bad_date(self, client: TestClient, db: Session):
"""非法日期 → expense_date 为 None 不报错"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "office", "title": "x", "amount": 100,
"expense_date": "not-a-date"})
assert resp.status_code == 200
assert resp.json()["data"]["expense_date"] is None
def test_list_filters(self, client: TestClient, db: Session):
"""状态/类型/申请人/关键字 过滤"""
create_test_user(db)
token = get_token_for_user(client)
mk_reimb(db, reimb_no="BX0001", applicant="张三", title="客户招待", status="pending")
mk_reimb(db, reimb_no="BX0002", applicant="李四", title="差旅住宿", status="approved")
mk_reimb(db, reimb_no="BX0003", applicant="张三", title="办公采购", status="rejected",
expense_type="office")
r = client.get(f"{self.BASE}?status=approved", headers=auth_header(token))
assert [x["reimb_no"] for x in r.json()["data"]] == ["BX0002"]
r = client.get(f"{self.BASE}?expense_type=office", headers=auth_header(token))
assert [x["reimb_no"] for x in r.json()["data"]] == ["BX0003"]
r = client.get(f"{self.BASE}?applicant=张三", headers=auth_header(token))
assert {x["reimb_no"] for x in r.json()["data"]} == {"BX0001", "BX0003"}
r = client.get(f"{self.BASE}?keyword=差旅", headers=auth_header(token))
assert [x["reimb_no"] for x in r.json()["data"]] == ["BX0002"]
r = client.get(f"{self.BASE}?keyword=BX0003", headers=auth_header(token))
assert [x["reimb_no"] for x in r.json()["data"]] == ["BX0003"]
def test_get_detail(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
reimb = mk_reimb(db, reimb_no="BX0099")
resp = client.get(f"{self.BASE}/{reimb.id}", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["reimb_no"] == "BX0099"
def test_get_detail_not_found(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{self.BASE}/99999", headers=auth_header(token))
assert resp.status_code == 404
def test_approve(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
reimb = mk_reimb(db, reimb_no="BX0100")
resp = client.post(f"{self.BASE}/{reimb.id}/approve", headers=auth_header(token),
json={"comment": "同意"})
assert resp.status_code == 200
assert resp.json()["message"] == "已审批通过"
d = resp.json()["data"]
assert d["status"] == "approved"
assert d["approver"] == "测试管理员"
assert d["approve_comment"] == "同意"
def test_approve_wrong_state(self, client: TestClient, db: Session):
"""已审批的单不能再审 → 400"""
create_test_user(db)
token = get_token_for_user(client)
reimb = mk_reimb(db, reimb_no="BX0101", status="approved")
resp = client.post(f"{self.BASE}/{reimb.id}/approve", headers=auth_header(token))
assert resp.status_code == 400
assert "不可审批" in resp.json()["detail"]
def test_approve_not_found(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/99999/approve", headers=auth_header(token))
assert resp.status_code == 404
def test_reject_requires_comment(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
reimb = mk_reimb(db, reimb_no="BX0102")
resp = client.post(f"{self.BASE}/{reimb.id}/reject", headers=auth_header(token))
assert resp.status_code == 400
assert "必须填写审批意见" in resp.json()["detail"]
def test_reject_with_comment(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
reimb = mk_reimb(db, reimb_no="BX0103")
resp = client.post(f"{self.BASE}/{reimb.id}/reject", headers=auth_header(token),
json={"comment": "发票不合规"})
assert resp.status_code == 200
d = resp.json()["data"]
assert d["status"] == "rejected"
assert d["approve_comment"] == "发票不合规"
def test_return(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
reimb = mk_reimb(db, reimb_no="BX0104")
resp = client.post(f"{self.BASE}/{reimb.id}/return", headers=auth_header(token))
assert resp.status_code == 200
d = resp.json()["data"]
assert d["status"] == "returned"
assert d["approve_comment"] == "人工打回"
def test_resubmit_after_return(self, client: TestClient, db: Session):
"""打回后改金额重新提交 → 重新校验通过"""
create_test_user(db)
token = get_token_for_user(client)
mk_rule(db, rule_name="招待单笔≤100", expense_type="entertainment",
limit_type="single", limit_amount=100.0)
# 超限被自动打回
r = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "entertainment", "title": "超标单",
"amount": 500.0})
rid = r.json()["data"]["id"]
assert r.json()["data"]["status"] == "returned"
# 改金额到限额内 → 重新提交通过
resp = client.post(f"{self.BASE}/{rid}/resubmit", headers=auth_header(token),
json={"amount": 80.0})
assert resp.status_code == 200
assert resp.json()["message"] == "已重新提交"
d = resp.json()["data"]
assert d["status"] == "pending"
assert d["amount"] == 80.0
assert d["approver"] is None
def test_resubmit_still_over(self, client: TestClient, db: Session):
"""重提仍超限 → 再次打回"""
create_test_user(db)
token = get_token_for_user(client)
mk_rule(db, rule_name="招待单笔≤100", expense_type="entertainment",
limit_type="single", limit_amount=100.0)
r = client.post(self.BASE, headers=auth_header(token),
json={"expense_type": "entertainment", "title": "超标单",
"amount": 500.0})
rid = r.json()["data"]["id"]
resp = client.post(f"{self.BASE}/{rid}/resubmit", headers=auth_header(token),
json={"amount": 300.0})
assert resp.status_code == 200
assert resp.json()["message"] == "仍超限,已再次打回"
assert resp.json()["data"]["status"] == "returned"
def test_resubmit_wrong_state(self, client: TestClient, db: Session):
"""pending 状态不能重提 → 400"""
create_test_user(db)
token = get_token_for_user(client)
reimb = mk_reimb(db, reimb_no="BX0105")
resp = client.post(f"{self.BASE}/{reimb.id}/resubmit", headers=auth_header(token))
assert resp.status_code == 400
assert "不可重新提交" in resp.json()["detail"]
def test_approve_permission_business_denied(self, client: TestClient, db: Session):
"""business不能审批 → 403"""
reimb = mk_reimb(db, reimb_no="BX0106")
u = User(username="biz_approve", password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
name="业务员", role="business")
db.add(u)
db.commit()
token = get_token_for_user(client, username="biz_approve", password="pass123")
resp = client.post(f"{self.BASE}/{reimb.id}/approve", headers=auth_header(token))
assert resp.status_code == 403
class TestStats:
"""审核看板统计"""
BASE = "/api/cma/expenses/stats"
def test_stats_empty(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(self.BASE, headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["counts"]["total"] == 0
assert data["monthly_total"] == 0
assert len(data["budget_usage"]) == 2
def test_stats_with_data(self, client: TestClient, db: Session):
"""有报销数据 → 状态计数/类型统计/超限预警/月度总额"""
create_test_user(db)
token = get_token_for_user(client)
from datetime import datetime
mk_reimb(db, reimb_no="BX0201", applicant="张三", amount=500.0,
expense_date=datetime(2026, 6, 10), status="pending")
mk_reimb(db, reimb_no="BX0202", applicant="李四", amount=300.0,
expense_date=datetime(2026, 6, 11), status="approved")
mk_reimb(db, reimb_no="BX0203", applicant="王五", amount=800.0,
expense_date=datetime(2026, 5, 20), status="approved") # 上期不计
# 超限打回
mk_reimb(db, reimb_no="BX0204", applicant="赵六", amount=99999.0,
expense_date=datetime(2026, 6, 12), status="returned", check_result="fail",
check_reason="招待费单笔限额: 超限")
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["period"] == "2026-06"
assert data["counts"]["pending"] == 1
assert data["counts"]["approved"] == 2
assert data["counts"]["returned"] == 1
assert data["counts"]["total"] == 4
# 月度总额 = 500 + 300 = 800(上期不计,returned不计)
assert data["monthly_total"] == 800.0
assert data["amounts_by_type"][0]["amount"] == 800.0
# 超限预警
assert data["over_limit_count"] == 1
assert data["over_limit"][0]["reimb_no"] == "BX0204"
# 预算使用率
ent_usage = next(u for u in data["budget_usage"] if u["expense_type"] == "entertainment")
assert ent_usage["used"] == 800.0
assert ent_usage["usage_rate"] == round(800 / 150000 * 100, 1)
# 最近列表
assert len(data["recent"]) == 4
def test_stats_no_token(self, client: TestClient, db: Session):
resp = client.get(self.BASE)
assert resp.status_code == 403