629 lines
23 KiB
Python
629 lines
23 KiB
Python
"""费用审核智能体 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,
|
||
}
|