"""费用审核智能体 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.deps import get_entity_id 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), entity_id: int = Depends(get_entity_id), _=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), entity_id: int = Depends(get_entity_id), _=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, }