- 新增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
486 lines
23 KiB
Python
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
|