Files
X-Financial/server/src/app/services/expense_claim_risk_review.py
caoxiaozhu ca691f3ee0 feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
2026-06-02 14:01:51 +08:00

209 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import or_, select
from app.core.logging import get_logger
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_constants import (
AI_REVIEW_LOOKBACK_DAYS,
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
)
from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.risk_observations import RiskObservationService
logger = get_logger("app.services.expense_claim_risk_review")
class ExpenseClaimRiskReviewMixin(
ExpenseClaimPlatformRiskMixin,
ExpenseClaimPolicyReviewMixin,
ExpenseClaimItemSyncMixin,
):
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
base_flags = list(claim.risk_flags_json or [])
attachment_flags = [
flag
for flag in base_flags
if isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
]
preserved_flags = [
flag
for flag in base_flags
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "submission_review"
)
]
review_flags: list[dict[str, Any]] = []
attention_reasons: list[str] = []
high_attachment_flags = [
flag
for flag in attachment_flags
if str(flag.get("severity") or "").strip().lower() == "high"
]
medium_attachment_flags = [
flag
for flag in attachment_flags
if str(flag.get("severity") or "").strip().lower() == "medium"
]
if high_attachment_flags:
attention_reasons.append("存在高风险票据,需审批人重点复核。")
review_flags.append(
{
"source": "submission_review",
"severity": "high",
"label": "自动检测重点复核",
"message": (
f"自动检测发现 {len(high_attachment_flags)} 条高风险附件,"
"已随单流转给审批人重点复核。"
),
}
)
elif medium_attachment_flags:
review_flags.append(
{
"source": "submission_review",
"severity": "medium",
"label": "自动检测提醒",
"message": (
f"自动检测发现 {len(medium_attachment_flags)} 条中风险附件,"
"已随单流转给审批人复核。"
),
}
)
manager_name = self._resolve_claim_manager_name(claim)
if not manager_name:
attention_reasons.append("未识别到该员工的直属领导,需审批环节补充分配。")
review_flags.append(
{
"source": "submission_review",
"severity": "medium",
"label": "审批链待分配",
"message": "自动检测发现直属领导缺失,已提交到审批环节等待分配或复核。",
}
)
historical_risk_count = self._count_recent_risky_claims(claim)
if historical_risk_count >= AI_REVIEW_REPEAT_RISK_BLOCK_COUNT:
review_flags.append(
{
"source": "submission_review",
"severity": "medium",
"label": "历史风险偏高",
"message": (
f"{AI_REVIEW_LOOKBACK_DAYS} 天内该员工已有 "
f"{historical_risk_count} 笔带风险标记的报销,"
"本次已追加到审批链重点关注。"
),
}
)
elif historical_risk_count >= AI_REVIEW_REPEAT_RISK_WARNING_COUNT:
review_flags.append(
{
"source": "submission_review",
"severity": "low",
"label": "历史风险提醒",
"message": (
f"{AI_REVIEW_LOOKBACK_DAYS} 天内该员工已有 "
f"{historical_risk_count} 笔带风险标记的报销,"
"建议直属领导重点复核。"
),
}
)
travel_review = self._run_travel_policy_review(claim)
attention_reasons.extend(travel_review["blocking_reasons"])
review_flags.extend(travel_review["flags"])
scene_policy_review = self._run_scene_policy_review(claim)
attention_reasons.extend(scene_policy_review["blocking_reasons"])
review_flags.extend(scene_policy_review["flags"])
platform_risk_review = self.evaluate_platform_risk_rules(claim)
attention_reasons.extend(platform_risk_review["blocking_reasons"])
platform_risk_flags = list(platform_risk_review["flags"])
review_flags.extend(platform_risk_flags)
if platform_risk_flags:
try:
RiskObservationService(self.db).upsert_platform_risk_flags(
claim,
platform_risk_flags,
)
except Exception:
logger.exception(
"Failed to persist platform risk observations for claim_id=%s",
claim.id,
)
if attention_reasons:
summary_message = "自动检测发现需审批重点关注事项:" + "".join(
dict.fromkeys(attention_reasons)
)
review_flags.insert(
0,
{
"source": "submission_review",
"severity": "medium",
"label": "自动检测重点复核",
"message": summary_message,
},
)
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
return {
"status": "submitted",
"approval_stage": "直属领导审批",
"risk_flags": preserved_flags + review_flags,
"message": (
f"报销单 {claim.claim_no} 已完成自动检测,"
f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
),
"passed": True,
}
@staticmethod
def _resolve_claim_manager_name(claim: ExpenseClaim) -> str:
if claim.employee is not None:
if claim.employee.manager is not None and claim.employee.manager.name:
return str(claim.employee.manager.name).strip()
if (
claim.employee.organization_unit is not None
and claim.employee.organization_unit.manager_name
):
return str(claim.employee.organization_unit.manager_name).strip()
return ""
def _count_recent_risky_claims(self, claim: ExpenseClaim) -> int:
filters = []
if claim.employee_id:
filters.append(ExpenseClaim.employee_id == claim.employee_id)
elif claim.employee_name:
filters.append(ExpenseClaim.employee_name == claim.employee_name)
if not filters:
return 0
since = datetime.now(UTC) - timedelta(days=AI_REVIEW_LOOKBACK_DAYS)
stmt = (
select(ExpenseClaim)
.where(or_(*filters))
.where(ExpenseClaim.id != claim.id)
.where(ExpenseClaim.occurred_at >= since)
)
recent_claims = list(self.db.scalars(stmt).all())
return sum(1 for item in recent_claims if list(item.risk_flags_json or []))