feat: 费用审核智能体—规则+报销流程+看板

This commit is contained in:
Hermes CI Fix
2026-08-04 14:40:03 +08:00
parent e3e8bad3da
commit 555d3b621a
7 changed files with 1423 additions and 5 deletions
+628
View File
@@ -0,0 +1,628 @@
"""费用审核智能体 API — 管理会计OS
费用规则配置 + 报销单自动校验 + 人工审批流程 + 审核看板统计
流程:
1. 提交报销单 → 自动校验费用规则
2. 超限 → 自动打回(returned, 标注原因)
3. 合规 → 待人工审批(pending)
4. 审批通过(approved) / 拒绝(rejected)
"""
import json
import random
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import ExpenseRule, ExpenseReimbursement, OperationLog
router = APIRouter(prefix="/api/cma/expenses", tags=["费用审核"])
# 费用类型中文映射
EXPENSE_TYPE_LABELS = {
"entertainment": "招待费",
"travel": "差旅费",
"office": "办公费",
"management": "管理费",
}
# 预置规则
PRESET_RULES = [
{"rule_name": "招待费单笔限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
"cycle": "single", "remark": "招待费标准:单笔≤2000元"},
{"rule_name": "招待费部门月限额", "dimension": "department", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 50000.0,
"cycle": "monthly", "remark": "招待费标准:部门月限额5万元"},
{"rule_name": "招待费月度总额限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 150000.0,
"cycle": "monthly", "remark": "公司招待费预算15万/月"},
{"rule_name": "差旅住宿单晚限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "travel", "limit_type": "single", "limit_amount": 300.0,
"cycle": "single", "remark": "差旅费标准:住宿≤300元/晚"},
{"rule_name": "差旅交通等级限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "travel", "limit_type": "single", "limit_amount": 1500.0,
"cycle": "single", "remark": "差旅费标准:交通等级(高铁二等座/经济舱)"},
{"rule_name": "办公费单笔限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "office", "limit_type": "single", "limit_amount": 500.0,
"cycle": "single", "remark": "办公费标准:单笔≤500元"},
{"rule_name": "管理费月度总额限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "management", "limit_type": "monthly", "limit_amount": 910000.0,
"cycle": "monthly", "remark": "管理费预算91万/月"},
]
def _rule_to_dict(r: ExpenseRule) -> dict:
return {
"id": r.id,
"rule_name": r.rule_name,
"dimension": r.dimension,
"dimension_value": r.dimension_value,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"limit_type": r.limit_type,
"limit_amount": r.limit_amount,
"cycle": r.cycle,
"status": r.status,
"remark": r.remark,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _reimb_to_dict(r: ExpenseReimbursement) -> dict:
return {
"id": r.id,
"reimb_no": r.reimb_no,
"applicant": r.applicant,
"department": r.department,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"title": r.title,
"amount": r.amount,
"expense_date": r.expense_date.isoformat() if r.expense_date else None,
"attachment": r.attachment,
"status": r.status,
"check_result": r.check_result,
"check_reason": r.check_reason,
"check_detail": r.check_detail,
"checked_at": r.checked_at.isoformat() if r.checked_at else None,
"approver": r.approver,
"approve_comment": r.approve_comment,
"approved_at": r.approved_at.isoformat() if r.approved_at else None,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _gen_reimb_no(db: Session) -> str:
"""生成报销单号: BX + 年月日 + 序号"""
today = datetime.now()
prefix = "BX" + today.strftime("%Y%m%d")
last = (
db.query(ExpenseReimbursement)
.filter(ExpenseReimbursement.reimb_no.like(prefix + "%"))
.order_by(ExpenseReimbursement.id.desc())
.first()
)
seq = (int(last.reimb_no[-4:]) + 1) if last and last.reimb_no[-4:].isdigit() else 1
return f"{prefix}{seq:04d}"
# ============================================================
# 费用规则 CRUD
# ============================================================
@router.get("/rules")
def list_rules(
expense_type: str = Query(None),
status: str = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询费用规则列表"""
q = db.query(ExpenseRule)
if expense_type:
q = q.filter(ExpenseRule.expense_type == expense_type)
if status:
q = q.filter(ExpenseRule.status == status)
rules = q.order_by(ExpenseRule.id.asc()).all()
return {"data": [_rule_to_dict(r) for r in rules], "total": len(rules)}
@router.post("/rules")
def create_rule(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""新建费用规则"""
rule_name = data.get("rule_name")
expense_type = data.get("expense_type")
limit_type = data.get("limit_type", "single")
limit_amount = data.get("limit_amount")
if not rule_name or not expense_type or limit_amount is None:
raise HTTPException(400, "缺少必要参数: rule_name, expense_type, limit_amount")
if expense_type not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {expense_type}")
if limit_type not in ("single", "monthly", "yearly"):
raise HTTPException(400, f"无效限额类型: {limit_type}")
rule = ExpenseRule(
rule_name=rule_name,
dimension=data.get("dimension", "expense_type"),
dimension_value=data.get("dimension_value", "") or None,
expense_type=expense_type,
limit_type=limit_type,
limit_amount=float(limit_amount),
cycle=data.get("cycle", limit_type),
status=data.get("status", "active"),
remark=data.get("remark", ""),
)
db.add(rule)
db.commit()
db.refresh(rule)
return {"message": "规则已创建", "id": rule.id}
@router.put("/rules/{rule_id}")
def update_rule(
rule_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新费用规则"""
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "规则不存在")
if "rule_name" in data and data["rule_name"]:
rule.rule_name = data["rule_name"]
if "expense_type" in data:
if data["expense_type"] not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {data['expense_type']}")
rule.expense_type = data["expense_type"]
if "dimension" in data:
rule.dimension = data["dimension"]
if "dimension_value" in data:
rule.dimension_value = data["dimension_value"] or None
if "limit_type" in data:
rule.limit_type = data["limit_type"]
if "limit_amount" in data and data["limit_amount"] is not None:
rule.limit_amount = float(data["limit_amount"])
if "status" in data:
rule.status = data["status"]
if "remark" in data:
rule.remark = data["remark"]
db.commit()
db.refresh(rule)
return {"message": "规则已更新", "id": rule.id}
@router.delete("/rules/{rule_id}")
def delete_rule(
rule_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除费用规则"""
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "规则不存在")
db.delete(rule)
db.commit()
return {"message": "规则已删除", "id": rule_id}
@router.post("/rules/seed")
def seed_rules(
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""预置默认费用规则(幂等,按规则名去重)"""
created = 0
for p in PRESET_RULES:
exists = db.query(ExpenseRule).filter(ExpenseRule.rule_name == p["rule_name"]).first()
if exists:
continue
db.add(ExpenseRule(**p))
created += 1
db.commit()
return {"message": f"预置完成,新增 {created} 条规则"}
# ============================================================
# 报销单自动校验
# ============================================================
def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list = None):
"""按启用规则校验报销单,写入 check_result / check_detail / check_reason
规则匹配:
- dimension=expense_type: 全局费用类型规则(dimension_value 可再限定具体类型)
- dimension=department: 部门维度规则(dimension_value 空=所有部门)
- dimension=person: 人员维度规则(dimension_value 空=所有人)
返回 (passed: bool, checks: list)
"""
if rules is None:
rules = db.query(ExpenseRule).filter(ExpenseRule.status == "active").all()
period = (reimb.expense_date or datetime.now()).strftime("%Y-%m")
checks = []
failed_reasons = []
for rule in rules:
# 费用类型必须匹配
if rule.expense_type and rule.expense_type != reimb.expense_type:
continue
# 维度匹配
if rule.dimension == "department":
if not reimb.department:
continue
if rule.dimension_value and rule.dimension_value != reimb.department:
continue
elif rule.dimension == "person":
if rule.dimension_value and rule.dimension_value != reimb.applicant:
continue
elif rule.dimension == "expense_type":
if rule.dimension_value and rule.dimension_value != reimb.expense_type:
continue
else:
continue
actual = reimb.amount
limit_desc = f"{rule.limit_amount:,.0f}"
if rule.limit_type == "single":
passed = reimb.amount <= rule.limit_amount
checks.append({
"rule_id": rule.id,
"rule_name": rule.rule_name,
"rule_type": "单笔限额",
"limit": rule.limit_amount,
"actual": reimb.amount,
"passed": passed,
"detail": f"单笔 {reimb.amount:,.2f}元 vs 限额 {limit_desc}",
})
else:
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
ExpenseReimbursement.expense_type == reimb.expense_type,
ExpenseReimbursement.id != reimb.id,
ExpenseReimbursement.status.in_(["pending", "approved"]),
)
if rule.limit_type == "monthly":
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period)
period_label = f"{period}"
else: # yearly
year = period[:4]
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y") == year)
period_label = f"{year}"
if rule.dimension == "department" and reimb.department:
q = q.filter(ExpenseReimbursement.department == reimb.department)
elif rule.dimension == "person":
q = q.filter(ExpenseReimbursement.applicant == reimb.applicant)
used = q.scalar() or 0.0
actual = used + reimb.amount
passed = actual <= rule.limit_amount
checks.append({
"rule_id": rule.id,
"rule_name": rule.rule_name,
"rule_type": "月度累计" if rule.limit_type == "monthly" else "年度累计",
"limit": rule.limit_amount,
"actual": actual,
"used": used,
"passed": passed,
"detail": f"{period_label}累计 {used:,.2f} + 本次 {reimb.amount:,.2f} = {actual:,.2f}元 vs 限额 {limit_desc}",
})
if not passed:
failed_reasons.append(f"{rule.rule_name}: {checks[-1]['detail']},超限")
passed_all = len(failed_reasons) == 0
reimb.check_result = "pass" if passed_all else "fail"
reimb.check_detail = checks
reimb.check_reason = "".join(failed_reasons) if failed_reasons else None
reimb.checked_at = datetime.now()
# 超限自动打回,合规进入待人工审批
reimb.status = "pending" if passed_all else "returned"
return passed_all, checks
# ============================================================
# 报销单提交 / 查询 / 审批
# ============================================================
@router.post("/reimbursements")
def submit_reimbursement(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""提交报销单 → 自动校验规则(超限自动打回)"""
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
expense_type = data.get("expense_type")
title = data.get("title")
amount = data.get("amount")
if not expense_type or not title or amount is None:
raise HTTPException(400, "缺少必要参数: expense_type, title, amount")
if expense_type not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {expense_type}")
amount = float(amount)
if amount <= 0:
raise HTTPException(400, "报销金额必须大于0")
expense_date = None
if data.get("expense_date"):
try:
expense_date = datetime.strptime(str(data["expense_date"])[:10], "%Y-%m-%d")
except Exception:
expense_date = None
reimb = ExpenseReimbursement(
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
applicant=applicant,
department=data.get("department", ""),
expense_type=expense_type,
title=title,
amount=amount,
expense_date=expense_date,
attachment=data.get("attachment", ""),
status="pending",
check_result="pass",
created_by=current_user.username if hasattr(current_user, "username") else applicant,
)
db.add(reimb)
db.flush() # 先拿到 id 再生成正式单号
reimb.reimb_no = _gen_reimb_no(db)
db.flush()
passed, checks = validate_reimbursement(db, reimb)
db.commit()
db.refresh(reimb)
result = _reimb_to_dict(reimb)
result["auto_check_passed"] = passed
return {"message": "报销单已提交" if passed else "报销单超限,已自动打回", "data": result}
@router.get("/reimbursements")
def list_reimbursements(
status: str = Query(None),
expense_type: str = Query(None),
applicant: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
q = db.query(ExpenseReimbursement)
if status:
q = q.filter(ExpenseReimbursement.status == status)
if expense_type:
q = q.filter(ExpenseReimbursement.expense_type == expense_type)
if applicant:
q = q.filter(ExpenseReimbursement.applicant.like(f"%{applicant}%"))
if keyword:
like = f"%{keyword}%"
q = q.filter(
(ExpenseReimbursement.title.like(like))
| (ExpenseReimbursement.reimb_no.like(like))
| (ExpenseReimbursement.applicant.like(like))
)
items = q.order_by(ExpenseReimbursement.id.desc()).limit(200).all()
return {"data": [_reimb_to_dict(r) for r in items], "total": len(items)}
@router.get("/reimbursements/{reimb_id}")
def get_reimbursement(
reimb_id: int,
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""报销单详情"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
return _reimb_to_dict(r)
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
"""执行审批动作: approve/reject/return"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("pending", "returned"):
raise HTTPException(400, f"当前状态({r.status})不可审批")
if action == "approve":
r.status = "approved"
elif action == "reject":
r.status = "rejected"
else:
r.status = "returned"
r.approver = approver
r.approve_comment = comment or ("" if action == "approve" else "人工打回")
r.approved_at = datetime.now()
db.commit()
db.refresh(r)
return r
@router.post("/reimbursements/{reimb_id}/approve")
def approve_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""审批通过"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/reject")
def reject_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""审批拒绝"""
comment = (data or {}).get("comment", "")
if not comment:
raise HTTPException(400, "拒绝时必须填写审批意见")
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "reject", comment, approver)
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/return")
def return_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""人工打回"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
return {"message": "已打回", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/resubmit")
def resubmit_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""被驳回/打回后重新提交 → 重新自动校验"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("returned", "rejected"):
raise HTTPException(400, f"当前状态({r.status})不可重新提交")
# 允许修改金额/事由后重新校验
if data:
if data.get("amount") is not None:
r.amount = float(data["amount"])
if data.get("title"):
r.title = data["title"]
if data.get("attachment") is not None:
r.attachment = data["attachment"]
passed, _ = validate_reimbursement(db, r)
r.approver = None
r.approve_comment = None
db.commit()
db.refresh(r)
return {"message": "已重新提交" if passed else "仍超限,已再次打回", "data": _reimb_to_dict(r)}
# ============================================================
# 审核看板统计
# ============================================================
@router.get("/stats")
def expense_stats(
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
now = datetime.now()
period = period or now.strftime("%Y-%m")
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by(
ExpenseReimbursement.status
).all():
if st in counts:
counts[st] = cnt
counts["total"] += cnt
# 本月(按费用发生日期)金额统计 by 费用类型 — 统计已提交(待审+已通过)
month_rows = (
db.query(
ExpenseReimbursement.expense_type,
func.coalesce(func.sum(ExpenseReimbursement.amount), 0),
func.count(ExpenseReimbursement.id),
)
.filter(
ExpenseReimbursement.status.in_(["pending", "approved"]),
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
)
.group_by(ExpenseReimbursement.expense_type)
.all()
)
amounts_by_type = [
{
"expense_type": et,
"label": EXPENSE_TYPE_LABELS.get(et, et),
"amount": round(float(amt), 2),
"count": cnt,
}
for et, amt, cnt in month_rows
]
monthly_total = round(sum(x["amount"] for x in amounts_by_type), 2)
# 超限预警列表 — 自动打回(returned + check_result=fail)
over_limit_rows = (
db.query(ExpenseReimbursement)
.filter(
ExpenseReimbursement.status == "returned",
ExpenseReimbursement.check_result == "fail",
)
.order_by(ExpenseReimbursement.id.desc())
.limit(50)
.all()
)
over_limit = [
{
"id": r.id,
"reimb_no": r.reimb_no,
"applicant": r.applicant,
"department": r.department,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"title": r.title,
"amount": r.amount,
"check_reason": r.check_reason,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in over_limit_rows
]
recent_rows = db.query(ExpenseReimbursement).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
recent = [_reimb_to_dict(r) for r in recent_rows]
# 本月预算使用率(招待费15万/月 管理费91万/月)
budget_usage = []
for et, budget in (("entertainment", 150000.0), ("management", 910000.0)):
used = next((x["amount"] for x in amounts_by_type if x["expense_type"] == et), 0.0)
budget_usage.append({
"expense_type": et,
"label": EXPENSE_TYPE_LABELS.get(et, et),
"budget": budget,
"used": used,
"usage_rate": round(used / budget * 100, 1) if budget else 0,
})
return {
"period": period,
"counts": counts,
"amounts_by_type": amounts_by_type,
"monthly_total": monthly_total,
"over_limit": over_limit,
"over_limit_count": len(over_limit),
"recent": recent,
"budget_usage": budget_usage,
}