From 555d3b621aa04a44e7bf6ad8de026da04e8a63fc Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 4 Aug 2026 14:40:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=B9=E7=94=A8=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E2=80=94=E8=A7=84=E5=88=99+?= =?UTF-8?q?=E6=8A=A5=E9=94=80=E6=B5=81=E7=A8=8B+=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/expenses.py | 628 +++++++++++++++++++++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 46 ++ frontend/src/api/index.ts | 19 + frontend/src/permission.ts | 9 +- frontend/src/router/index.ts | 1 + frontend/src/views/ExpenseManage.vue | 722 +++++++++++++++++++++++++++ 7 files changed, 1423 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/expenses.py create mode 100644 frontend/src/views/ExpenseManage.vue diff --git a/backend/app/api/expenses.py b/backend/app/api/expenses.py new file mode 100644 index 00000000..088cee1e --- /dev/null +++ b/backend/app/api/expenses.py @@ -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, + } diff --git a/backend/app/main.py b/backend/app/main.py index fccc5fe6..f048104b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 92ff3951..fb6f8ef9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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()) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index bc3856ed..981c0805 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -305,4 +305,23 @@ export const analysisApi = { autoCalculate: (params?: any) => api.post('/analysis/auto-calculate', null, { params }), } +export const expenseApi = { + // 规则配置 + listRules: (params?: any) => api.get('/expenses/rules', { params }), + createRule: (data: any) => api.post('/expenses/rules', data), + updateRule: (id: number, data: any) => api.put(`/expenses/rules/${id}`, data), + deleteRule: (id: number) => api.delete(`/expenses/rules/${id}`), + seedRules: () => api.post('/expenses/rules/seed'), + // 报销单 + listReimbursements: (params?: any) => api.get('/expenses/reimbursements', { params }), + getReimbursement: (id: number) => api.get(`/expenses/reimbursements/${id}`), + submitReimbursement: (data: any) => api.post('/expenses/reimbursements', data), + approveReimbursement: (id: number, comment?: string) => api.post(`/expenses/reimbursements/${id}/approve`, { comment }), + rejectReimbursement: (id: number, comment: string) => api.post(`/expenses/reimbursements/${id}/reject`, { comment }), + returnReimbursement: (id: number, comment?: string) => api.post(`/expenses/reimbursements/${id}/return`, { comment }), + resubmitReimbursement: (id: number, data?: any) => api.post(`/expenses/reimbursements/${id}/resubmit`, data), + // 看板统计 + stats: (params?: any) => api.get('/expenses/stats', { params }), +} + export default api diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index 5a4bc805..6dabd05d 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -9,10 +9,10 @@ interface MenuItem { // ── 角色路由映射 ── export const ROLE_ROUTES: Record = { - ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence'], - finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence'], - business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer'], - it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence'], + ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses'], + finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses'], + business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses'], + it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses'], } export const ROLE_ACTIONS: Record = { @@ -32,6 +32,7 @@ export const MENU_ITEMS: MenuItem[] = [ // ── GROUP 2: 执行与控制(Do)── { path: '/cost', label: '成本分析', icon: 'Money', roles: ['ceo', 'finance', 'it'], group: '🟢 D 执行与控制' }, + { path: '/expenses', label: '费用审核', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' }, { path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' }, // ── GROUP 3: 监控与评价(Check)── diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 404a06fe..bbd433a6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -41,6 +41,7 @@ const routes = [ { path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } }, { path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } }, { path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } }, + { path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'] } }, ] }, ] diff --git a/frontend/src/views/ExpenseManage.vue b/frontend/src/views/ExpenseManage.vue new file mode 100644 index 00000000..2e8b8666 --- /dev/null +++ b/frontend/src/views/ExpenseManage.vue @@ -0,0 +1,722 @@ + + + + +