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

This commit is contained in:
Hermes CI Fix
2026-08-04 14:40:03 +08:00
parent e3e8bad3da
commit 555d3b621a
7 changed files with 1423 additions and 5 deletions
+628
View File
@@ -0,0 +1,628 @@
"""费用审核智能体 API — 管理会计OS
费用规则配置 + 报销单自动校验 + 人工审批流程 + 审核看板统计
流程:
1. 提交报销单 → 自动校验费用规则
2. 超限 → 自动打回(returned, 标注原因)
3. 合规 → 待人工审批(pending)
4. 审批通过(approved) / 拒绝(rejected)
"""
import json
import random
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import ExpenseRule, ExpenseReimbursement, OperationLog
router = APIRouter(prefix="/api/cma/expenses", tags=["费用审核"])
# 费用类型中文映射
EXPENSE_TYPE_LABELS = {
"entertainment": "招待费",
"travel": "差旅费",
"office": "办公费",
"management": "管理费",
}
# 预置规则
PRESET_RULES = [
{"rule_name": "招待费单笔限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
"cycle": "single", "remark": "招待费标准:单笔≤2000元"},
{"rule_name": "招待费部门月限额", "dimension": "department", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 50000.0,
"cycle": "monthly", "remark": "招待费标准:部门月限额5万元"},
{"rule_name": "招待费月度总额限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 150000.0,
"cycle": "monthly", "remark": "公司招待费预算15万/月"},
{"rule_name": "差旅住宿单晚限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "travel", "limit_type": "single", "limit_amount": 300.0,
"cycle": "single", "remark": "差旅费标准:住宿≤300元/晚"},
{"rule_name": "差旅交通等级限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "travel", "limit_type": "single", "limit_amount": 1500.0,
"cycle": "single", "remark": "差旅费标准:交通等级(高铁二等座/经济舱)"},
{"rule_name": "办公费单笔限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "office", "limit_type": "single", "limit_amount": 500.0,
"cycle": "single", "remark": "办公费标准:单笔≤500元"},
{"rule_name": "管理费月度总额限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "management", "limit_type": "monthly", "limit_amount": 910000.0,
"cycle": "monthly", "remark": "管理费预算91万/月"},
]
def _rule_to_dict(r: ExpenseRule) -> dict:
return {
"id": r.id,
"rule_name": r.rule_name,
"dimension": r.dimension,
"dimension_value": r.dimension_value,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"limit_type": r.limit_type,
"limit_amount": r.limit_amount,
"cycle": r.cycle,
"status": r.status,
"remark": r.remark,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _reimb_to_dict(r: ExpenseReimbursement) -> dict:
return {
"id": r.id,
"reimb_no": r.reimb_no,
"applicant": r.applicant,
"department": r.department,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"title": r.title,
"amount": r.amount,
"expense_date": r.expense_date.isoformat() if r.expense_date else None,
"attachment": r.attachment,
"status": r.status,
"check_result": r.check_result,
"check_reason": r.check_reason,
"check_detail": r.check_detail,
"checked_at": r.checked_at.isoformat() if r.checked_at else None,
"approver": r.approver,
"approve_comment": r.approve_comment,
"approved_at": r.approved_at.isoformat() if r.approved_at else None,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _gen_reimb_no(db: Session) -> str:
"""生成报销单号: BX + 年月日 + 序号"""
today = datetime.now()
prefix = "BX" + today.strftime("%Y%m%d")
last = (
db.query(ExpenseReimbursement)
.filter(ExpenseReimbursement.reimb_no.like(prefix + "%"))
.order_by(ExpenseReimbursement.id.desc())
.first()
)
seq = (int(last.reimb_no[-4:]) + 1) if last and last.reimb_no[-4:].isdigit() else 1
return f"{prefix}{seq:04d}"
# ============================================================
# 费用规则 CRUD
# ============================================================
@router.get("/rules")
def list_rules(
expense_type: str = Query(None),
status: str = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询费用规则列表"""
q = db.query(ExpenseRule)
if expense_type:
q = q.filter(ExpenseRule.expense_type == expense_type)
if status:
q = q.filter(ExpenseRule.status == status)
rules = q.order_by(ExpenseRule.id.asc()).all()
return {"data": [_rule_to_dict(r) for r in rules], "total": len(rules)}
@router.post("/rules")
def create_rule(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""新建费用规则"""
rule_name = data.get("rule_name")
expense_type = data.get("expense_type")
limit_type = data.get("limit_type", "single")
limit_amount = data.get("limit_amount")
if not rule_name or not expense_type or limit_amount is None:
raise HTTPException(400, "缺少必要参数: rule_name, expense_type, limit_amount")
if expense_type not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {expense_type}")
if limit_type not in ("single", "monthly", "yearly"):
raise HTTPException(400, f"无效限额类型: {limit_type}")
rule = ExpenseRule(
rule_name=rule_name,
dimension=data.get("dimension", "expense_type"),
dimension_value=data.get("dimension_value", "") or None,
expense_type=expense_type,
limit_type=limit_type,
limit_amount=float(limit_amount),
cycle=data.get("cycle", limit_type),
status=data.get("status", "active"),
remark=data.get("remark", ""),
)
db.add(rule)
db.commit()
db.refresh(rule)
return {"message": "规则已创建", "id": rule.id}
@router.put("/rules/{rule_id}")
def update_rule(
rule_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新费用规则"""
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "规则不存在")
if "rule_name" in data and data["rule_name"]:
rule.rule_name = data["rule_name"]
if "expense_type" in data:
if data["expense_type"] not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {data['expense_type']}")
rule.expense_type = data["expense_type"]
if "dimension" in data:
rule.dimension = data["dimension"]
if "dimension_value" in data:
rule.dimension_value = data["dimension_value"] or None
if "limit_type" in data:
rule.limit_type = data["limit_type"]
if "limit_amount" in data and data["limit_amount"] is not None:
rule.limit_amount = float(data["limit_amount"])
if "status" in data:
rule.status = data["status"]
if "remark" in data:
rule.remark = data["remark"]
db.commit()
db.refresh(rule)
return {"message": "规则已更新", "id": rule.id}
@router.delete("/rules/{rule_id}")
def delete_rule(
rule_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除费用规则"""
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "规则不存在")
db.delete(rule)
db.commit()
return {"message": "规则已删除", "id": rule_id}
@router.post("/rules/seed")
def seed_rules(
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""预置默认费用规则(幂等,按规则名去重)"""
created = 0
for p in PRESET_RULES:
exists = db.query(ExpenseRule).filter(ExpenseRule.rule_name == p["rule_name"]).first()
if exists:
continue
db.add(ExpenseRule(**p))
created += 1
db.commit()
return {"message": f"预置完成,新增 {created} 条规则"}
# ============================================================
# 报销单自动校验
# ============================================================
def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list = None):
"""按启用规则校验报销单,写入 check_result / check_detail / check_reason
规则匹配:
- dimension=expense_type: 全局费用类型规则(dimension_value 可再限定具体类型)
- dimension=department: 部门维度规则(dimension_value 空=所有部门)
- dimension=person: 人员维度规则(dimension_value 空=所有人)
返回 (passed: bool, checks: list)
"""
if rules is None:
rules = db.query(ExpenseRule).filter(ExpenseRule.status == "active").all()
period = (reimb.expense_date or datetime.now()).strftime("%Y-%m")
checks = []
failed_reasons = []
for rule in rules:
# 费用类型必须匹配
if rule.expense_type and rule.expense_type != reimb.expense_type:
continue
# 维度匹配
if rule.dimension == "department":
if not reimb.department:
continue
if rule.dimension_value and rule.dimension_value != reimb.department:
continue
elif rule.dimension == "person":
if rule.dimension_value and rule.dimension_value != reimb.applicant:
continue
elif rule.dimension == "expense_type":
if rule.dimension_value and rule.dimension_value != reimb.expense_type:
continue
else:
continue
actual = reimb.amount
limit_desc = f"{rule.limit_amount:,.0f}"
if rule.limit_type == "single":
passed = reimb.amount <= rule.limit_amount
checks.append({
"rule_id": rule.id,
"rule_name": rule.rule_name,
"rule_type": "单笔限额",
"limit": rule.limit_amount,
"actual": reimb.amount,
"passed": passed,
"detail": f"单笔 {reimb.amount:,.2f}元 vs 限额 {limit_desc}",
})
else:
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
ExpenseReimbursement.expense_type == reimb.expense_type,
ExpenseReimbursement.id != reimb.id,
ExpenseReimbursement.status.in_(["pending", "approved"]),
)
if rule.limit_type == "monthly":
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period)
period_label = f"{period}"
else: # yearly
year = period[:4]
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y") == year)
period_label = f"{year}"
if rule.dimension == "department" and reimb.department:
q = q.filter(ExpenseReimbursement.department == reimb.department)
elif rule.dimension == "person":
q = q.filter(ExpenseReimbursement.applicant == reimb.applicant)
used = q.scalar() or 0.0
actual = used + reimb.amount
passed = actual <= rule.limit_amount
checks.append({
"rule_id": rule.id,
"rule_name": rule.rule_name,
"rule_type": "月度累计" if rule.limit_type == "monthly" else "年度累计",
"limit": rule.limit_amount,
"actual": actual,
"used": used,
"passed": passed,
"detail": f"{period_label}累计 {used:,.2f} + 本次 {reimb.amount:,.2f} = {actual:,.2f}元 vs 限额 {limit_desc}",
})
if not passed:
failed_reasons.append(f"{rule.rule_name}: {checks[-1]['detail']},超限")
passed_all = len(failed_reasons) == 0
reimb.check_result = "pass" if passed_all else "fail"
reimb.check_detail = checks
reimb.check_reason = "".join(failed_reasons) if failed_reasons else None
reimb.checked_at = datetime.now()
# 超限自动打回,合规进入待人工审批
reimb.status = "pending" if passed_all else "returned"
return passed_all, checks
# ============================================================
# 报销单提交 / 查询 / 审批
# ============================================================
@router.post("/reimbursements")
def submit_reimbursement(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""提交报销单 → 自动校验规则(超限自动打回)"""
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
expense_type = data.get("expense_type")
title = data.get("title")
amount = data.get("amount")
if not expense_type or not title or amount is None:
raise HTTPException(400, "缺少必要参数: expense_type, title, amount")
if expense_type not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {expense_type}")
amount = float(amount)
if amount <= 0:
raise HTTPException(400, "报销金额必须大于0")
expense_date = None
if data.get("expense_date"):
try:
expense_date = datetime.strptime(str(data["expense_date"])[:10], "%Y-%m-%d")
except Exception:
expense_date = None
reimb = ExpenseReimbursement(
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
applicant=applicant,
department=data.get("department", ""),
expense_type=expense_type,
title=title,
amount=amount,
expense_date=expense_date,
attachment=data.get("attachment", ""),
status="pending",
check_result="pass",
created_by=current_user.username if hasattr(current_user, "username") else applicant,
)
db.add(reimb)
db.flush() # 先拿到 id 再生成正式单号
reimb.reimb_no = _gen_reimb_no(db)
db.flush()
passed, checks = validate_reimbursement(db, reimb)
db.commit()
db.refresh(reimb)
result = _reimb_to_dict(reimb)
result["auto_check_passed"] = passed
return {"message": "报销单已提交" if passed else "报销单超限,已自动打回", "data": result}
@router.get("/reimbursements")
def list_reimbursements(
status: str = Query(None),
expense_type: str = Query(None),
applicant: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
q = db.query(ExpenseReimbursement)
if status:
q = q.filter(ExpenseReimbursement.status == status)
if expense_type:
q = q.filter(ExpenseReimbursement.expense_type == expense_type)
if applicant:
q = q.filter(ExpenseReimbursement.applicant.like(f"%{applicant}%"))
if keyword:
like = f"%{keyword}%"
q = q.filter(
(ExpenseReimbursement.title.like(like))
| (ExpenseReimbursement.reimb_no.like(like))
| (ExpenseReimbursement.applicant.like(like))
)
items = q.order_by(ExpenseReimbursement.id.desc()).limit(200).all()
return {"data": [_reimb_to_dict(r) for r in items], "total": len(items)}
@router.get("/reimbursements/{reimb_id}")
def get_reimbursement(
reimb_id: int,
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""报销单详情"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
return _reimb_to_dict(r)
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
"""执行审批动作: approve/reject/return"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("pending", "returned"):
raise HTTPException(400, f"当前状态({r.status})不可审批")
if action == "approve":
r.status = "approved"
elif action == "reject":
r.status = "rejected"
else:
r.status = "returned"
r.approver = approver
r.approve_comment = comment or ("" if action == "approve" else "人工打回")
r.approved_at = datetime.now()
db.commit()
db.refresh(r)
return r
@router.post("/reimbursements/{reimb_id}/approve")
def approve_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""审批通过"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/reject")
def reject_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""审批拒绝"""
comment = (data or {}).get("comment", "")
if not comment:
raise HTTPException(400, "拒绝时必须填写审批意见")
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "reject", comment, approver)
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/return")
def return_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""人工打回"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
return {"message": "已打回", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/resubmit")
def resubmit_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""被驳回/打回后重新提交 → 重新自动校验"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("returned", "rejected"):
raise HTTPException(400, f"当前状态({r.status})不可重新提交")
# 允许修改金额/事由后重新校验
if data:
if data.get("amount") is not None:
r.amount = float(data["amount"])
if data.get("title"):
r.title = data["title"]
if data.get("attachment") is not None:
r.attachment = data["attachment"]
passed, _ = validate_reimbursement(db, r)
r.approver = None
r.approve_comment = None
db.commit()
db.refresh(r)
return {"message": "已重新提交" if passed else "仍超限,已再次打回", "data": _reimb_to_dict(r)}
# ============================================================
# 审核看板统计
# ============================================================
@router.get("/stats")
def expense_stats(
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
now = datetime.now()
period = period or now.strftime("%Y-%m")
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by(
ExpenseReimbursement.status
).all():
if st in counts:
counts[st] = cnt
counts["total"] += cnt
# 本月(按费用发生日期)金额统计 by 费用类型 — 统计已提交(待审+已通过)
month_rows = (
db.query(
ExpenseReimbursement.expense_type,
func.coalesce(func.sum(ExpenseReimbursement.amount), 0),
func.count(ExpenseReimbursement.id),
)
.filter(
ExpenseReimbursement.status.in_(["pending", "approved"]),
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
)
.group_by(ExpenseReimbursement.expense_type)
.all()
)
amounts_by_type = [
{
"expense_type": et,
"label": EXPENSE_TYPE_LABELS.get(et, et),
"amount": round(float(amt), 2),
"count": cnt,
}
for et, amt, cnt in month_rows
]
monthly_total = round(sum(x["amount"] for x in amounts_by_type), 2)
# 超限预警列表 — 自动打回(returned + check_result=fail)
over_limit_rows = (
db.query(ExpenseReimbursement)
.filter(
ExpenseReimbursement.status == "returned",
ExpenseReimbursement.check_result == "fail",
)
.order_by(ExpenseReimbursement.id.desc())
.limit(50)
.all()
)
over_limit = [
{
"id": r.id,
"reimb_no": r.reimb_no,
"applicant": r.applicant,
"department": r.department,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"title": r.title,
"amount": r.amount,
"check_reason": r.check_reason,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in over_limit_rows
]
recent_rows = db.query(ExpenseReimbursement).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
recent = [_reimb_to_dict(r) for r in recent_rows]
# 本月预算使用率(招待费15万/月 管理费91万/月)
budget_usage = []
for et, budget in (("entertainment", 150000.0), ("management", 910000.0)):
used = next((x["amount"] for x in amounts_by_type if x["expense_type"] == et), 0.0)
budget_usage.append({
"expense_type": et,
"label": EXPENSE_TYPE_LABELS.get(et, et),
"budget": budget,
"used": used,
"usage_rate": round(used / budget * 100, 1) if budget else 0,
})
return {
"period": period,
"counts": counts,
"amounts_by_type": amounts_by_type,
"monthly_total": monthly_total,
"over_limit": over_limit,
"over_limit_count": len(over_limit),
"recent": recent,
"budget_usage": budget_usage,
}
+2 -1
View File
@@ -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):
+46
View File
@@ -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())
+19
View File
@@ -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
+5 -4
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = {
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<string, string[]> = {
@@ -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)──
+1
View File
@@ -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'] } },
]
},
]
+722
View File
@@ -0,0 +1,722 @@
<template>
<div class="expense-page">
<!-- 页头 -->
<div class="expense-header">
<h3 class="page-title">🧾 费用审核智能体</h3>
<div class="header-right">
<el-button size="small" @click="seedRules">预置规则</el-button>
<el-button size="small" @click="loadAll">刷新</el-button>
<el-button size="small" @click="activeTab = 'rules'">规则配置</el-button>
<el-button type="primary" size="small" @click="openSubmitDialog">提交报销单</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card" @click="filterByStatus('pending')">
<div class="stat-num warn">{{ stats.counts?.pending ?? 0 }}</div>
<div class="stat-label">待人工审批</div>
</div>
<div class="stat-card" @click="filterByStatus('approved')">
<div class="stat-num success">{{ stats.counts?.approved ?? 0 }}</div>
<div class="stat-label">已通过</div>
</div>
<div class="stat-card" @click="filterByStatus('returned')">
<div class="stat-num danger">{{ stats.counts?.returned ?? 0 }}</div>
<div class="stat-label">自动打回</div>
</div>
<div class="stat-card" @click="filterByStatus('rejected')">
<div class="stat-num gray">{{ stats.counts?.rejected ?? 0 }}</div>
<div class="stat-label">已拒绝</div>
</div>
<div class="stat-card highlight">
<div class="stat-num">¥{{ fmt(stats.monthly_total) }}</div>
<div class="stat-label">本月费用总额 ({{ stats.period }})</div>
</div>
</div>
<el-tabs v-model="activeTab">
<!-- 报销单列表 -->
<el-tab-pane label="报销单" name="list">
<div class="filter-row">
<el-select v-model="filters.status" placeholder="状态" clearable size="small" style="width:130px;" @change="loadReimbursements">
<el-option label="待审批" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已拒绝" value="rejected" />
<el-option label="已打回" value="returned" />
</el-select>
<el-select v-model="filters.expense_type" placeholder="费用类型" clearable size="small" style="width:130px;" @change="loadReimbursements">
<el-option v-for="(label, key) in EXPENSE_TYPES" :key="key" :label="label" :value="key" />
</el-select>
<el-input v-model="filters.keyword" placeholder="单号/事由/申请人" clearable size="small" style="width:220px;" @change="loadReimbursements" />
<span class="filter-info"> {{ reimbursements.length }} </span>
</div>
<el-table :data="reimbursements" border stripe size="small" style="width:100%;margin-top:8px;" v-loading="loadingList">
<el-table-column prop="reimb_no" label="单号" width="150" />
<el-table-column prop="applicant" label="申请人" width="90" />
<el-table-column prop="department" label="部门" width="110" />
<el-table-column label="费用类型" width="90">
<template #default="{ row }">
<el-tag size="small" :type="typeTag(row.expense_type)">{{ row.expense_type_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="事由" min-width="160" show-overflow-tooltip />
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }">
<span :class="{ 'amount-over': row.check_result === 'fail' }">¥{{ fmt(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="expense_date" label="费用日期" width="100" />
<el-table-column label="自动校验" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.check_result === 'pass' ? 'success' : 'danger'">
{{ row.check_result === 'pass' ? '通过' : '超限' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openDetail(row)">详情</el-button>
<template v-if="canReview && ['pending', 'returned'].includes(row.status)">
<el-button size="small" link type="success" @click="openReview(row, 'approve')">通过</el-button>
<el-button size="small" link type="danger" @click="openReview(row, 'reject')">拒绝</el-button>
</template>
<el-button v-if="['returned', 'rejected'].includes(row.status)" size="small" link type="warning" @click="resubmit(row)">重提</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 超限预警 -->
<el-tab-pane label="超限预警" name="overlimit">
<el-alert
type="warning" :closable="false" show-icon
:title="`共 ${stats.over_limit_count ?? 0} 笔报销单被自动打回,请关注以下超标准支出`"
style="margin-bottom:8px;"
/>
<el-table :data="stats.over_limit || []" border stripe size="small" style="width:100%;" v-loading="loadingStats">
<el-table-column prop="reimb_no" label="单号" width="150" />
<el-table-column prop="applicant" label="申请人" width="90" />
<el-table-column prop="department" label="部门" width="110" />
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag size="small" :type="typeTag(row.expense_type)">{{ row.expense_type_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="事由" min-width="150" show-overflow-tooltip />
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }"><span class="amount-over">¥{{ fmt(row.amount) }}</span></template>
</el-table-column>
<el-table-column prop="check_reason" label="超限原因" min-width="280" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<!-- 费用统计 -->
<el-tab-pane label="费用统计" name="stats">
<div class="stats-grid">
<el-card shadow="never" class="chart-card">
<template #header><span>本月费用类型统计 ({{ stats.period }})</span></template>
<div ref="typeChartRef" style="width:100%;height:300px;"></div>
</el-card>
<el-card shadow="never" class="chart-card">
<template #header><span>预算使用率</span></template>
<div v-for="b in stats.budget_usage || []" :key="b.expense_type" class="budget-item">
<div class="budget-head">
<span>{{ b.label }}</span>
<span>¥{{ fmt(b.used) }} / ¥{{ fmt(b.budget) }} ({{ b.usage_rate }}%)</span>
</div>
<el-progress :percentage="Math.min(b.usage_rate, 100)" :status="b.usage_rate > 90 ? 'exception' : (b.usage_rate > 70 ? 'warning' : 'success')" :stroke-width="14" />
</div>
<div class="no-data" v-if="!(stats.budget_usage || []).length">暂无预算数据</div>
</el-card>
</div>
</el-tab-pane>
<!-- 规则配置 -->
<el-tab-pane label="规则配置" name="rules">
<div class="filter-row">
<span class="filter-info">自动校验规则 超限报销单将自动打回并标注原因</span>
<span style="margin-left:auto;">
<el-button v-if="canReview" size="small" type="success" @click="openRuleDialog()">新增规则</el-button>
<el-button v-if="canReview" size="small" @click="seedRules">预置默认规则</el-button>
</span>
</div>
<el-table :data="rules" border stripe size="small" style="width:100%;margin-top:8px;" v-loading="loadingRules">
<el-table-column prop="rule_name" label="规则名称" min-width="160" />
<el-table-column label="维度" width="100">
<template #default="{ row }">{{ dimensionLabel(row.dimension) }}</template>
</el-table-column>
<el-table-column prop="dimension_value" label="维度值" width="110">
<template #default="{ row }">{{ row.dimension_value || '全部' }}</template>
</el-table-column>
<el-table-column label="费用类型" width="90">
<template #default="{ row }">
<el-tag size="small" :type="typeTag(row.expense_type)">{{ row.expense_type_label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="限额类型" width="100">
<template #default="{ row }">{{ limitTypeLabel(row.limit_type) }}</template>
</el-table-column>
<el-table-column label="限额" width="110" align="right">
<template #default="{ row }">¥{{ fmt(row.limit_amount) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" v-if="canReview">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openRuleDialog(row)">编辑</el-button>
<el-button size="small" link :type="row.status === 'active' ? 'warning' : 'success'" @click="toggleRule(row)">
{{ row.status === 'active' ? '停用' : '启用' }}
</el-button>
<el-button size="small" link type="danger" @click="deleteRule(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- 提交报销单对话框 -->
<MyDialog v-model="submitVisible" title="提交报销单" :width="560">
<el-form :model="submitForm" label-width="90px" size="small">
<el-form-item label="申请人" required>
<el-input v-model="submitForm.applicant" placeholder="报销申请人" />
</el-form-item>
<el-form-item label="部门">
<el-input v-model="submitForm.department" placeholder="所属部门" />
</el-form-item>
<el-form-item label="费用类型" required>
<el-select v-model="submitForm.expense_type" placeholder="选择费用类型" style="width:100%;">
<el-option v-for="(label, key) in EXPENSE_TYPES" :key="key" :label="label" :value="key" />
</el-select>
</el-form-item>
<el-form-item label="事由" required>
<el-input v-model="submitForm.title" placeholder="报销事由/摘要" />
</el-form-item>
<el-form-item label="金额" required>
<el-input-number v-model="submitForm.amount" :min="0.01" :precision="2" :step="100" style="width:100%;" placeholder="报销金额(元)" />
</el-form-item>
<el-form-item label="费用日期">
<el-date-picker v-model="submitForm.expense_date" type="date" value-format="YYYY-MM-DD" placeholder="费用发生日期" style="width:100%;" />
</el-form-item>
<el-form-item label="附件">
<el-input v-model="submitForm.attachment" placeholder="附件文件名(如: 发票.jpg" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="submitVisible = false">取消</el-button>
<el-button type="primary" size="small" :loading="submitting" @click="doSubmit">提交并自动校验</el-button>
</template>
</MyDialog>
<!-- 规则编辑对话框 -->
<MyDialog v-model="ruleDialogVisible" :title="ruleForm.id ? '编辑规则' : '新增规则'" :width="520">
<el-form :model="ruleForm" label-width="90px" size="small">
<el-form-item label="规则名称" required>
<el-input v-model="ruleForm.rule_name" placeholder="如: 招待费单笔限额" />
</el-form-item>
<el-form-item label="维度">
<el-select v-model="ruleForm.dimension" style="width:100%;">
<el-option label="费用类型(全局)" value="expense_type" />
<el-option label="部门" value="department" />
<el-option label="人员" value="person" />
</el-select>
</el-form-item>
<el-form-item label="维度值">
<el-input v-model="ruleForm.dimension_value" placeholder="部门名/人员名,留空=全部" />
</el-form-item>
<el-form-item label="费用类型" required>
<el-select v-model="ruleForm.expense_type" style="width:100%;">
<el-option v-for="(label, key) in EXPENSE_TYPES" :key="key" :label="label" :value="key" />
</el-select>
</el-form-item>
<el-form-item label="限额类型">
<el-select v-model="ruleForm.limit_type" style="width:100%;">
<el-option label="单笔限额" value="single" />
<el-option label="月度累计限额" value="monthly" />
<el-option label="年度累计限额" value="yearly" />
</el-select>
</el-form-item>
<el-form-item label="限额金额" required>
<el-input-number v-model="ruleForm.limit_amount" :min="0" :precision="2" style="width:100%;" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="ruleForm.remark" type="textarea" :rows="2" placeholder="规则说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="ruleDialogVisible = false">取消</el-button>
<el-button type="primary" size="small" :loading="savingRule" @click="saveRule">{{ ruleForm.id ? '保存' : '创建' }}</el-button>
</template>
</MyDialog>
<!-- 报销单详情对话框 -->
<MyDialog v-model="detailVisible" title="报销单详情" :width="720">
<template v-if="detail">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="单号">{{ detail.reimb_no }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ detail.applicant }}</el-descriptions-item>
<el-descriptions-item label="部门">{{ detail.department || '-' }}</el-descriptions-item>
<el-descriptions-item label="费用类型">
<el-tag size="small" :type="typeTag(detail.expense_type)">{{ detail.expense_type_label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="金额">
<span :class="{ 'amount-over': detail.check_result === 'fail' }">¥{{ fmt(detail.amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="费用日期">{{ detail.expense_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="事由" :span="3">{{ detail.title }}</el-descriptions-item>
<el-descriptions-item label="附件" :span="3">{{ detail.attachment || '无' }}</el-descriptions-item>
<el-descriptions-item label="自动校验">
<el-tag size="small" :type="detail.check_result === 'pass' ? 'success' : 'danger'">
{{ detail.check_result === 'pass' ? '通过' : '超限打回' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(detail.status) }}</el-descriptions-item>
<el-descriptions-item label="审批人">{{ detail.approver || '-' }}</el-descriptions-item>
<el-descriptions-item label="提交时间" :span="2">{{ detail.created_at }}</el-descriptions-item>
<el-descriptions-item label="审批意见" :span="3">{{ detail.approve_comment || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="check-block" v-if="detail.check_detail && detail.check_detail.length">
<div class="check-title">校验明细</div>
<div v-for="(c, i) in detail.check_detail" :key="i" class="check-item">
<el-tag size="small" :type="c.passed ? 'success' : 'danger'" style="width:80px;text-align:center;">
{{ c.passed ? '通过' : '超限' }}
</el-tag>
<span class="check-name">{{ c.rule_name }} ({{ c.rule_type }})</span>
<span class="check-detail">{{ c.detail }}</span>
</div>
<div v-if="detail.check_reason" class="check-reason"> 超限原因{{ detail.check_reason }}</div>
</div>
<div v-if="canReview && ['pending', 'returned'].includes(detail.status)" class="detail-actions">
<el-button size="small" type="success" @click="openReview(detail, 'approve')">通过</el-button>
<el-button size="small" type="danger" @click="openReview(detail, 'reject')">拒绝</el-button>
<el-button size="small" @click="openReview(detail, 'return')">人工打回</el-button>
</div>
</template>
</MyDialog>
<!-- 审批操作对话框 -->
<MyDialog v-model="reviewVisible" :title="reviewTitle" :width="460">
<el-form label-width="80px" size="small">
<el-form-item label="报销单">{{ review.reimb?.reimb_no }} ¥{{ fmt(review.reimb?.amount) }}</el-form-item>
<el-form-item label="审批意见" :required="review.mode === 'reject'">
<el-input v-model="review.comment" type="textarea" :rows="3" :placeholder="review.mode === 'reject' ? '拒绝时必须填写意见' : '选填'" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="reviewVisible = false">取消</el-button>
<el-button size="small" :type="review.mode === 'approve' ? 'success' : (review.mode === 'reject' ? 'danger' : 'warning')" :loading="reviewing" @click="doReview">
{{ review.mode === 'approve' ? '确认通过' : (review.mode === 'reject' ? '确认拒绝' : '确认打回') }}
</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { expenseApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const EXPENSE_TYPES: Record<string, string> = {
entertainment: '招待费',
travel: '差旅费',
office: '办公费',
management: '管理费',
}
const userStr = localStorage.getItem('cma_user') || '{}'
const user = JSON.parse(userStr)
const role: string = user.role || ''
const canReview = ['ceo', 'finance'].includes(role)
const activeTab = ref('list')
const loadingList = ref(false)
const loadingRules = ref(false)
const loadingStats = ref(false)
//
const stats = ref<any>({ counts: {}, over_limit: [], budget_usage: [], amounts_by_type: [] })
//
const filters = reactive({ status: '', expense_type: '', keyword: '' })
const reimbursements = ref<any[]>([])
function filterByStatus(st: string) {
filters.status = st
activeTab.value = 'list'
loadReimbursements()
}
//
const rules = ref<any[]>([])
//
const submitVisible = ref(false)
const submitting = ref(false)
const submitForm = reactive({
applicant: user.name || user.username || '',
department: '',
expense_type: '',
title: '',
amount: undefined as number | undefined,
expense_date: '',
attachment: '',
})
const ruleDialogVisible = ref(false)
const savingRule = ref(false)
const ruleForm = reactive({
id: 0,
rule_name: '',
dimension: 'expense_type',
dimension_value: '',
expense_type: 'entertainment',
limit_type: 'single',
limit_amount: 1000,
remark: '',
})
const detailVisible = ref(false)
const detail = ref<any>(null)
const reviewVisible = ref(false)
const reviewing = ref(false)
const review = reactive({ mode: 'approve' as 'approve' | 'reject' | 'return', reimb: null as any, comment: '' })
const reviewTitle = computed(() => {
const m: Record<string, string> = { approve: '审批通过', reject: '审批拒绝', return: '人工打回' }
return m[review.mode]
})
//
const typeChartRef = ref<HTMLElement | null>(null)
//
function fmt(v: any): string {
if (v === null || v === undefined) return '0'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function statusLabel(s: string): string {
const m: Record<string, string> = { pending: '待审批', approved: '已通过', rejected: '已拒绝', returned: '已打回' }
return m[s] || s
}
function statusTag(s: string): string {
const m: Record<string, string> = { pending: 'warning', approved: 'success', rejected: 'danger', returned: 'info' }
return m[s] || 'info'
}
function typeTag(t: string): string {
const m: Record<string, string> = { entertainment: 'danger', travel: 'warning', office: 'primary', management: 'info' }
return m[t] || 'info'
}
function dimensionLabel(d: string): string {
const m: Record<string, string> = { expense_type: '费用类型', department: '部门', person: '人员' }
return m[d] || d
}
function limitTypeLabel(l: string): string {
const m: Record<string, string> = { single: '单笔', monthly: '月度累计', yearly: '年度累计' }
return m[l] || l
}
//
async function loadStats() {
loadingStats.value = true
try {
const r: any = await expenseApi.stats()
stats.value = r || {}
renderChart()
} catch (e: any) {
ElMessage.error('统计加载失败: ' + (e?.response?.data?.detail || e.message))
} finally {
loadingStats.value = false
}
}
async function loadReimbursements() {
loadingList.value = true
try {
const r: any = await expenseApi.listReimbursements({
status: filters.status || undefined,
expense_type: filters.expense_type || undefined,
keyword: filters.keyword || undefined,
})
reimbursements.value = r.data || []
} catch (e: any) {
ElMessage.error('报销单加载失败: ' + (e?.response?.data?.detail || e.message))
} finally {
loadingList.value = false
}
}
async function loadRules() {
loadingRules.value = true
try {
const r: any = await expenseApi.listRules()
rules.value = r.data || []
} catch (e: any) {
ElMessage.error('规则加载失败: ' + (e?.response?.data?.detail || e.message))
} finally {
loadingRules.value = false
}
}
function loadAll() {
loadStats()
loadReimbursements()
loadRules()
}
//
async function renderChart() {
await nextTick()
if (!typeChartRef.value) return
const echarts: any = await import('echarts')
const el = typeChartRef.value
const chart = echarts.getInstanceByDom(el) || echarts.init(el)
const items = stats.value.amounts_by_type || []
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 20, top: 30, bottom: 40 },
xAxis: { type: 'category', data: items.map((i: any) => i.label) },
yAxis: { type: 'value', name: '元' },
series: [{
type: 'bar',
data: items.map((i: any) => i.amount),
barWidth: 48,
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: (p: any) => {
const colors = ['#F56C6C', '#E6A23C', '#409EFF', '#909399']
return colors[p.dataIndex % colors.length]
},
},
label: { show: true, position: 'top', formatter: (p: any) => '¥' + Number(p.value).toLocaleString() },
}],
})
}
//
function openSubmitDialog() {
submitForm.applicant = user.name || user.username || ''
submitForm.department = ''
submitForm.expense_type = ''
submitForm.title = ''
submitForm.amount = undefined
submitForm.expense_date = ''
submitForm.attachment = ''
submitVisible.value = true
}
async function doSubmit() {
if (!submitForm.applicant || !submitForm.expense_type || !submitForm.title || !submitForm.amount) {
ElMessage.warning('请填写申请人、费用类型、事由和金额')
return
}
submitting.value = true
try {
const r: any = await expenseApi.submitReimbursement({ ...submitForm })
submitVisible.value = false
ElMessage.success(r.message || '已提交')
if (r.data && r.data.check_result === 'fail') {
ElMessageBox.alert(`该报销单超限,已自动打回:\n${r.data.check_reason || ''}`, '自动校验未通过', { confirmButtonText: '知道了' })
}
loadAll()
} catch (e: any) {
ElMessage.error('提交失败: ' + (e?.response?.data?.detail || e.message))
} finally {
submitting.value = false
}
}
//
function openReview(row: any, mode: 'approve' | 'reject' | 'return') {
review.mode = mode
review.reimb = row
review.comment = ''
reviewVisible.value = true
}
async function doReview() {
if (!review.reimb) return
if (review.mode === 'reject' && !review.comment.trim()) {
ElMessage.warning('拒绝时必须填写审批意见')
return
}
reviewing.value = true
try {
const id = review.reimb.id
if (review.mode === 'approve') await expenseApi.approveReimbursement(id, review.comment)
else if (review.mode === 'reject') await expenseApi.rejectReimbursement(id, review.comment)
else await expenseApi.returnReimbursement(id, review.comment)
reviewVisible.value = false
detailVisible.value = false
ElMessage.success('操作成功')
loadAll()
} catch (e: any) {
ElMessage.error('操作失败: ' + (e?.response?.data?.detail || e.message))
} finally {
reviewing.value = false
}
}
async function resubmit(row: any) {
try {
await ElMessageBox.confirm(`确认重新提交「${row.reimb_no}」?将重新执行自动校验。`, '重新提交', { confirmButtonText: '重新提交', cancelButtonText: '取消' })
} catch {
return
}
try {
const r: any = await expenseApi.resubmitReimbursement(row.id)
ElMessage.success(r.message || '已重新提交')
loadAll()
} catch (e: any) {
ElMessage.error('重提失败: ' + (e?.response?.data?.detail || e.message))
}
}
//
async function openDetail(row: any) {
try {
const r: any = await expenseApi.getReimbursement(row.id)
detail.value = r
detailVisible.value = true
} catch (e: any) {
ElMessage.error('详情加载失败: ' + (e?.response?.data?.detail || e.message))
}
}
// CRUD
function openRuleDialog(row?: any) {
if (row) {
Object.assign(ruleForm, {
id: row.id, rule_name: row.rule_name, dimension: row.dimension,
dimension_value: row.dimension_value || '', expense_type: row.expense_type,
limit_type: row.limit_type, limit_amount: row.limit_amount, remark: row.remark || '',
})
} else {
Object.assign(ruleForm, {
id: 0, rule_name: '', dimension: 'expense_type', dimension_value: '',
expense_type: 'entertainment', limit_type: 'single', limit_amount: 1000, remark: '',
})
}
ruleDialogVisible.value = true
}
async function saveRule() {
if (!ruleForm.rule_name || !ruleForm.expense_type || ruleForm.limit_amount === null || ruleForm.limit_amount === undefined) {
ElMessage.warning('请填写规则名称、费用类型和限额金额')
return
}
savingRule.value = true
try {
if (ruleForm.id) {
await expenseApi.updateRule(ruleForm.id, { ...ruleForm })
} else {
await expenseApi.createRule({ ...ruleForm })
}
ruleDialogVisible.value = false
ElMessage.success('规则已保存')
loadRules()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e.message))
} finally {
savingRule.value = false
}
}
async function toggleRule(row: any) {
try {
await expenseApi.updateRule(row.id, { status: row.status === 'active' ? 'inactive' : 'active' })
ElMessage.success(row.status === 'active' ? '已停用' : '已启用')
loadRules()
} catch (e: any) {
ElMessage.error('操作失败: ' + (e?.response?.data?.detail || e.message))
}
}
async function deleteRule(row: any) {
try {
await ElMessageBox.confirm(`确认删除规则「${row.rule_name}」?`, '删除规则', { confirmButtonText: '删除', cancelButtonText: '取消' })
} catch {
return
}
try {
await expenseApi.deleteRule(row.id)
ElMessage.success('已删除')
loadRules()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e?.response?.data?.detail || e.message))
}
}
async function seedRules() {
try {
await ElMessageBox.confirm('将预置默认费用规则(招待费/差旅/办公/管理费标准),已有同名规则跳过。', '预置规则', { confirmButtonText: '预置', cancelButtonText: '取消' })
} catch {
return
}
try {
const r: any = await expenseApi.seedRules()
ElMessage.success(r.message || '预置完成')
loadRules()
} catch (e: any) {
ElMessage.error('预置失败: ' + (e?.response?.data?.detail || e.message))
}
}
onMounted(() => {
loadAll()
})
</script>
<style scoped>
.expense-page { padding: 4px; }
.expense-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.expense-header .page-title { margin: 0; font-size: 18px; }
.header-right { display: flex; gap: 8px; }
.stat-cards { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.stat-card {
flex: 1; min-width: 140px; background: #fff; border-radius: 8px; padding: 14px 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; transition: transform .15s;
}
.stat-card:hover { transform: translateY(-2px); }
.stat-card.highlight { background: linear-gradient(135deg, #409EFF, #337ecc); color: #fff; cursor: default; }
.stat-num { font-size: 24px; font-weight: 700; }
.stat-num.warn { color: #E6A23C; }
.stat-num.success { color: #67C23A; }
.stat-num.danger { color: #F56C6C; }
.stat-num.gray { color: #909399; }
.stat-card.highlight .stat-num { color: #fff; }
.stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
.stat-card.highlight .stat-label { color: rgba(255,255,255,0.85); }
.filter-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filter-info { font-size: 12px; color: #909399; }
.amount-over { color: #F56C6C; font-weight: 600; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media screen and (max-width: 900px) { .stats-grid { grid-template-columns: 1fr; } }
.budget-item { margin-bottom: 16px; }
.budget-head { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; color: #606266; }
.no-data { color: #909399; font-size: 13px; padding: 20px 0; text-align: center; }
.check-block { margin-top: 14px; background: #f8f9fb; border-radius: 6px; padding: 12px; }
.check-title { font-weight: 600; font-size: 13px; margin-bottom: 8px; color: #303133; }
.check-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
.check-name { font-weight: 500; white-space: nowrap; color: #303133; }
.check-detail { color: #606266; }
.check-reason { margin-top: 8px; color: #F56C6C; font-size: 13px; font-weight: 500; }
.detail-actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
</style>