feat: 费用审核智能体—规则+报销流程+看板
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
"""费用审核智能体 API — 管理会计OS
|
||||
费用规则配置 + 报销单自动校验 + 人工审批流程 + 审核看板统计
|
||||
|
||||
流程:
|
||||
1. 提交报销单 → 自动校验费用规则
|
||||
2. 超限 → 自动打回(returned, 标注原因)
|
||||
3. 合规 → 待人工审批(pending)
|
||||
4. 审批通过(approved) / 拒绝(rejected)
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import ExpenseRule, ExpenseReimbursement, OperationLog
|
||||
|
||||
router = APIRouter(prefix="/api/cma/expenses", tags=["费用审核"])
|
||||
|
||||
# 费用类型中文映射
|
||||
EXPENSE_TYPE_LABELS = {
|
||||
"entertainment": "招待费",
|
||||
"travel": "差旅费",
|
||||
"office": "办公费",
|
||||
"management": "管理费",
|
||||
}
|
||||
|
||||
# 预置规则
|
||||
PRESET_RULES = [
|
||||
{"rule_name": "招待费单笔限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
|
||||
"cycle": "single", "remark": "招待费标准:单笔≤2000元"},
|
||||
{"rule_name": "招待费部门月限额", "dimension": "department", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 50000.0,
|
||||
"cycle": "monthly", "remark": "招待费标准:部门月限额5万元"},
|
||||
{"rule_name": "招待费月度总额限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 150000.0,
|
||||
"cycle": "monthly", "remark": "公司招待费预算15万/月"},
|
||||
{"rule_name": "差旅住宿单晚限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "travel", "limit_type": "single", "limit_amount": 300.0,
|
||||
"cycle": "single", "remark": "差旅费标准:住宿≤300元/晚"},
|
||||
{"rule_name": "差旅交通等级限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "travel", "limit_type": "single", "limit_amount": 1500.0,
|
||||
"cycle": "single", "remark": "差旅费标准:交通等级(高铁二等座/经济舱)"},
|
||||
{"rule_name": "办公费单笔限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "office", "limit_type": "single", "limit_amount": 500.0,
|
||||
"cycle": "single", "remark": "办公费标准:单笔≤500元"},
|
||||
{"rule_name": "管理费月度总额限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "management", "limit_type": "monthly", "limit_amount": 910000.0,
|
||||
"cycle": "monthly", "remark": "管理费预算91万/月"},
|
||||
]
|
||||
|
||||
|
||||
def _rule_to_dict(r: ExpenseRule) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"rule_name": r.rule_name,
|
||||
"dimension": r.dimension,
|
||||
"dimension_value": r.dimension_value,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"limit_type": r.limit_type,
|
||||
"limit_amount": r.limit_amount,
|
||||
"cycle": r.cycle,
|
||||
"status": r.status,
|
||||
"remark": r.remark,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _reimb_to_dict(r: ExpenseReimbursement) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"reimb_no": r.reimb_no,
|
||||
"applicant": r.applicant,
|
||||
"department": r.department,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"title": r.title,
|
||||
"amount": r.amount,
|
||||
"expense_date": r.expense_date.isoformat() if r.expense_date else None,
|
||||
"attachment": r.attachment,
|
||||
"status": r.status,
|
||||
"check_result": r.check_result,
|
||||
"check_reason": r.check_reason,
|
||||
"check_detail": r.check_detail,
|
||||
"checked_at": r.checked_at.isoformat() if r.checked_at else None,
|
||||
"approver": r.approver,
|
||||
"approve_comment": r.approve_comment,
|
||||
"approved_at": r.approved_at.isoformat() if r.approved_at else None,
|
||||
"created_by": r.created_by,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _gen_reimb_no(db: Session) -> str:
|
||||
"""生成报销单号: BX + 年月日 + 序号"""
|
||||
today = datetime.now()
|
||||
prefix = "BX" + today.strftime("%Y%m%d")
|
||||
last = (
|
||||
db.query(ExpenseReimbursement)
|
||||
.filter(ExpenseReimbursement.reimb_no.like(prefix + "%"))
|
||||
.order_by(ExpenseReimbursement.id.desc())
|
||||
.first()
|
||||
)
|
||||
seq = (int(last.reimb_no[-4:]) + 1) if last and last.reimb_no[-4:].isdigit() else 1
|
||||
return f"{prefix}{seq:04d}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 费用规则 CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("/rules")
|
||||
def list_rules(
|
||||
expense_type: str = Query(None),
|
||||
status: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询费用规则列表"""
|
||||
q = db.query(ExpenseRule)
|
||||
if expense_type:
|
||||
q = q.filter(ExpenseRule.expense_type == expense_type)
|
||||
if status:
|
||||
q = q.filter(ExpenseRule.status == status)
|
||||
rules = q.order_by(ExpenseRule.id.asc()).all()
|
||||
return {"data": [_rule_to_dict(r) for r in rules], "total": len(rules)}
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
def create_rule(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""新建费用规则"""
|
||||
rule_name = data.get("rule_name")
|
||||
expense_type = data.get("expense_type")
|
||||
limit_type = data.get("limit_type", "single")
|
||||
limit_amount = data.get("limit_amount")
|
||||
if not rule_name or not expense_type or limit_amount is None:
|
||||
raise HTTPException(400, "缺少必要参数: rule_name, expense_type, limit_amount")
|
||||
if expense_type not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {expense_type}")
|
||||
if limit_type not in ("single", "monthly", "yearly"):
|
||||
raise HTTPException(400, f"无效限额类型: {limit_type}")
|
||||
|
||||
rule = ExpenseRule(
|
||||
rule_name=rule_name,
|
||||
dimension=data.get("dimension", "expense_type"),
|
||||
dimension_value=data.get("dimension_value", "") or None,
|
||||
expense_type=expense_type,
|
||||
limit_type=limit_type,
|
||||
limit_amount=float(limit_amount),
|
||||
cycle=data.get("cycle", limit_type),
|
||||
status=data.get("status", "active"),
|
||||
remark=data.get("remark", ""),
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"message": "规则已创建", "id": rule.id}
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
def update_rule(
|
||||
rule_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新费用规则"""
|
||||
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
if "rule_name" in data and data["rule_name"]:
|
||||
rule.rule_name = data["rule_name"]
|
||||
if "expense_type" in data:
|
||||
if data["expense_type"] not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {data['expense_type']}")
|
||||
rule.expense_type = data["expense_type"]
|
||||
if "dimension" in data:
|
||||
rule.dimension = data["dimension"]
|
||||
if "dimension_value" in data:
|
||||
rule.dimension_value = data["dimension_value"] or None
|
||||
if "limit_type" in data:
|
||||
rule.limit_type = data["limit_type"]
|
||||
if "limit_amount" in data and data["limit_amount"] is not None:
|
||||
rule.limit_amount = float(data["limit_amount"])
|
||||
if "status" in data:
|
||||
rule.status = data["status"]
|
||||
if "remark" in data:
|
||||
rule.remark = data["remark"]
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"message": "规则已更新", "id": rule.id}
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
def delete_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除费用规则"""
|
||||
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
db.delete(rule)
|
||||
db.commit()
|
||||
return {"message": "规则已删除", "id": rule_id}
|
||||
|
||||
|
||||
@router.post("/rules/seed")
|
||||
def seed_rules(
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""预置默认费用规则(幂等,按规则名去重)"""
|
||||
created = 0
|
||||
for p in PRESET_RULES:
|
||||
exists = db.query(ExpenseRule).filter(ExpenseRule.rule_name == p["rule_name"]).first()
|
||||
if exists:
|
||||
continue
|
||||
db.add(ExpenseRule(**p))
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"预置完成,新增 {created} 条规则"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报销单自动校验
|
||||
# ============================================================
|
||||
|
||||
def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list = None):
|
||||
"""按启用规则校验报销单,写入 check_result / check_detail / check_reason
|
||||
|
||||
规则匹配:
|
||||
- dimension=expense_type: 全局费用类型规则(dimension_value 可再限定具体类型)
|
||||
- dimension=department: 部门维度规则(dimension_value 空=所有部门)
|
||||
- dimension=person: 人员维度规则(dimension_value 空=所有人)
|
||||
返回 (passed: bool, checks: list)
|
||||
"""
|
||||
if rules is None:
|
||||
rules = db.query(ExpenseRule).filter(ExpenseRule.status == "active").all()
|
||||
|
||||
period = (reimb.expense_date or datetime.now()).strftime("%Y-%m")
|
||||
checks = []
|
||||
failed_reasons = []
|
||||
|
||||
for rule in rules:
|
||||
# 费用类型必须匹配
|
||||
if rule.expense_type and rule.expense_type != reimb.expense_type:
|
||||
continue
|
||||
# 维度匹配
|
||||
if rule.dimension == "department":
|
||||
if not reimb.department:
|
||||
continue
|
||||
if rule.dimension_value and rule.dimension_value != reimb.department:
|
||||
continue
|
||||
elif rule.dimension == "person":
|
||||
if rule.dimension_value and rule.dimension_value != reimb.applicant:
|
||||
continue
|
||||
elif rule.dimension == "expense_type":
|
||||
if rule.dimension_value and rule.dimension_value != reimb.expense_type:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
actual = reimb.amount
|
||||
limit_desc = f"{rule.limit_amount:,.0f}元"
|
||||
|
||||
if rule.limit_type == "single":
|
||||
passed = reimb.amount <= rule.limit_amount
|
||||
checks.append({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.rule_name,
|
||||
"rule_type": "单笔限额",
|
||||
"limit": rule.limit_amount,
|
||||
"actual": reimb.amount,
|
||||
"passed": passed,
|
||||
"detail": f"单笔 {reimb.amount:,.2f}元 vs 限额 {limit_desc}",
|
||||
})
|
||||
else:
|
||||
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
|
||||
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
|
||||
ExpenseReimbursement.expense_type == reimb.expense_type,
|
||||
ExpenseReimbursement.id != reimb.id,
|
||||
ExpenseReimbursement.status.in_(["pending", "approved"]),
|
||||
)
|
||||
if rule.limit_type == "monthly":
|
||||
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period)
|
||||
period_label = f"{period}月"
|
||||
else: # yearly
|
||||
year = period[:4]
|
||||
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y") == year)
|
||||
period_label = f"{year}年"
|
||||
if rule.dimension == "department" and reimb.department:
|
||||
q = q.filter(ExpenseReimbursement.department == reimb.department)
|
||||
elif rule.dimension == "person":
|
||||
q = q.filter(ExpenseReimbursement.applicant == reimb.applicant)
|
||||
|
||||
used = q.scalar() or 0.0
|
||||
actual = used + reimb.amount
|
||||
passed = actual <= rule.limit_amount
|
||||
checks.append({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.rule_name,
|
||||
"rule_type": "月度累计" if rule.limit_type == "monthly" else "年度累计",
|
||||
"limit": rule.limit_amount,
|
||||
"actual": actual,
|
||||
"used": used,
|
||||
"passed": passed,
|
||||
"detail": f"{period_label}累计 {used:,.2f} + 本次 {reimb.amount:,.2f} = {actual:,.2f}元 vs 限额 {limit_desc}",
|
||||
})
|
||||
|
||||
if not passed:
|
||||
failed_reasons.append(f"{rule.rule_name}: {checks[-1]['detail']},超限")
|
||||
|
||||
passed_all = len(failed_reasons) == 0
|
||||
reimb.check_result = "pass" if passed_all else "fail"
|
||||
reimb.check_detail = checks
|
||||
reimb.check_reason = ";".join(failed_reasons) if failed_reasons else None
|
||||
reimb.checked_at = datetime.now()
|
||||
# 超限自动打回,合规进入待人工审批
|
||||
reimb.status = "pending" if passed_all else "returned"
|
||||
return passed_all, checks
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报销单提交 / 查询 / 审批
|
||||
# ============================================================
|
||||
|
||||
@router.post("/reimbursements")
|
||||
def submit_reimbursement(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""提交报销单 → 自动校验规则(超限自动打回)"""
|
||||
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
|
||||
expense_type = data.get("expense_type")
|
||||
title = data.get("title")
|
||||
amount = data.get("amount")
|
||||
|
||||
if not expense_type or not title or amount is None:
|
||||
raise HTTPException(400, "缺少必要参数: expense_type, title, amount")
|
||||
if expense_type not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {expense_type}")
|
||||
amount = float(amount)
|
||||
if amount <= 0:
|
||||
raise HTTPException(400, "报销金额必须大于0")
|
||||
|
||||
expense_date = None
|
||||
if data.get("expense_date"):
|
||||
try:
|
||||
expense_date = datetime.strptime(str(data["expense_date"])[:10], "%Y-%m-%d")
|
||||
except Exception:
|
||||
expense_date = None
|
||||
|
||||
reimb = ExpenseReimbursement(
|
||||
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
|
||||
applicant=applicant,
|
||||
department=data.get("department", ""),
|
||||
expense_type=expense_type,
|
||||
title=title,
|
||||
amount=amount,
|
||||
expense_date=expense_date,
|
||||
attachment=data.get("attachment", ""),
|
||||
status="pending",
|
||||
check_result="pass",
|
||||
created_by=current_user.username if hasattr(current_user, "username") else applicant,
|
||||
)
|
||||
db.add(reimb)
|
||||
db.flush() # 先拿到 id 再生成正式单号
|
||||
reimb.reimb_no = _gen_reimb_no(db)
|
||||
db.flush()
|
||||
|
||||
passed, checks = validate_reimbursement(db, reimb)
|
||||
db.commit()
|
||||
db.refresh(reimb)
|
||||
|
||||
result = _reimb_to_dict(reimb)
|
||||
result["auto_check_passed"] = passed
|
||||
return {"message": "报销单已提交" if passed else "报销单超限,已自动打回", "data": result}
|
||||
|
||||
|
||||
@router.get("/reimbursements")
|
||||
def list_reimbursements(
|
||||
status: str = Query(None),
|
||||
expense_type: str = Query(None),
|
||||
applicant: str = Query(None),
|
||||
keyword: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
|
||||
q = db.query(ExpenseReimbursement)
|
||||
if status:
|
||||
q = q.filter(ExpenseReimbursement.status == status)
|
||||
if expense_type:
|
||||
q = q.filter(ExpenseReimbursement.expense_type == expense_type)
|
||||
if applicant:
|
||||
q = q.filter(ExpenseReimbursement.applicant.like(f"%{applicant}%"))
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
(ExpenseReimbursement.title.like(like))
|
||||
| (ExpenseReimbursement.reimb_no.like(like))
|
||||
| (ExpenseReimbursement.applicant.like(like))
|
||||
)
|
||||
items = q.order_by(ExpenseReimbursement.id.desc()).limit(200).all()
|
||||
return {"data": [_reimb_to_dict(r) for r in items], "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/reimbursements/{reimb_id}")
|
||||
def get_reimbursement(
|
||||
reimb_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""报销单详情"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
return _reimb_to_dict(r)
|
||||
|
||||
|
||||
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
|
||||
"""执行审批动作: approve/reject/return"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
if r.status not in ("pending", "returned"):
|
||||
raise HTTPException(400, f"当前状态({r.status})不可审批")
|
||||
|
||||
if action == "approve":
|
||||
r.status = "approved"
|
||||
elif action == "reject":
|
||||
r.status = "rejected"
|
||||
else:
|
||||
r.status = "returned"
|
||||
r.approver = approver
|
||||
r.approve_comment = comment or ("" if action == "approve" else "人工打回")
|
||||
r.approved_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/approve")
|
||||
def approve_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""审批通过"""
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
|
||||
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/reject")
|
||||
def reject_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""审批拒绝"""
|
||||
comment = (data or {}).get("comment", "")
|
||||
if not comment:
|
||||
raise HTTPException(400, "拒绝时必须填写审批意见")
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "reject", comment, approver)
|
||||
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/return")
|
||||
def return_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""人工打回"""
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
|
||||
return {"message": "已打回", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/resubmit")
|
||||
def resubmit_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""被驳回/打回后重新提交 → 重新自动校验"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
if r.status not in ("returned", "rejected"):
|
||||
raise HTTPException(400, f"当前状态({r.status})不可重新提交")
|
||||
|
||||
# 允许修改金额/事由后重新校验
|
||||
if data:
|
||||
if data.get("amount") is not None:
|
||||
r.amount = float(data["amount"])
|
||||
if data.get("title"):
|
||||
r.title = data["title"]
|
||||
if data.get("attachment") is not None:
|
||||
r.attachment = data["attachment"]
|
||||
|
||||
passed, _ = validate_reimbursement(db, r)
|
||||
r.approver = None
|
||||
r.approve_comment = None
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return {"message": "已重新提交" if passed else "仍超限,已再次打回", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 审核看板统计
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats")
|
||||
def expense_stats(
|
||||
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
|
||||
now = datetime.now()
|
||||
period = period or now.strftime("%Y-%m")
|
||||
|
||||
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
|
||||
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by(
|
||||
ExpenseReimbursement.status
|
||||
).all():
|
||||
if st in counts:
|
||||
counts[st] = cnt
|
||||
counts["total"] += cnt
|
||||
|
||||
# 本月(按费用发生日期)金额统计 by 费用类型 — 统计已提交(待审+已通过)
|
||||
month_rows = (
|
||||
db.query(
|
||||
ExpenseReimbursement.expense_type,
|
||||
func.coalesce(func.sum(ExpenseReimbursement.amount), 0),
|
||||
func.count(ExpenseReimbursement.id),
|
||||
)
|
||||
.filter(
|
||||
ExpenseReimbursement.status.in_(["pending", "approved"]),
|
||||
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
|
||||
)
|
||||
.group_by(ExpenseReimbursement.expense_type)
|
||||
.all()
|
||||
)
|
||||
amounts_by_type = [
|
||||
{
|
||||
"expense_type": et,
|
||||
"label": EXPENSE_TYPE_LABELS.get(et, et),
|
||||
"amount": round(float(amt), 2),
|
||||
"count": cnt,
|
||||
}
|
||||
for et, amt, cnt in month_rows
|
||||
]
|
||||
|
||||
monthly_total = round(sum(x["amount"] for x in amounts_by_type), 2)
|
||||
|
||||
# 超限预警列表 — 自动打回(returned + check_result=fail)
|
||||
over_limit_rows = (
|
||||
db.query(ExpenseReimbursement)
|
||||
.filter(
|
||||
ExpenseReimbursement.status == "returned",
|
||||
ExpenseReimbursement.check_result == "fail",
|
||||
)
|
||||
.order_by(ExpenseReimbursement.id.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
over_limit = [
|
||||
{
|
||||
"id": r.id,
|
||||
"reimb_no": r.reimb_no,
|
||||
"applicant": r.applicant,
|
||||
"department": r.department,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"title": r.title,
|
||||
"amount": r.amount,
|
||||
"check_reason": r.check_reason,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in over_limit_rows
|
||||
]
|
||||
|
||||
recent_rows = db.query(ExpenseReimbursement).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
|
||||
recent = [_reimb_to_dict(r) for r in recent_rows]
|
||||
|
||||
# 本月预算使用率(招待费15万/月 管理费91万/月)
|
||||
budget_usage = []
|
||||
for et, budget in (("entertainment", 150000.0), ("management", 910000.0)):
|
||||
used = next((x["amount"] for x in amounts_by_type if x["expense_type"] == et), 0.0)
|
||||
budget_usage.append({
|
||||
"expense_type": et,
|
||||
"label": EXPENSE_TYPE_LABELS.get(et, et),
|
||||
"budget": budget,
|
||||
"used": used,
|
||||
"usage_rate": round(used / budget * 100, 1) if budget else 0,
|
||||
})
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"counts": counts,
|
||||
"amounts_by_type": amounts_by_type,
|
||||
"monthly_total": monthly_total,
|
||||
"over_limit": over_limit,
|
||||
"over_limit_count": len(over_limit),
|
||||
"recent": recent,
|
||||
"budget_usage": budget_usage,
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results, expenses
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
@@ -73,6 +73,7 @@ app.include_router(driver_budget.router)
|
||||
app.include_router(bot_kpis.router)
|
||||
app.include_router(bot_iron_law.router)
|
||||
app.include_router(analysis_results.router)
|
||||
app.include_router(expenses.router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -530,3 +530,49 @@ class VoucherDetail(Base):
|
||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
|
||||
period = Column(String(20), nullable=True, comment="期间 YYYY-MM")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 费用审核智能体 (2026-08)
|
||||
# ============================================================
|
||||
|
||||
class ExpenseRule(Base):
|
||||
"""费用规则 — 自动校验报销单的标准"""
|
||||
__tablename__ = "expense_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
rule_name = Column(String(100), nullable=False, comment="规则名称")
|
||||
dimension = Column(String(20), nullable=False, comment="维度: department/person/expense_type")
|
||||
dimension_value = Column(String(100), nullable=True, comment="维度值: 部门名/人员名/费用类型(空=全局)")
|
||||
expense_type = Column(String(30), nullable=False, comment="费用类型: entertainment/travel/office/management")
|
||||
limit_type = Column(String(20), nullable=False, comment="限额类型: single/monthly/yearly 单笔/月度累计/年度累计")
|
||||
limit_amount = Column(Float, nullable=False, comment="限额金额")
|
||||
cycle = Column(String(20), default="monthly", comment="周期: single/monthly/yearly")
|
||||
status = Column(String(20), default="active", comment="active/inactive")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ExpenseReimbursement(Base):
|
||||
"""费用报销单 — 提交后自动校验规则,超限自动打回"""
|
||||
__tablename__ = "expense_reimbursements"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
reimb_no = Column(String(50), unique=True, nullable=False, comment="报销单号")
|
||||
applicant = Column(String(100), nullable=False, comment="申请人")
|
||||
department = Column(String(100), nullable=True, comment="部门")
|
||||
expense_type = Column(String(30), nullable=False, comment="费用类型: entertainment/travel/office/management")
|
||||
title = Column(String(200), nullable=False, comment="事由/摘要")
|
||||
amount = Column(Float, nullable=False, comment="报销金额")
|
||||
expense_date = Column(DateTime, nullable=True, comment="费用发生日期")
|
||||
attachment = Column(String(500), nullable=True, comment="附件文件名")
|
||||
status = Column(String(20), default="pending", comment="pending待审批/approved已通过/rejected已拒绝/returned已打回")
|
||||
check_result = Column(String(20), default="pass", comment="自动校验结果: pass/fail")
|
||||
check_reason = Column(String(1000), nullable=True, comment="超限原因")
|
||||
check_detail = Column(JSON, nullable=True, comment="校验明细: [{rule_name, limit, actual, passed}]")
|
||||
checked_at = Column(DateTime, nullable=True, comment="自动校验时间")
|
||||
approver = Column(String(100), nullable=True, comment="审批人")
|
||||
approve_comment = Column(String(500), nullable=True, comment="审批意见")
|
||||
approved_at = Column(DateTime, nullable=True, comment="审批时间")
|
||||
created_by = Column(String(100), nullable=True, comment="提交人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)──
|
||||
|
||||
@@ -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'] } },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user