feat: 费用审核智能体—规则+报销流程+看板
This commit is contained in:
@@ -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,
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results, expenses
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
@@ -73,6 +73,7 @@ app.include_router(driver_budget.router)
|
||||
app.include_router(bot_kpis.router)
|
||||
app.include_router(bot_iron_law.router)
|
||||
app.include_router(analysis_results.router)
|
||||
app.include_router(expenses.router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -530,3 +530,49 @@ class VoucherDetail(Base):
|
||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
|
||||
period = Column(String(20), nullable=True, comment="期间 YYYY-MM")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 费用审核智能体 (2026-08)
|
||||
# ============================================================
|
||||
|
||||
class ExpenseRule(Base):
|
||||
"""费用规则 — 自动校验报销单的标准"""
|
||||
__tablename__ = "expense_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
rule_name = Column(String(100), nullable=False, comment="规则名称")
|
||||
dimension = Column(String(20), nullable=False, comment="维度: department/person/expense_type")
|
||||
dimension_value = Column(String(100), nullable=True, comment="维度值: 部门名/人员名/费用类型(空=全局)")
|
||||
expense_type = Column(String(30), nullable=False, comment="费用类型: entertainment/travel/office/management")
|
||||
limit_type = Column(String(20), nullable=False, comment="限额类型: single/monthly/yearly 单笔/月度累计/年度累计")
|
||||
limit_amount = Column(Float, nullable=False, comment="限额金额")
|
||||
cycle = Column(String(20), default="monthly", comment="周期: single/monthly/yearly")
|
||||
status = Column(String(20), default="active", comment="active/inactive")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ExpenseReimbursement(Base):
|
||||
"""费用报销单 — 提交后自动校验规则,超限自动打回"""
|
||||
__tablename__ = "expense_reimbursements"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
reimb_no = Column(String(50), unique=True, nullable=False, comment="报销单号")
|
||||
applicant = Column(String(100), nullable=False, comment="申请人")
|
||||
department = Column(String(100), nullable=True, comment="部门")
|
||||
expense_type = Column(String(30), nullable=False, comment="费用类型: entertainment/travel/office/management")
|
||||
title = Column(String(200), nullable=False, comment="事由/摘要")
|
||||
amount = Column(Float, nullable=False, comment="报销金额")
|
||||
expense_date = Column(DateTime, nullable=True, comment="费用发生日期")
|
||||
attachment = Column(String(500), nullable=True, comment="附件文件名")
|
||||
status = Column(String(20), default="pending", comment="pending待审批/approved已通过/rejected已拒绝/returned已打回")
|
||||
check_result = Column(String(20), default="pass", comment="自动校验结果: pass/fail")
|
||||
check_reason = Column(String(1000), nullable=True, comment="超限原因")
|
||||
check_detail = Column(JSON, nullable=True, comment="校验明细: [{rule_name, limit, actual, passed}]")
|
||||
checked_at = Column(DateTime, nullable=True, comment="自动校验时间")
|
||||
approver = Column(String(100), nullable=True, comment="审批人")
|
||||
approve_comment = Column(String(500), nullable=True, comment="审批意见")
|
||||
approved_at = Column(DateTime, nullable=True, comment="审批时间")
|
||||
created_by = Column(String(100), nullable=True, comment="提交人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
Reference in New Issue
Block a user