- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
511 lines
21 KiB
Python
511 lines
21 KiB
Python
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import UTC, date, datetime, timedelta
|
||
from decimal import Decimal
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
from sqlalchemy import or_, select
|
||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||
|
||
from app.api.deps import CurrentUserContext
|
||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||
from app.models.agent_asset import AgentAsset
|
||
from app.models.employee import Employee
|
||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||
from app.services.expense_claim_constants import (
|
||
AI_REVIEW_LOOKBACK_DAYS,
|
||
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
|
||
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
|
||
DOCUMENT_FACT_ITEM_TYPES,
|
||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||
SYSTEM_GENERATED_ITEM_TYPES,
|
||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||
)
|
||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||
from app.services.expense_rule_runtime import (
|
||
ExpenseRuleRuntimeService,
|
||
RuntimeTravelPolicy,
|
||
build_default_expense_rule_catalog,
|
||
)
|
||
|
||
|
||
class ExpenseClaimItemSyncMixin:
|
||
def _sync_travel_allowance_item(self, claim: ExpenseClaim) -> None:
|
||
items = list(claim.items or [])
|
||
allowance_items = [
|
||
item for item in items if str(item.item_type or "").strip().lower() == "travel_allowance"
|
||
]
|
||
business_items = [
|
||
item for item in items if str(item.item_type or "").strip().lower() != "travel_allowance"
|
||
]
|
||
business_types = {str(item.item_type or "").strip().lower() for item in business_items}
|
||
is_travel_claim = str(claim.expense_type or "").strip().lower() == "travel"
|
||
has_travel_detail = bool(business_types & TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES)
|
||
if not is_travel_claim and not has_travel_detail:
|
||
for item in allowance_items:
|
||
self._discard_claim_item(claim, item)
|
||
return
|
||
|
||
grade = self._resolve_claim_employee_grade(claim)
|
||
if not grade:
|
||
return
|
||
|
||
allowance_location = self._resolve_travel_allowance_location_from_claim(
|
||
claim=claim,
|
||
business_items=business_items,
|
||
)
|
||
if not allowance_location:
|
||
return
|
||
|
||
existing_allowance = allowance_items[0] if allowance_items else None
|
||
days, start_date, end_date = self._resolve_travel_allowance_days_from_claim(
|
||
claim=claim,
|
||
business_items=business_items,
|
||
existing_allowance=existing_allowance,
|
||
)
|
||
if days < 1:
|
||
return
|
||
|
||
try:
|
||
from app.services.travel_reimbursement_calculator import (
|
||
TravelReimbursementCalculatorService,
|
||
)
|
||
|
||
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||
TravelReimbursementCalculatorRequest(
|
||
days=days,
|
||
location=allowance_location,
|
||
grade=grade,
|
||
),
|
||
CurrentUserContext(
|
||
username=str(claim.employee_id or claim.employee_name or "system"),
|
||
name=str(claim.employee_name or ""),
|
||
role_codes=[],
|
||
is_admin=False,
|
||
),
|
||
)
|
||
except ValueError:
|
||
return
|
||
|
||
allowance_amount = Decimal(result.allowance_amount or Decimal("0.00")).quantize(Decimal("0.01"))
|
||
allowance_rate = Decimal(result.total_allowance_rate or Decimal("0.00")).quantize(Decimal("0.01"))
|
||
if allowance_amount <= Decimal("0.00") or allowance_rate <= Decimal("0.00"):
|
||
return
|
||
|
||
item = existing_allowance
|
||
if item is None:
|
||
item = ExpenseClaimItem(claim_id=claim.id)
|
||
claim.items.append(item)
|
||
self.db.add(item)
|
||
|
||
for duplicate in allowance_items[1:]:
|
||
self._discard_claim_item(claim, duplicate)
|
||
|
||
item.item_date = end_date
|
||
item.item_type = "travel_allowance"
|
||
item.item_reason = (
|
||
f"系统自动计算出差补贴:{result.matched_city},{days}天,"
|
||
f"{allowance_rate:.2f}元/天"
|
||
)
|
||
item.item_location = str(result.allowance_region or allowance_location).strip()
|
||
item.item_amount = allowance_amount
|
||
item.invoice_id = None
|
||
|
||
def _resolve_claim_employee_grade(self, claim: ExpenseClaim) -> str:
|
||
grade = str(claim.employee_grade or "").strip()
|
||
if grade:
|
||
return grade
|
||
employee_id = str(claim.employee_id or "").strip()
|
||
if not employee_id:
|
||
return ""
|
||
employee = self.db.get(Employee, employee_id)
|
||
return str(employee.grade if employee is not None and employee.grade else "").strip()
|
||
|
||
def _discard_claim_item(self, claim: ExpenseClaim, item: ExpenseClaimItem) -> None:
|
||
if item in claim.items:
|
||
claim.items.remove(item)
|
||
state = sqlalchemy_inspect(item)
|
||
if state.persistent:
|
||
self.db.delete(item)
|
||
elif state.pending:
|
||
self.db.expunge(item)
|
||
|
||
def _resolve_travel_allowance_days_from_claim(
|
||
self,
|
||
*,
|
||
claim: ExpenseClaim,
|
||
business_items: list[ExpenseClaimItem],
|
||
existing_allowance: ExpenseClaimItem | None,
|
||
) -> tuple[int, date, date]:
|
||
dated_items = sorted(
|
||
[item.item_date for item in business_items if item.item_date is not None]
|
||
)
|
||
if dated_items:
|
||
start_date = dated_items[0]
|
||
end_date = dated_items[-1]
|
||
elif claim.occurred_at is not None:
|
||
start_date = claim.occurred_at.date()
|
||
end_date = start_date
|
||
else:
|
||
start_date = date.today()
|
||
end_date = start_date
|
||
|
||
days = (end_date - start_date).days + 1
|
||
explicit_days = max(
|
||
(self._extract_travel_day_count(item.item_reason) for item in business_items),
|
||
default=0,
|
||
)
|
||
if explicit_days > 0:
|
||
days = explicit_days
|
||
end_date = start_date + timedelta(days=days - 1)
|
||
return max(1, days), start_date, end_date
|
||
existing_days = self._extract_travel_allowance_days(existing_allowance)
|
||
unique_dates = {value for value in dated_items}
|
||
if existing_days > days and len(unique_dates) <= 1:
|
||
days = existing_days
|
||
end_date = start_date + timedelta(days=days - 1)
|
||
return max(1, days), start_date, end_date
|
||
|
||
@staticmethod
|
||
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
|
||
if item is None:
|
||
return 0
|
||
match = re.search(r"(\d+)\s*天", str(item.item_reason or ""))
|
||
if not match:
|
||
return 0
|
||
try:
|
||
return max(0, int(match.group(1)))
|
||
except ValueError:
|
||
return 0
|
||
|
||
def _resolve_travel_allowance_location_from_claim(
|
||
self,
|
||
*,
|
||
claim: ExpenseClaim,
|
||
business_items: list[ExpenseClaimItem],
|
||
) -> str:
|
||
claim_location = str(claim.location or "").strip()
|
||
if claim_location and claim_location not in {"待补充", "未知", "暂无", "非必填"}:
|
||
return claim_location
|
||
|
||
sorted_items = sorted(
|
||
business_items,
|
||
key=lambda item: (item.item_date or date.max, self._normalize_sort_datetime(item.created_at)),
|
||
)
|
||
for item in sorted_items:
|
||
location = str(item.item_location or "").strip()
|
||
if location and location not in {"待补充", "未知", "暂无", "非必填"}:
|
||
return location
|
||
reason = str(item.item_reason or "").strip()
|
||
for separator in ("-", "至", "到", "→", "->"):
|
||
if separator in reason:
|
||
destination = reason.split(separator)[-1].strip()
|
||
if destination:
|
||
return destination
|
||
return ""
|
||
|
||
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
|
||
self._sync_travel_allowance_item(claim)
|
||
if not claim.items:
|
||
claim.amount = Decimal("0.00")
|
||
claim.invoice_count = 0
|
||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
||
return
|
||
|
||
ordered_items = sorted(
|
||
claim.items,
|
||
key=lambda item: (
|
||
item.item_date or date.max,
|
||
self._normalize_sort_datetime(item.created_at),
|
||
),
|
||
)
|
||
primary_item = ordered_items[0]
|
||
total_amount = sum((item.item_amount for item in ordered_items), Decimal("0.00"))
|
||
|
||
claim.amount = total_amount.quantize(Decimal("0.01"))
|
||
claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())
|
||
claim.occurred_at = datetime(
|
||
primary_item.item_date.year,
|
||
primary_item.item_date.month,
|
||
primary_item.item_date.day,
|
||
tzinfo=UTC,
|
||
)
|
||
claim.expense_type = self._resolve_claim_expense_type_from_items(
|
||
ordered_items,
|
||
fallback=str(primary_item.item_type or claim.expense_type or "other").strip() or "other",
|
||
)
|
||
primary_item_type = str(primary_item.item_type or "").strip()
|
||
if primary_item_type not in DOCUMENT_FACT_ITEM_TYPES:
|
||
claim.reason = (
|
||
self._normalize_optional_text(primary_item.item_reason, fallback=claim.reason or "待补充")
|
||
or "待补充"
|
||
)
|
||
claim.location = (
|
||
self._normalize_optional_text(primary_item.item_location, fallback=claim.location or "待补充")
|
||
or "待补充"
|
||
)
|
||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(
|
||
claim,
|
||
self._build_claim_attachment_risk_flags(ordered_items),
|
||
)
|
||
self._refresh_claim_platform_risk_preview_flags(claim)
|
||
if str(claim.status or "").strip().lower() == "draft":
|
||
claim.approval_stage = "待提交"
|
||
|
||
@staticmethod
|
||
def _resolve_claim_expense_type_from_items(
|
||
items: list[ExpenseClaimItem],
|
||
*,
|
||
fallback: str,
|
||
) -> str:
|
||
fallback_type = str(fallback or "").strip() or "other"
|
||
item_types = {str(item.item_type or "").strip().lower() for item in items}
|
||
if item_types & (TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES | {"travel_allowance"}):
|
||
return "travel"
|
||
return fallback_type
|
||
|
||
def _refresh_item_attachment_analysis(self, item: ExpenseClaimItem) -> None:
|
||
file_path = self._attachment_storage.resolve_path(item.invoice_id)
|
||
if file_path is None or not file_path.exists():
|
||
return
|
||
|
||
metadata = self._attachment_storage.read_meta(file_path)
|
||
media_type = str(metadata.get("media_type") or self._attachment_presentation.resolve_media_type(file_path.name)).strip()
|
||
ocr_status = str(metadata.get("ocr_status") or "").strip().lower()
|
||
|
||
if ocr_status == "failed":
|
||
analysis = self._build_failed_ocr_attachment_analysis(
|
||
media_type=media_type,
|
||
error_message=str(metadata.get("ocr_error") or ""),
|
||
item=item,
|
||
)
|
||
elif ocr_status == "recognized" or any(
|
||
(
|
||
str(metadata.get("ocr_text") or "").strip(),
|
||
str(metadata.get("ocr_summary") or "").strip(),
|
||
int(metadata.get("ocr_line_count") or 0),
|
||
list(metadata.get("ocr_warnings") or []),
|
||
)
|
||
):
|
||
stored_document_info = metadata.get("document_info")
|
||
if not isinstance(stored_document_info, dict):
|
||
stored_document_info = {}
|
||
document = SimpleNamespace(
|
||
filename=str(metadata.get("file_name") or file_path.name),
|
||
text=str(metadata.get("ocr_text") or ""),
|
||
summary=str(metadata.get("ocr_summary") or ""),
|
||
avg_score=float(metadata.get("ocr_avg_score") or 0.0),
|
||
line_count=int(metadata.get("ocr_line_count") or 0),
|
||
document_type=str(stored_document_info.get("document_type") or ""),
|
||
document_type_label=str(stored_document_info.get("document_type_label") or ""),
|
||
scene_code=str(stored_document_info.get("scene_code") or ""),
|
||
scene_label=str(stored_document_info.get("scene_label") or ""),
|
||
document_fields=list(stored_document_info.get("fields") or []),
|
||
warnings=[str(value) for value in list(metadata.get("ocr_warnings") or []) if str(value).strip()],
|
||
)
|
||
document_info = self._build_attachment_document_info(document)
|
||
requirement_check = self._build_attachment_requirement_check(
|
||
item=item,
|
||
document_info=document_info,
|
||
)
|
||
analysis = self._build_attachment_analysis(
|
||
document=document,
|
||
item=item,
|
||
claim=getattr(item, "claim", None),
|
||
document_info=document_info,
|
||
requirement_check=requirement_check,
|
||
)
|
||
metadata["document_info"] = document_info
|
||
metadata["requirement_check"] = requirement_check
|
||
else:
|
||
analysis = self._build_fallback_attachment_analysis(media_type=media_type, item=item)
|
||
|
||
metadata["analysis"] = analysis
|
||
self._attachment_storage.write_meta(file_path, metadata)
|
||
|
||
def _build_claim_attachment_risk_flags(
|
||
self, ordered_items: list[ExpenseClaimItem]
|
||
) -> list[dict[str, Any]]:
|
||
derived_flags: list[dict[str, Any]] = []
|
||
for index, item in enumerate(ordered_items, start=1):
|
||
file_path = self._attachment_storage.resolve_path(item.invoice_id)
|
||
if file_path is None or not file_path.exists():
|
||
continue
|
||
|
||
metadata = self._attachment_storage.read_meta(file_path)
|
||
analysis = metadata.get("analysis")
|
||
if not isinstance(analysis, dict):
|
||
continue
|
||
|
||
severity = str(analysis.get("severity") or "").strip().lower()
|
||
if severity in {"", "pass", "low"}:
|
||
continue
|
||
|
||
summary = (
|
||
str(analysis.get("summary") or analysis.get("headline") or "").strip()
|
||
or "附件存在待核对风险。"
|
||
)
|
||
points = [
|
||
str(point or "").strip()
|
||
for point in list(analysis.get("points") or [])
|
||
if str(point or "").strip()
|
||
]
|
||
message_detail = ";".join(points[:3]) if points else summary
|
||
label = str(
|
||
analysis.get("label") or ("高风险" if severity == "high" else "中风险")
|
||
).strip()
|
||
derived_flags.append(
|
||
with_risk_business_stage(
|
||
{
|
||
"source": "attachment_analysis",
|
||
"item_id": item.id,
|
||
"severity": severity,
|
||
"label": label,
|
||
"message": f"费用明细第 {index} 条:{message_detail}",
|
||
"summary": summary,
|
||
"points": points,
|
||
},
|
||
"reimbursement",
|
||
)
|
||
)
|
||
return derived_flags
|
||
|
||
def _get_expense_rule_catalog(self) -> Any:
|
||
cached = getattr(self, "_expense_rule_catalog", None)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
db = getattr(self, "db", None)
|
||
if db is None:
|
||
catalog = build_default_expense_rule_catalog()
|
||
else:
|
||
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||
setattr(self, "_expense_rule_catalog", catalog)
|
||
return catalog
|
||
|
||
def _get_expense_scene_policy(self, expense_type: str | None) -> Any | None:
|
||
return self._get_expense_rule_catalog().get_scene_policy(expense_type)
|
||
|
||
def _resolve_min_attachment_count(self, expense_type: str | None) -> int:
|
||
policy = self._get_expense_scene_policy(expense_type)
|
||
if policy is None:
|
||
return 1
|
||
return max(0, int(policy.min_attachment_count or 0))
|
||
|
||
def _build_scene_reason_corpus(self, claim: ExpenseClaim) -> str:
|
||
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
|
||
for item in claim.items:
|
||
parts.append(str(item.item_reason or "").strip())
|
||
parts.append(str(item.item_location or "").strip())
|
||
return "\n".join(part for part in parts if part)
|
||
|
||
@staticmethod
|
||
def _merge_claim_attachment_risk_flags(
|
||
claim: ExpenseClaim,
|
||
attachment_risk_flags: list[dict[str, Any]],
|
||
) -> list[Any]:
|
||
preserved_flags = [
|
||
flag
|
||
for flag in list(claim.risk_flags_json or [])
|
||
if not (isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis")
|
||
]
|
||
return preserved_flags + attachment_risk_flags
|
||
|
||
def _refresh_claim_platform_risk_preview_flags(self, claim: ExpenseClaim) -> None:
|
||
if str(claim.expense_type or "").strip().lower().endswith("_application"):
|
||
return
|
||
evaluator = getattr(self, "evaluate_platform_risk_rules", None)
|
||
if not callable(evaluator):
|
||
return
|
||
try:
|
||
review = evaluator(claim, business_stage="reimbursement")
|
||
except Exception:
|
||
return
|
||
platform_flags = list(review.get("flags") or []) if isinstance(review, dict) else []
|
||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(
|
||
claim,
|
||
platform_flags,
|
||
)
|
||
|
||
@staticmethod
|
||
def _merge_claim_platform_risk_preview_flags(
|
||
claim: ExpenseClaim,
|
||
platform_flags: list[dict[str, Any]],
|
||
) -> list[Any]:
|
||
preserved_flags = [
|
||
flag
|
||
for flag in list(claim.risk_flags_json or [])
|
||
if not (
|
||
isinstance(flag, dict)
|
||
and str(flag.get("source") or "").strip() == "submission_review"
|
||
and str(flag.get("hit_source") or "").strip() == "rule_center"
|
||
)
|
||
]
|
||
return preserved_flags + platform_flags
|
||
|
||
@staticmethod
|
||
def _format_submission_blocked_message(issues: list[str]) -> str:
|
||
normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
|
||
if not normalized_issues:
|
||
return "AI预审未通过,但没有返回明确原因,请刷新草稿后重试。"
|
||
|
||
return "AI预审暂未通过,原因如下:\n" + "\n".join(
|
||
f"{index}. {issue}" for index, issue in enumerate(normalized_issues, start=1)
|
||
)
|
||
|
||
def _validate_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
||
issues: list[str] = []
|
||
claim_location_required = self._is_location_required_expense_type(claim.expense_type)
|
||
claim_min_attachment_count = self._resolve_min_attachment_count(claim.expense_type)
|
||
|
||
if self._is_missing_value(claim.employee_name):
|
||
issues.append("申请人未完善")
|
||
if self._is_missing_value(claim.department_name):
|
||
issues.append("所属部门未完善")
|
||
if self._is_missing_value(claim.expense_type):
|
||
issues.append("报销类型未完善")
|
||
if self._is_missing_value(claim.reason):
|
||
issues.append("报销事由未完善")
|
||
if claim_location_required and self._is_missing_value(claim.location):
|
||
issues.append("业务地点未完善")
|
||
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
||
issues.append("报销金额未完善")
|
||
if claim.occurred_at is None:
|
||
issues.append("发生时间未完善")
|
||
if int(claim.invoice_count or 0) < claim_min_attachment_count:
|
||
issues.append("票据附件数量不足")
|
||
if not claim.items:
|
||
issues.append("费用明细不能为空")
|
||
|
||
for index, item in enumerate(claim.items, start=1):
|
||
prefix = f"费用明细第 {index} 条"
|
||
is_system_generated = str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES
|
||
item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type)
|
||
if item.item_date is None:
|
||
issues.append(f"{prefix}缺少日期")
|
||
if self._is_missing_value(item.item_type):
|
||
issues.append(f"{prefix}缺少费用项目")
|
||
if self._is_missing_value(item.item_reason):
|
||
issues.append(f"{prefix}缺少说明")
|
||
if item_location_required and self._is_missing_value(item.item_location):
|
||
issues.append(f"{prefix}缺少地点")
|
||
if item.item_amount is None or item.item_amount <= Decimal("0.00"):
|
||
issues.append(f"{prefix}缺少金额")
|
||
if not is_system_generated and self._is_missing_value(item.invoice_id):
|
||
issues.append(f"{prefix}缺少票据标识")
|
||
|
||
return issues
|
||
|
||
def _is_location_required_expense_type(self, expense_type: str | None) -> bool:
|
||
policy = self._get_expense_scene_policy(expense_type)
|
||
if policy is None:
|
||
return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES
|
||
return bool(policy.location_required)
|