Files
cma-management/backend/app/api/expenses.py
T

629 lines
23 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.
"""费用审核智能体 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,
}