feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -39,6 +39,7 @@ class AgentConversationService:
|
||||
normalized_id = str(conversation_id or "").strip()
|
||||
normalized_user_id = str(user_id or "").strip() or None
|
||||
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense"
|
||||
incoming_draft_claim_id = self._resolve_draft_claim_id(context_json)
|
||||
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
||||
if conversation is not None and conversation.user_id != normalized_user_id:
|
||||
normalized_id = ""
|
||||
@@ -56,6 +57,7 @@ class AgentConversationService:
|
||||
source=source,
|
||||
entry_source=str(context_json.get("entry_source") or "").strip() or None,
|
||||
title=self._resolve_title(context_json),
|
||||
draft_claim_id=incoming_draft_claim_id or None,
|
||||
state_json=self._extract_state_json(context_json),
|
||||
)
|
||||
self.db.add(conversation)
|
||||
@@ -69,6 +71,8 @@ class AgentConversationService:
|
||||
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
|
||||
if not conversation.title:
|
||||
conversation.title = self._resolve_title(context_json)
|
||||
if incoming_draft_claim_id:
|
||||
conversation.draft_claim_id = incoming_draft_claim_id
|
||||
conversation.state_json = self._merge_state_json(
|
||||
conversation.state_json,
|
||||
self._extract_state_json(context_json),
|
||||
@@ -354,6 +358,38 @@ class AgentConversationService:
|
||||
self.db.commit()
|
||||
return len(conversations)
|
||||
|
||||
def delete_conversations_for_draft_claim(
|
||||
self,
|
||||
*,
|
||||
claim_id: str | None,
|
||||
source: str | None = "user_message",
|
||||
session_type: str | None = "expense",
|
||||
) -> int:
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
if not normalized_claim_id:
|
||||
return 0
|
||||
|
||||
stmt = select(AgentConversation).where(AgentConversation.draft_claim_id == normalized_claim_id)
|
||||
if source:
|
||||
stmt = stmt.where(AgentConversation.source == source)
|
||||
conversations = list(self.db.scalars(stmt).all())
|
||||
normalized_session_type = str(session_type or "").strip()
|
||||
if normalized_session_type:
|
||||
conversations = [
|
||||
conversation
|
||||
for conversation in conversations
|
||||
if (str((conversation.state_json or {}).get("session_type") or "").strip() or "expense")
|
||||
== normalized_session_type
|
||||
]
|
||||
if not conversations:
|
||||
return 0
|
||||
|
||||
for conversation in conversations:
|
||||
self.db.delete(conversation)
|
||||
|
||||
self.db.commit()
|
||||
return len(conversations)
|
||||
|
||||
def delete_conversation(
|
||||
self,
|
||||
*,
|
||||
@@ -478,11 +514,28 @@ class AgentConversationService:
|
||||
continue
|
||||
state_json[key] = value
|
||||
|
||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||
draft_claim_id = AgentConversationService._resolve_draft_claim_id(context_json)
|
||||
if draft_claim_id:
|
||||
state_json["draft_claim_id"] = draft_claim_id
|
||||
return state_json
|
||||
|
||||
@staticmethod
|
||||
def _resolve_draft_claim_id(context_json: dict[str, Any]) -> str:
|
||||
draft_claim_id = str((context_json or {}).get("draft_claim_id") or "").strip()
|
||||
if draft_claim_id:
|
||||
return draft_claim_id
|
||||
|
||||
request_context = (context_json or {}).get("request_context")
|
||||
if isinstance(request_context, dict):
|
||||
return str(
|
||||
request_context.get("claim_id")
|
||||
or request_context.get("claimId")
|
||||
or request_context.get("draft_claim_id")
|
||||
or request_context.get("draftClaimId")
|
||||
or ""
|
||||
).strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _merge_state_json(
|
||||
current_state: dict[str, Any] | None,
|
||||
|
||||
@@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
||||
scene_code="travel",
|
||||
scene_label="差旅票据",
|
||||
expense_type="travel",
|
||||
keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"),
|
||||
keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"),
|
||||
score_bias=0.32,
|
||||
),
|
||||
DocumentRule(
|
||||
|
||||
@@ -57,6 +57,7 @@ EXPENSE_TYPE_LABELS = {
|
||||
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
MAX_DRAFT_CLAIMS_PER_USER = 3
|
||||
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES = {
|
||||
@@ -542,14 +543,19 @@ class ExpenseClaimService:
|
||||
[(normalized_name, content, media_type or "application/octet-stream")]
|
||||
)
|
||||
documents = list(ocr_result.documents or [])
|
||||
if documents:
|
||||
ocr_document = documents[0]
|
||||
ocr_status = "recognized"
|
||||
document_info = self._build_attachment_document_info(ocr_document)
|
||||
requirement_check = self._build_attachment_requirement_check(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
if documents:
|
||||
ocr_document = documents[0]
|
||||
ocr_status = "recognized"
|
||||
document_info = self._build_attachment_document_info(ocr_document)
|
||||
self._backfill_item_amount_from_attachment(
|
||||
item=item,
|
||||
document=ocr_document,
|
||||
document_info=document_info,
|
||||
)
|
||||
requirement_check = self._build_attachment_requirement_check(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
attachment_analysis = self._build_attachment_analysis(
|
||||
document=ocr_document,
|
||||
item=item,
|
||||
@@ -615,13 +621,15 @@ class ExpenseClaimService:
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"{normalized_name} 已上传并关联到当前费用明细。",
|
||||
"claim_id": claim.id,
|
||||
"item_id": item.id,
|
||||
"invoice_id": item.invoice_id,
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
}
|
||||
return {
|
||||
"message": f"{normalized_name} 已上传并关联到当前费用明细。",
|
||||
"claim_id": claim.id,
|
||||
"item_id": item.id,
|
||||
"invoice_id": item.invoice_id,
|
||||
"item_amount": item.item_amount,
|
||||
"claim_amount": claim.amount,
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
}
|
||||
|
||||
def get_claim_item_attachment_meta(
|
||||
self,
|
||||
@@ -739,16 +747,18 @@ class ExpenseClaimService:
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.submit",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.submit",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
if str(claim.status or "").strip().lower() == "submitted":
|
||||
self._delete_submitted_claim_assistant_sessions(claim.id)
|
||||
|
||||
return claim
|
||||
|
||||
def save_or_submit_from_ontology(
|
||||
self,
|
||||
@@ -858,8 +868,10 @@ class ExpenseClaimService:
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._has_privileged_claim_access(current_user):
|
||||
if not self._has_claim_delete_access(current_user):
|
||||
self._ensure_draft_claim(claim)
|
||||
if not self._is_claim_owned_by_current_user(claim, current_user):
|
||||
raise ValueError("只有高级管理人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
resource_id = claim.id
|
||||
@@ -903,7 +915,7 @@ class ExpenseClaimService:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = current_user.name or current_user.username
|
||||
operator = self._resolve_current_user_display_name(current_user)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
@@ -987,29 +999,43 @@ class ExpenseClaimService:
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
raise ValueError("只有审批中的报销单可以审批通过。")
|
||||
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
if previous_stage != "直属领导审批":
|
||||
raise ValueError("当前节点不是直属领导审批,不能执行领导审批通过。")
|
||||
if previous_stage == "直属领导审批":
|
||||
if not self._can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
|
||||
approval_source = "manual_approval"
|
||||
event_type = "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "submitted"
|
||||
next_stage = "财务审批"
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
elif previous_stage == "财务审批":
|
||||
if not self._can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员可以完成财务终审。")
|
||||
approval_source = "finance_approval"
|
||||
event_type = "expense_claim_finance_approval"
|
||||
label = "财务审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = "归档入账"
|
||||
default_message = "{operator} 已完成财务审核,进入归档入账。"
|
||||
else:
|
||||
raise ValueError("当前节点不支持审批通过。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = current_user.name or current_user.username
|
||||
leader_opinion = str(opinion or "").strip()
|
||||
next_stage = "财务审批"
|
||||
operator = self._resolve_current_user_display_name(current_user)
|
||||
approval_opinion = str(opinion or "").strip()
|
||||
approval_flag = {
|
||||
"source": "manual_approval",
|
||||
"event_type": "expense_claim_approval",
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "领导审批通过",
|
||||
"message": leader_opinion or f"{operator} 已审批通过,流转至{next_stage}。",
|
||||
"opinion": leader_opinion,
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
@@ -1024,7 +1050,7 @@ class ExpenseClaimService:
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
claim.status = "submitted"
|
||||
claim.status = next_status
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
@@ -2205,16 +2231,89 @@ class ExpenseClaimService:
|
||||
meta_path = self._attachment_meta_path(file_path)
|
||||
meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def _read_attachment_meta(self, file_path: Path) -> dict[str, Any]:
|
||||
meta_path = self._attachment_meta_path(file_path)
|
||||
if not meta_path.exists():
|
||||
return {}
|
||||
def _read_attachment_meta(self, file_path: Path) -> dict[str, Any]:
|
||||
meta_path = self._attachment_meta_path(file_path)
|
||||
if not meta_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
payload = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
def _repair_pdf_text_layer_metadata_if_needed(
|
||||
self,
|
||||
*,
|
||||
file_path: Path,
|
||||
metadata: dict[str, Any],
|
||||
item: ExpenseClaimItem | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not metadata:
|
||||
return metadata
|
||||
|
||||
media_type = str(metadata.get("media_type") or self._resolve_attachment_media_type(file_path.name)).strip()
|
||||
if media_type != "application/pdf":
|
||||
return metadata
|
||||
|
||||
ocr_text = str(metadata.get("ocr_text") or "")
|
||||
ocr_summary = str(metadata.get("ocr_summary") or "")
|
||||
if OcrService._placeholder_ratio(f"{ocr_summary}\n{ocr_text}") < 0.12:
|
||||
return metadata
|
||||
|
||||
text_layer = OcrService(self.db)._extract_pdf_text_layer(file_path)
|
||||
repaired_text, used_text_layer = OcrService._choose_document_text(
|
||||
ocr_text=ocr_text,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
if not used_text_layer or not repaired_text:
|
||||
return metadata
|
||||
|
||||
repaired_summary = OcrService._summarize_text(repaired_text)
|
||||
document = SimpleNamespace(
|
||||
filename=str(metadata.get("file_name") or file_path.name),
|
||||
text=repaired_text,
|
||||
summary=repaired_summary,
|
||||
avg_score=float(metadata.get("ocr_avg_score") or 0.0),
|
||||
line_count=int(metadata.get("ocr_line_count") or 0),
|
||||
document_type="",
|
||||
document_type_label="",
|
||||
scene_code="",
|
||||
scene_label="",
|
||||
document_fields=[],
|
||||
warnings=[str(value) for value in list(metadata.get("ocr_warnings") or []) if str(value).strip()],
|
||||
)
|
||||
document_info = self._build_attachment_document_info(document)
|
||||
document.document_type = document_info.get("document_type", "")
|
||||
document.document_type_label = document_info.get("document_type_label", "")
|
||||
document.scene_code = document_info.get("scene_code", "")
|
||||
document.scene_label = document_info.get("scene_label", "")
|
||||
document.document_fields = list(document_info.get("fields") or [])
|
||||
|
||||
metadata["ocr_text"] = repaired_text
|
||||
metadata["ocr_summary"] = repaired_summary
|
||||
metadata["document_info"] = document_info
|
||||
metadata["previewable"] = True
|
||||
metadata["preview_kind"] = "pdf"
|
||||
metadata["preview_storage_key"] = str(metadata.get("storage_key") or self._to_attachment_storage_key(file_path))
|
||||
metadata["preview_media_type"] = "application/pdf"
|
||||
metadata["preview_file_name"] = str(metadata.get("file_name") or file_path.name)
|
||||
|
||||
if item is not None:
|
||||
requirement_check = self._build_attachment_requirement_check(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
metadata["requirement_check"] = requirement_check
|
||||
metadata["analysis"] = self._build_attachment_analysis(
|
||||
document=document,
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
requirement_check=requirement_check,
|
||||
)
|
||||
|
||||
self._write_attachment_meta(file_path, metadata)
|
||||
return metadata
|
||||
|
||||
def _build_attachment_preview_meta(
|
||||
self,
|
||||
@@ -2262,12 +2361,17 @@ class ExpenseClaimService:
|
||||
"preview_file_name": "",
|
||||
}
|
||||
|
||||
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
|
||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||
metadata = self._read_attachment_meta(file_path)
|
||||
preview_storage_key = str(metadata.get("preview_storage_key") or "").strip()
|
||||
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
|
||||
preview_media_type = str(metadata.get("preview_media_type") or "").strip()
|
||||
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
|
||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||
metadata = self._read_attachment_meta(file_path)
|
||||
metadata = self._repair_pdf_text_layer_metadata_if_needed(
|
||||
file_path=file_path,
|
||||
metadata=metadata,
|
||||
item=item,
|
||||
)
|
||||
preview_storage_key = str(metadata.get("preview_storage_key") or "").strip()
|
||||
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
|
||||
preview_media_type = str(metadata.get("preview_media_type") or "").strip()
|
||||
|
||||
if preview_storage_key:
|
||||
preview_path = self._resolve_attachment_path(preview_storage_key)
|
||||
@@ -2284,10 +2388,15 @@ class ExpenseClaimService:
|
||||
|
||||
raise FileNotFoundError("Attachment preview not found")
|
||||
|
||||
def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]:
|
||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||
metadata = self._read_attachment_meta(file_path)
|
||||
uploaded_at_value = metadata.get("uploaded_at")
|
||||
def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]:
|
||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||
metadata = self._read_attachment_meta(file_path)
|
||||
metadata = self._repair_pdf_text_layer_metadata_if_needed(
|
||||
file_path=file_path,
|
||||
metadata=metadata,
|
||||
item=item,
|
||||
)
|
||||
uploaded_at_value = metadata.get("uploaded_at")
|
||||
uploaded_at = None
|
||||
if isinstance(uploaded_at_value, str) and uploaded_at_value.strip():
|
||||
try:
|
||||
@@ -2402,11 +2511,11 @@ class ExpenseClaimService:
|
||||
|
||||
return normalized_next
|
||||
|
||||
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
|
||||
insight = build_document_insight(
|
||||
filename=str(getattr(document, "filename", "") or ""),
|
||||
summary=str(getattr(document, "summary", "") or ""),
|
||||
text=str(getattr(document, "text", "") or ""),
|
||||
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
|
||||
insight = build_document_insight(
|
||||
filename=str(getattr(document, "filename", "") or ""),
|
||||
summary=str(getattr(document, "summary", "") or ""),
|
||||
text=str(getattr(document, "text", "") or ""),
|
||||
)
|
||||
raw_fields = list(getattr(document, "document_fields", []) or [])
|
||||
normalized_fields: list[dict[str, str]] = []
|
||||
@@ -2463,14 +2572,35 @@ class ExpenseClaimService:
|
||||
"document_type_label": document_type_label,
|
||||
"scene_code": scene_code,
|
||||
"scene_label": scene_label,
|
||||
"fields": normalized_fields,
|
||||
}
|
||||
|
||||
def _build_attachment_requirement_check(
|
||||
self,
|
||||
*,
|
||||
item: ExpenseClaimItem,
|
||||
document_info: dict[str, Any],
|
||||
"fields": normalized_fields,
|
||||
}
|
||||
|
||||
def _backfill_item_amount_from_attachment(
|
||||
self,
|
||||
*,
|
||||
item: ExpenseClaimItem,
|
||||
document: Any,
|
||||
document_info: dict[str, Any],
|
||||
) -> None:
|
||||
current_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
|
||||
if current_amount > Decimal("0.00"):
|
||||
return
|
||||
|
||||
amount = self._resolve_document_item_amount(
|
||||
{
|
||||
"document_fields": document_info.get("fields") or [],
|
||||
"summary": str(getattr(document, "summary", "") or ""),
|
||||
"text": str(getattr(document, "text", "") or ""),
|
||||
}
|
||||
)
|
||||
if amount is not None and amount > Decimal("0.00"):
|
||||
item.item_amount = amount
|
||||
|
||||
def _build_attachment_requirement_check(
|
||||
self,
|
||||
*,
|
||||
item: ExpenseClaimItem,
|
||||
document_info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
expense_type = str(item.item_type or "").strip().lower() or "other"
|
||||
policy = self._get_expense_scene_policy(expense_type)
|
||||
@@ -2932,8 +3062,17 @@ class ExpenseClaimService:
|
||||
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
|
||||
if not self._is_editable_claim_status(claim.status):
|
||||
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
|
||||
|
||||
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
|
||||
def _delete_submitted_claim_assistant_sessions(self, claim_id: str | None) -> None:
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
|
||||
AgentConversationService(self.db).delete_conversations_for_draft_claim(
|
||||
claim_id=claim_id,
|
||||
source="user_message",
|
||||
session_type="expense",
|
||||
)
|
||||
|
||||
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
base_flags = list(claim.risk_flags_json or [])
|
||||
attachment_flags = [
|
||||
flag
|
||||
@@ -4593,7 +4732,7 @@ class ExpenseClaimService:
|
||||
return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES
|
||||
return bool(policy.location_required)
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def _has_privileged_claim_access(current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
@@ -4604,6 +4743,17 @@ class ExpenseClaimService:
|
||||
}
|
||||
return bool(role_codes & PRIVILEGED_CLAIM_ROLE_CODES)
|
||||
|
||||
@staticmethod
|
||||
def _has_claim_delete_access(current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = {
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
}
|
||||
return bool(role_codes & CLAIM_DELETE_ROLE_CODES)
|
||||
|
||||
def _can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
if self._has_privileged_claim_access(current_user):
|
||||
return True
|
||||
@@ -4636,7 +4786,41 @@ class ExpenseClaimService:
|
||||
return self._resolve_claim_manager_name(claim) == approver_name
|
||||
|
||||
def _can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
return self._can_return_claim(current_user, claim)
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage == "直属领导审批":
|
||||
return self._is_current_direct_manager_approver(current_user, claim)
|
||||
if stage == "财务审批":
|
||||
role_codes = self._normalize_role_codes(current_user)
|
||||
return current_user.is_admin or "finance" in role_codes
|
||||
return False
|
||||
|
||||
def _is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
role_codes = self._normalize_role_codes(current_user)
|
||||
if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES):
|
||||
return False
|
||||
if str(claim.status or "").strip().lower() != "submitted":
|
||||
return False
|
||||
if str(claim.approval_stage or "").strip() != "直属领导审批":
|
||||
return False
|
||||
|
||||
current_employee = self._resolve_current_employee(current_user)
|
||||
if current_employee is not None and str(claim.employee_id or "").strip() == current_employee.id:
|
||||
return False
|
||||
|
||||
claim_employee = claim.employee
|
||||
if current_employee is not None and claim_employee is not None:
|
||||
if claim_employee.manager_id == current_employee.id:
|
||||
return True
|
||||
if claim_employee.manager is not None and claim_employee.manager.id == current_employee.id:
|
||||
return True
|
||||
|
||||
approver_name = str(
|
||||
current_employee.name if current_employee is not None and current_employee.name else current_user.name or ""
|
||||
).strip()
|
||||
if not approver_name:
|
||||
return False
|
||||
|
||||
return self._resolve_claim_manager_name(claim) == approver_name
|
||||
|
||||
@staticmethod
|
||||
def _normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
|
||||
@@ -4654,6 +4838,44 @@ class ExpenseClaimService:
|
||||
]
|
||||
)
|
||||
|
||||
def _resolve_current_user_display_name(self, current_user: CurrentUserContext) -> str:
|
||||
current_employee = self._resolve_current_employee(current_user)
|
||||
if current_employee is not None and str(current_employee.name or "").strip():
|
||||
return str(current_employee.name).strip()
|
||||
|
||||
for candidate in (current_user.name, current_user.username):
|
||||
normalized = str(candidate or "").strip()
|
||||
if normalized and not self._is_email_like(normalized):
|
||||
return normalized
|
||||
|
||||
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
|
||||
|
||||
def _is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool:
|
||||
current_employee = self._resolve_current_employee(current_user)
|
||||
if current_employee is not None:
|
||||
if str(claim.employee_id or "").strip() == current_employee.id:
|
||||
return True
|
||||
identity_values = {
|
||||
str(current_employee.name or "").strip(),
|
||||
str(current_employee.email or "").strip(),
|
||||
str(current_employee.employee_no or "").strip(),
|
||||
}
|
||||
else:
|
||||
identity_values = set()
|
||||
|
||||
identity_values.update(
|
||||
{
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
}
|
||||
)
|
||||
identity_values.discard("")
|
||||
return str(claim.employee_name or "").strip() in identity_values
|
||||
|
||||
@staticmethod
|
||||
def _is_email_like(value: str) -> bool:
|
||||
return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", str(value or "").strip()))
|
||||
|
||||
def _resolve_claim_employee_for_backfill(self, claim: ExpenseClaim) -> Employee | None:
|
||||
if claim.employee is not None:
|
||||
employee = self.db.scalar(
|
||||
@@ -4850,8 +5072,14 @@ class ExpenseClaimService:
|
||||
return conditions
|
||||
|
||||
def _apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||
if self._has_privileged_claim_access(current_user):
|
||||
role_codes = self._normalize_role_codes(current_user)
|
||||
if current_user.is_admin or "executive" in role_codes:
|
||||
return stmt.where(ExpenseClaim.status == "submitted")
|
||||
if "finance" in role_codes:
|
||||
return stmt.where(
|
||||
ExpenseClaim.status == "submitted",
|
||||
ExpenseClaim.approval_stage == "财务审批",
|
||||
)
|
||||
|
||||
conditions = self._build_approval_claim_conditions(current_user)
|
||||
if not conditions:
|
||||
|
||||
@@ -6,12 +6,17 @@ from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
AgentAssetSpreadsheetManager,
|
||||
)
|
||||
|
||||
EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL)
|
||||
|
||||
@@ -351,6 +356,11 @@ class TravelPolicyConfig(BaseModel):
|
||||
band_labels: dict[str, str] = Field(default_factory=dict)
|
||||
city_tiers: dict[str, str] = Field(default_factory=dict)
|
||||
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
standard_rule_code: str = ""
|
||||
standard_rule_name: str = ""
|
||||
standard_rule_version: str = ""
|
||||
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||
train_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||
@@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService:
|
||||
).all()
|
||||
)
|
||||
if not assets:
|
||||
return catalog
|
||||
assets = []
|
||||
|
||||
asset_ids = {asset.id for asset in assets}
|
||||
travel_spreadsheet_asset = self.db.scalar(
|
||||
select(AgentAsset)
|
||||
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
|
||||
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
|
||||
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids:
|
||||
assets.append(travel_spreadsheet_asset)
|
||||
|
||||
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
|
||||
for asset in assets:
|
||||
version = self._get_current_version(asset)
|
||||
if version is None:
|
||||
continue
|
||||
is_travel_spreadsheet_asset = (
|
||||
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
|
||||
)
|
||||
runtime_payload = self._extract_runtime_payload(
|
||||
markdown_content=str(version.content or ""),
|
||||
config_json=asset.config_json,
|
||||
)
|
||||
if not isinstance(runtime_payload, dict):
|
||||
spreadsheet_assets.append((asset, version))
|
||||
continue
|
||||
self._apply_runtime_payload(
|
||||
catalog,
|
||||
@@ -594,6 +622,15 @@ class ExpenseRuleRuntimeService:
|
||||
asset=asset,
|
||||
version=version,
|
||||
)
|
||||
if is_travel_spreadsheet_asset:
|
||||
spreadsheet_assets.append((asset, version))
|
||||
|
||||
for asset, version in spreadsheet_assets:
|
||||
self._apply_spreadsheet_runtime_payload(
|
||||
catalog,
|
||||
asset=asset,
|
||||
version=version,
|
||||
)
|
||||
|
||||
return catalog
|
||||
|
||||
@@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService:
|
||||
)
|
||||
except ValidationError:
|
||||
return
|
||||
|
||||
def _apply_spreadsheet_runtime_payload(
|
||||
self,
|
||||
catalog: ExpenseRuleCatalog,
|
||||
*,
|
||||
asset: AgentAsset,
|
||||
version: AgentAssetVersion,
|
||||
) -> None:
|
||||
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||
return
|
||||
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
|
||||
return
|
||||
|
||||
manager = AgentAssetSpreadsheetManager()
|
||||
metadata = manager.parse_version_markdown(str(version.content or ""))
|
||||
rule_document = (asset.config_json or {}).get("rule_document")
|
||||
if not isinstance(rule_document, dict):
|
||||
rule_document = {}
|
||||
storage_key = str(metadata.storage_key if metadata is not None else "").strip()
|
||||
if storage_key:
|
||||
try:
|
||||
workbook_path = manager.resolve_storage_path(storage_key)
|
||||
except FileNotFoundError:
|
||||
workbook_path = None
|
||||
if workbook_path is not None and not workbook_path.exists():
|
||||
workbook_path = None
|
||||
else:
|
||||
workbook_path = None
|
||||
|
||||
if workbook_path is None:
|
||||
fallback_storage_key = str(rule_document.get("storage_key") or "").strip()
|
||||
if not fallback_storage_key:
|
||||
return
|
||||
try:
|
||||
workbook_path = manager.resolve_storage_path(fallback_storage_key)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if not workbook_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
workbook = load_workbook(
|
||||
workbook_path,
|
||||
read_only=True,
|
||||
data_only=True,
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
return
|
||||
|
||||
try:
|
||||
standards = self._extract_travel_amount_standards_from_workbook(workbook)
|
||||
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
|
||||
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
|
||||
transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
standard_rule_version = str(
|
||||
rule_document.get("asset_version") or asset.current_version or version.version
|
||||
).strip()
|
||||
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None:
|
||||
payload = catalog.travel_policy.model_dump()
|
||||
payload["standard_rule_code"] = asset.code
|
||||
payload["standard_rule_name"] = asset.name
|
||||
payload["standard_rule_version"] = standard_rule_version
|
||||
if hotel_city_limits:
|
||||
payload["hotel_city_limits"] = {
|
||||
**payload.get("hotel_city_limits", {}),
|
||||
**hotel_city_limits,
|
||||
}
|
||||
if allowance_limits:
|
||||
payload["allowance_limits"] = {
|
||||
**payload.get("allowance_limits", {}),
|
||||
**allowance_limits,
|
||||
}
|
||||
if transport_limits:
|
||||
payload["transport_limits"] = {
|
||||
**payload.get("transport_limits", {}),
|
||||
**transport_limits,
|
||||
}
|
||||
catalog.travel_policy = RuntimeTravelPolicy(**payload)
|
||||
|
||||
for expense_type, amount in standards.items():
|
||||
current = catalog.scene_policies.get(expense_type)
|
||||
if current is None:
|
||||
continue
|
||||
limit_attr = "item_amount_limit" if expense_type == "transport" else "claim_amount_limit"
|
||||
base_limit = getattr(current, limit_attr, None)
|
||||
next_limit = self._replace_amount_limit_warn_amount(
|
||||
base_limit,
|
||||
amount=amount,
|
||||
metric_label=self._spreadsheet_metric_label(expense_type),
|
||||
)
|
||||
payload = current.model_dump()
|
||||
payload["rule_code"] = asset.code
|
||||
payload["rule_name"] = asset.name
|
||||
payload["rule_version"] = standard_rule_version
|
||||
payload[limit_attr] = next_limit.model_dump()
|
||||
catalog.scene_policies[expense_type] = ExpenseScenePolicy(**payload)
|
||||
|
||||
@staticmethod
|
||||
def _extract_travel_amount_standards_from_workbook(workbook: Any) -> dict[str, Decimal]:
|
||||
standards: dict[str, Decimal] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
header_index = -1
|
||||
category_index = -1
|
||||
standard_index = -1
|
||||
for index, row in enumerate(rows[:8]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "费用分类" in values and "报销标准" in values:
|
||||
header_index = index
|
||||
category_index = values.index("费用分类")
|
||||
standard_index = values.index("报销标准")
|
||||
break
|
||||
if header_index < 0:
|
||||
continue
|
||||
|
||||
for row in rows[header_index + 1 :]:
|
||||
category = str(row[category_index] or "").strip() if len(row) > category_index else ""
|
||||
standard_text = str(row[standard_index] or "").strip() if len(row) > standard_index else ""
|
||||
amount = ExpenseRuleRuntimeService._extract_first_standard_amount(standard_text)
|
||||
if not category or amount is None:
|
||||
continue
|
||||
normalized_type = ExpenseRuleRuntimeService._map_spreadsheet_category_to_expense_type(category)
|
||||
if normalized_type:
|
||||
standards[normalized_type] = amount
|
||||
return standards
|
||||
|
||||
@staticmethod
|
||||
def _extract_hotel_city_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||
city_limits: dict[str, dict[str, Decimal]] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
header_index = -1
|
||||
city_index = -1
|
||||
band_indexes: dict[str, int] = {}
|
||||
for index, row in enumerate(rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
for candidate in ("地区(城市)", "城市", "地区"):
|
||||
if candidate in values:
|
||||
city_index = values.index(candidate)
|
||||
break
|
||||
if city_index < 0:
|
||||
continue
|
||||
for column_index, header in enumerate(values):
|
||||
compact = re.sub(r"\s+", "", header)
|
||||
if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
|
||||
band_indexes["junior"] = column_index
|
||||
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
|
||||
band_indexes["mid"] = column_index
|
||||
band_indexes["senior"] = column_index
|
||||
if any(keyword in compact for keyword in ("P7", "高层经理", "公司级管理")):
|
||||
band_indexes["manager"] = column_index
|
||||
band_indexes["executive"] = column_index
|
||||
if band_indexes:
|
||||
header_index = index
|
||||
break
|
||||
|
||||
if header_index < 0:
|
||||
continue
|
||||
|
||||
for row in rows[header_index + 1 :]:
|
||||
raw_city = str(row[city_index] or "").strip() if len(row) > city_index else ""
|
||||
cities = ExpenseRuleRuntimeService._extract_city_names_from_cell(raw_city)
|
||||
if not cities:
|
||||
continue
|
||||
for city in cities:
|
||||
city_entry = city_limits.setdefault(city, {})
|
||||
for band, column_index in band_indexes.items():
|
||||
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||
row[column_index] if len(row) > column_index else None
|
||||
)
|
||||
if amount is not None:
|
||||
city_entry[band] = amount
|
||||
return city_limits
|
||||
|
||||
@staticmethod
|
||||
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||
allowance_limits: dict[str, dict[str, Decimal]] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
header_index = -1
|
||||
type_index = -1
|
||||
region_indexes: dict[str, int] = {}
|
||||
for index, row in enumerate(rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "补助类型" not in values:
|
||||
continue
|
||||
header_index = index
|
||||
type_index = values.index("补助类型")
|
||||
for column_index, header in enumerate(values):
|
||||
if column_index <= type_index:
|
||||
continue
|
||||
normalized = str(header or "").strip()
|
||||
if not normalized or normalized == "项目":
|
||||
continue
|
||||
region_indexes[normalized] = column_index
|
||||
break
|
||||
|
||||
if header_index < 0 or type_index < 0 or not region_indexes:
|
||||
continue
|
||||
|
||||
for row in rows[header_index + 1 :]:
|
||||
raw_type = str(row[type_index] or "").strip() if len(row) > type_index else ""
|
||||
allowance_key = ExpenseRuleRuntimeService._map_allowance_type_to_key(raw_type)
|
||||
if not allowance_key:
|
||||
continue
|
||||
|
||||
entry: dict[str, Decimal] = {}
|
||||
for region_label, column_index in region_indexes.items():
|
||||
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||
row[column_index] if len(row) > column_index else None
|
||||
)
|
||||
if amount is not None:
|
||||
entry[region_label] = amount
|
||||
if entry:
|
||||
allowance_limits[allowance_key] = entry
|
||||
return allowance_limits
|
||||
|
||||
@staticmethod
|
||||
def _map_allowance_type_to_key(value: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
if "伙食" in normalized or "餐" in normalized:
|
||||
return "meal"
|
||||
if "基本" in normalized:
|
||||
return "basic"
|
||||
if "合计" in normalized or "总计" in normalized:
|
||||
return "total"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_transport_class_limits_from_workbook(workbook: Any) -> dict[str, dict[str, int]]:
|
||||
limits: dict[str, dict[str, int]] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
employee_index = -1
|
||||
flight_index = -1
|
||||
train_index = -1
|
||||
for row_index, row in enumerate(rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "员工职级" in values:
|
||||
employee_index = values.index("员工职级")
|
||||
for next_row in rows[row_index + 1 : row_index + 4]:
|
||||
next_values = [str(value or "").strip() for value in next_row]
|
||||
if "飞机" in next_values:
|
||||
flight_index = next_values.index("飞机")
|
||||
if "火车" in next_values:
|
||||
train_index = next_values.index("火车")
|
||||
if flight_index >= 0 and train_index >= 0:
|
||||
break
|
||||
break
|
||||
|
||||
if employee_index < 0 or (flight_index < 0 and train_index < 0):
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
|
||||
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text)
|
||||
if not bands:
|
||||
continue
|
||||
flight_level = (
|
||||
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||
row[flight_index] if len(row) > flight_index else None,
|
||||
kind="flight",
|
||||
)
|
||||
if flight_index >= 0
|
||||
else None
|
||||
)
|
||||
train_level = (
|
||||
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||
row[train_index] if len(row) > train_index else None,
|
||||
kind="train",
|
||||
)
|
||||
if train_index >= 0
|
||||
else None
|
||||
)
|
||||
for band in bands:
|
||||
entry = limits.setdefault(band, {})
|
||||
if flight_level is not None:
|
||||
entry["flight"] = flight_level
|
||||
if train_level is not None:
|
||||
entry["train"] = train_level
|
||||
return limits
|
||||
|
||||
@staticmethod
|
||||
def _map_transport_grade_row_to_bands(value: str) -> list[str]:
|
||||
normalized = re.sub(r"\s+", "", str(value or "").upper())
|
||||
if not normalized or normalized.startswith("注"):
|
||||
return []
|
||||
bands: list[str] = []
|
||||
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")):
|
||||
bands.extend(["junior", "mid"])
|
||||
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")):
|
||||
bands.extend(["mid", "senior", "manager", "executive"])
|
||||
return list(dict.fromkeys(bands))
|
||||
|
||||
@staticmethod
|
||||
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
if not normalized:
|
||||
return None
|
||||
if kind == "flight":
|
||||
if any(keyword in normalized for keyword in ("头等舱",)):
|
||||
return 4
|
||||
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
|
||||
return 3
|
||||
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
|
||||
return 2
|
||||
if "经济舱" in normalized:
|
||||
return 1
|
||||
if kind == "train":
|
||||
if "商务座" in normalized:
|
||||
return 3
|
||||
if any(keyword in normalized for keyword in ("一等座", "软卧")):
|
||||
return 2
|
||||
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
|
||||
return 1
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_city_names_from_cell(value: str) -> list[str]:
|
||||
normalized = re.sub(r"[;;,,、/]+", "、", str(value or "").strip())
|
||||
if not normalized:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for part in normalized.split("、"):
|
||||
cleaned = re.sub(r"\s+", "", part)
|
||||
cleaned = re.sub(r"[((].*?[))]", "", cleaned)
|
||||
if not cleaned or any(keyword in cleaned for keyword in ("不含", "中心城区", "新区")):
|
||||
continue
|
||||
if len(cleaned) <= 12:
|
||||
names.append(cleaned)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
@staticmethod
|
||||
def _coerce_decimal_cell(value: Any) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
|
||||
except (ArithmeticError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_first_standard_amount(text: str) -> Decimal | None:
|
||||
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)\s*/\s*(?:天|人|晚|次|笔)", str(text or ""))
|
||||
if match is None:
|
||||
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", str(text or ""))
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(match.group(1)).quantize(Decimal("0.01"))
|
||||
except (ArithmeticError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _map_spreadsheet_category_to_expense_type(category: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(category or ""))
|
||||
if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")):
|
||||
return "transport"
|
||||
if "招待" in normalized and "餐" in normalized:
|
||||
return "entertainment"
|
||||
if "餐补" in normalized or normalized == "餐费":
|
||||
return "meal"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _spreadsheet_metric_label(expense_type: str) -> str:
|
||||
return {
|
||||
"transport": "单笔交通金额",
|
||||
"meal": "差旅餐补金额",
|
||||
"entertainment": "人均招待餐费",
|
||||
}.get(expense_type, "金额")
|
||||
|
||||
@staticmethod
|
||||
def _replace_amount_limit_warn_amount(
|
||||
base_limit: AmountLimitConfig | None,
|
||||
*,
|
||||
amount: Decimal,
|
||||
metric_label: str,
|
||||
) -> AmountLimitConfig:
|
||||
if base_limit is None:
|
||||
return AmountLimitConfig(
|
||||
warn_amount=amount,
|
||||
block_amount=None,
|
||||
metric_label=metric_label,
|
||||
)
|
||||
payload = base_limit.model_dump()
|
||||
payload["warn_amount"] = amount
|
||||
payload["metric_label"] = metric_label
|
||||
return AmountLimitConfig(**payload)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
@@ -27,6 +28,7 @@ class PreparedOcrInput:
|
||||
page_index: int | None = None
|
||||
preview_kind: str = ""
|
||||
preview_data_url: str = ""
|
||||
text_layer: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -38,6 +40,7 @@ class AggregatedOcrDocument:
|
||||
model: str = "PP-OCRv5_mobile"
|
||||
summary_fragments: list[str] = field(default_factory=list)
|
||||
text_fragments: list[str] = field(default_factory=list)
|
||||
text_layer_fragments: list[str] = field(default_factory=list)
|
||||
score_values: list[float] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
lines: list[OcrRecognizeLineRead] = field(default_factory=list)
|
||||
@@ -112,12 +115,14 @@ class OcrService:
|
||||
|
||||
if suffix == ".pdf":
|
||||
try:
|
||||
text_layer = self._extract_pdf_text_layer(temp_path)
|
||||
prepared_inputs.extend(
|
||||
self._prepare_pdf_inputs(
|
||||
pdf_path=temp_path,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
@@ -261,6 +266,7 @@ class OcrService:
|
||||
filename: str,
|
||||
media_type: str,
|
||||
cleanup_paths: list[Path],
|
||||
text_layer: str = "",
|
||||
) -> list[PreparedOcrInput]:
|
||||
output_dir = pdf_path.with_suffix("")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -283,10 +289,33 @@ class OcrService:
|
||||
page_index=page_index,
|
||||
preview_kind="image" if page_index == 0 else "",
|
||||
preview_data_url=preview_data_url if page_index == 0 else "",
|
||||
text_layer=text_layer if page_index == 0 else "",
|
||||
)
|
||||
)
|
||||
return descriptors
|
||||
|
||||
def _extract_pdf_text_layer(self, pdf_path: Path) -> str:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"pdftotext",
|
||||
"-layout",
|
||||
str(pdf_path),
|
||||
"-",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.settings.ocr_timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, UnicodeError):
|
||||
return ""
|
||||
|
||||
if completed.returncode != 0:
|
||||
return ""
|
||||
|
||||
return self._normalize_extracted_text(completed.stdout)
|
||||
|
||||
def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
prefix = output_dir / "page"
|
||||
completed = subprocess.run(
|
||||
@@ -367,6 +396,8 @@ class OcrService:
|
||||
aggregated.preview_kind = descriptor.preview_kind
|
||||
if descriptor.preview_data_url and not aggregated.preview_data_url:
|
||||
aggregated.preview_data_url = descriptor.preview_data_url
|
||||
if descriptor.text_layer and descriptor.text_layer not in aggregated.text_layer_fragments:
|
||||
aggregated.text_layer_fragments.append(descriptor.text_layer)
|
||||
|
||||
page_summary = str(payload.get("summary", "") or "").strip()
|
||||
if page_summary:
|
||||
@@ -401,6 +432,20 @@ class OcrService:
|
||||
aggregated = aggregated_by_source.get(source_key)
|
||||
if aggregated is None:
|
||||
first_descriptor = descriptors[0]
|
||||
text_layer = self._collect_descriptor_text_layer(descriptors)
|
||||
if text_layer:
|
||||
fallback = AggregatedOcrDocument(
|
||||
filename=first_descriptor.filename,
|
||||
media_type=first_descriptor.media_type,
|
||||
source_key=first_descriptor.source_key,
|
||||
page_count=max(1, len(descriptors)),
|
||||
preview_kind=first_descriptor.preview_kind,
|
||||
preview_data_url=first_descriptor.preview_data_url,
|
||||
warnings=["OCR worker 未返回该文件的识别结果,已使用 PDF 文本层。"],
|
||||
)
|
||||
fallback.text_layer_fragments.append(text_layer)
|
||||
documents.append(self._finalize_document(fallback))
|
||||
continue
|
||||
documents.append(
|
||||
OcrRecognizeDocumentRead(
|
||||
filename=first_descriptor.filename,
|
||||
@@ -416,6 +461,13 @@ class OcrService:
|
||||
|
||||
return documents
|
||||
|
||||
@staticmethod
|
||||
def _collect_descriptor_text_layer(descriptors: list[PreparedOcrInput]) -> str:
|
||||
for descriptor in descriptors:
|
||||
if descriptor.text_layer:
|
||||
return descriptor.text_layer
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_lines(
|
||||
items: list[dict],
|
||||
@@ -451,13 +503,26 @@ class OcrService:
|
||||
return summary
|
||||
|
||||
def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead:
|
||||
full_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
|
||||
ocr_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
|
||||
text_layer = "\n".join(fragment for fragment in aggregated.text_layer_fragments if fragment).strip()
|
||||
full_text, used_text_layer = self._choose_document_text(ocr_text=ocr_text, text_layer=text_layer)
|
||||
summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments)
|
||||
if used_text_layer or self._placeholder_ratio(summary) >= 0.12:
|
||||
summary = self._summarize_text(full_text)
|
||||
preview_kind = aggregated.preview_kind
|
||||
preview_data_url = aggregated.preview_data_url
|
||||
if (
|
||||
used_text_layer
|
||||
and aggregated.media_type == "application/pdf"
|
||||
and self._placeholder_ratio(ocr_text) >= 0.12
|
||||
):
|
||||
preview_kind = ""
|
||||
preview_data_url = ""
|
||||
insight = self.document_intelligence_service.build_document_insight(
|
||||
filename=aggregated.filename,
|
||||
summary=summary,
|
||||
text=full_text,
|
||||
preview_data_url=aggregated.preview_data_url,
|
||||
preview_data_url=preview_data_url,
|
||||
)
|
||||
warnings = list(aggregated.warnings)
|
||||
for warning in insight.warnings:
|
||||
@@ -493,8 +558,8 @@ class OcrService:
|
||||
)
|
||||
for field in insight.fields
|
||||
],
|
||||
preview_kind=aggregated.preview_kind,
|
||||
preview_data_url=aggregated.preview_data_url,
|
||||
preview_kind=preview_kind,
|
||||
preview_data_url=preview_data_url,
|
||||
warnings=warnings,
|
||||
lines=sorted(
|
||||
aggregated.lines,
|
||||
@@ -502,6 +567,45 @@ class OcrService:
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _choose_document_text(cls, *, ocr_text: str, text_layer: str) -> tuple[str, bool]:
|
||||
normalized_ocr_text = cls._normalize_extracted_text(ocr_text)
|
||||
normalized_text_layer = cls._normalize_extracted_text(text_layer)
|
||||
if not normalized_text_layer:
|
||||
return normalized_ocr_text, False
|
||||
if not normalized_ocr_text:
|
||||
return normalized_text_layer, True
|
||||
if cls._placeholder_ratio(normalized_ocr_text) >= 0.12 and cls._meaningful_char_count(normalized_text_layer) >= 8:
|
||||
return normalized_text_layer, True
|
||||
if cls._meaningful_char_count(normalized_text_layer) > cls._meaningful_char_count(normalized_ocr_text) * 1.3:
|
||||
return normalized_text_layer, True
|
||||
return normalized_ocr_text, False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_extracted_text(value: str) -> str:
|
||||
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in str(value or "").replace("\r", "\n").split("\n")]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
@staticmethod
|
||||
def _summarize_text(value: str) -> str:
|
||||
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
|
||||
summary = ";".join(lines[:3])
|
||||
if len(summary) > 180:
|
||||
return f"{summary[:177]}..."
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def _meaningful_char_count(value: str) -> int:
|
||||
return len(re.findall(r"[0-9A-Za-z\u4e00-\u9fff]", str(value or "")))
|
||||
|
||||
@staticmethod
|
||||
def _placeholder_ratio(value: str) -> float:
|
||||
chars = [char for char in str(value or "") if not char.isspace()]
|
||||
if not chars:
|
||||
return 0.0
|
||||
placeholder_count = sum(1 for char in chars if char in {"□", "<EFBFBD>"})
|
||||
return placeholder_count / len(chars)
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_temp_paths(paths: list[Path]) -> None:
|
||||
for path in reversed(paths):
|
||||
|
||||
593
server/src/app/services/travel_reimbursement_calculator.py
Normal file
593
server/src/app/services/travel_reimbursement_calculator.py
Normal file
@@ -0,0 +1,593 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.models.employee import Employee
|
||||
from app.schemas.reimbursement import (
|
||||
TravelReimbursementCalculatorRequest,
|
||||
TravelReimbursementCalculatorResponse,
|
||||
)
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService
|
||||
|
||||
OTHER_REGION_LOCATION_KEYWORDS = {
|
||||
"河北",
|
||||
"石家庄",
|
||||
"唐山",
|
||||
"秦皇岛",
|
||||
"邯郸",
|
||||
"邢台",
|
||||
"保定",
|
||||
"张家口",
|
||||
"承德",
|
||||
"沧州",
|
||||
"廊坊",
|
||||
"衡水",
|
||||
"山西",
|
||||
"太原",
|
||||
"大同",
|
||||
"长治",
|
||||
"晋城",
|
||||
"晋中",
|
||||
"运城",
|
||||
"临汾",
|
||||
"吕梁",
|
||||
"内蒙古",
|
||||
"呼和浩特",
|
||||
"包头",
|
||||
"赤峰",
|
||||
"通辽",
|
||||
"鄂尔多斯",
|
||||
"辽宁",
|
||||
"鞍山",
|
||||
"抚顺",
|
||||
"本溪",
|
||||
"丹东",
|
||||
"锦州",
|
||||
"营口",
|
||||
"盘锦",
|
||||
"吉林",
|
||||
"长春",
|
||||
"吉林市",
|
||||
"四平",
|
||||
"通化",
|
||||
"白山",
|
||||
"松原",
|
||||
"延边",
|
||||
"黑龙江",
|
||||
"哈尔滨",
|
||||
"齐齐哈尔",
|
||||
"牡丹江",
|
||||
"佳木斯",
|
||||
"大庆",
|
||||
"江苏",
|
||||
"常州",
|
||||
"南通",
|
||||
"连云港",
|
||||
"淮安",
|
||||
"盐城",
|
||||
"扬州",
|
||||
"镇江",
|
||||
"泰州",
|
||||
"宿迁",
|
||||
"浙江",
|
||||
"温州",
|
||||
"嘉兴",
|
||||
"湖州",
|
||||
"绍兴",
|
||||
"金华",
|
||||
"衢州",
|
||||
"舟山",
|
||||
"台州",
|
||||
"丽水",
|
||||
"安徽",
|
||||
"芜湖",
|
||||
"蚌埠",
|
||||
"淮南",
|
||||
"马鞍山",
|
||||
"淮北",
|
||||
"铜陵",
|
||||
"安庆",
|
||||
"黄山",
|
||||
"滁州",
|
||||
"阜阳",
|
||||
"宿州",
|
||||
"六安",
|
||||
"亳州",
|
||||
"池州",
|
||||
"宣城",
|
||||
"福建",
|
||||
"泉州",
|
||||
"漳州",
|
||||
"莆田",
|
||||
"三明",
|
||||
"南平",
|
||||
"龙岩",
|
||||
"宁德",
|
||||
"江西",
|
||||
"南昌",
|
||||
"景德镇",
|
||||
"萍乡",
|
||||
"九江",
|
||||
"新余",
|
||||
"鹰潭",
|
||||
"赣州",
|
||||
"吉安",
|
||||
"宜春",
|
||||
"抚州",
|
||||
"上饶",
|
||||
"山东",
|
||||
"淄博",
|
||||
"枣庄",
|
||||
"东营",
|
||||
"烟台",
|
||||
"潍坊",
|
||||
"济宁",
|
||||
"泰安",
|
||||
"威海",
|
||||
"日照",
|
||||
"临沂",
|
||||
"德州",
|
||||
"聊城",
|
||||
"滨州",
|
||||
"菏泽",
|
||||
"河南",
|
||||
"洛阳",
|
||||
"开封",
|
||||
"平顶山",
|
||||
"安阳",
|
||||
"鹤壁",
|
||||
"新乡",
|
||||
"焦作",
|
||||
"濮阳",
|
||||
"许昌",
|
||||
"漯河",
|
||||
"三门峡",
|
||||
"南阳",
|
||||
"商丘",
|
||||
"信阳",
|
||||
"周口",
|
||||
"驻马店",
|
||||
"湖北",
|
||||
"黄石",
|
||||
"十堰",
|
||||
"宜昌",
|
||||
"襄阳",
|
||||
"鄂州",
|
||||
"荆门",
|
||||
"孝感",
|
||||
"荆州",
|
||||
"黄冈",
|
||||
"咸宁",
|
||||
"随州",
|
||||
"恩施",
|
||||
"湖南",
|
||||
"株洲",
|
||||
"湘潭",
|
||||
"衡阳",
|
||||
"邵阳",
|
||||
"岳阳",
|
||||
"常德",
|
||||
"张家界",
|
||||
"益阳",
|
||||
"郴州",
|
||||
"永州",
|
||||
"怀化",
|
||||
"娄底",
|
||||
"湘西",
|
||||
"广东",
|
||||
"惠州",
|
||||
"江门",
|
||||
"湛江",
|
||||
"茂名",
|
||||
"肇庆",
|
||||
"梅州",
|
||||
"汕尾",
|
||||
"河源",
|
||||
"阳江",
|
||||
"清远",
|
||||
"潮州",
|
||||
"揭阳",
|
||||
"云浮",
|
||||
"广西",
|
||||
"南宁",
|
||||
"柳州",
|
||||
"桂林",
|
||||
"梧州",
|
||||
"北海",
|
||||
"防城港",
|
||||
"钦州",
|
||||
"贵港",
|
||||
"玉林",
|
||||
"百色",
|
||||
"贺州",
|
||||
"河池",
|
||||
"来宾",
|
||||
"崇左",
|
||||
"海南",
|
||||
"儋州",
|
||||
"四川",
|
||||
"自贡",
|
||||
"攀枝花",
|
||||
"泸州",
|
||||
"德阳",
|
||||
"绵阳",
|
||||
"广元",
|
||||
"遂宁",
|
||||
"内江",
|
||||
"乐山",
|
||||
"南充",
|
||||
"眉山",
|
||||
"宜宾",
|
||||
"广安",
|
||||
"达州",
|
||||
"雅安",
|
||||
"巴中",
|
||||
"资阳",
|
||||
"阿坝",
|
||||
"甘孜",
|
||||
"凉山",
|
||||
"贵州",
|
||||
"贵阳",
|
||||
"遵义",
|
||||
"六盘水",
|
||||
"安顺",
|
||||
"毕节",
|
||||
"铜仁",
|
||||
"黔东南",
|
||||
"黔南",
|
||||
"黔西南",
|
||||
"云南",
|
||||
"曲靖",
|
||||
"玉溪",
|
||||
"保山",
|
||||
"昭通",
|
||||
"丽江",
|
||||
"普洱",
|
||||
"临沧",
|
||||
"楚雄",
|
||||
"红河",
|
||||
"文山",
|
||||
"西双版纳",
|
||||
"大理",
|
||||
"德宏",
|
||||
"怒江",
|
||||
"迪庆",
|
||||
"陕西",
|
||||
"宝鸡",
|
||||
"咸阳",
|
||||
"铜川",
|
||||
"渭南",
|
||||
"延安",
|
||||
"汉中",
|
||||
"榆林",
|
||||
"安康",
|
||||
"商洛",
|
||||
"甘肃",
|
||||
"兰州",
|
||||
"嘉峪关",
|
||||
"金昌",
|
||||
"白银",
|
||||
"天水",
|
||||
"武威",
|
||||
"张掖",
|
||||
"平凉",
|
||||
"酒泉",
|
||||
"庆阳",
|
||||
"定西",
|
||||
"陇南",
|
||||
"临夏",
|
||||
"甘南",
|
||||
"青海",
|
||||
"西宁",
|
||||
"海东",
|
||||
"海北",
|
||||
"黄南",
|
||||
"海南州",
|
||||
"果洛",
|
||||
"玉树",
|
||||
"海西",
|
||||
"宁夏",
|
||||
"银川",
|
||||
"石嘴山",
|
||||
"吴忠",
|
||||
"固原",
|
||||
"中卫",
|
||||
}
|
||||
|
||||
OTHER_REGION_PROVINCE_KEYWORDS = {
|
||||
"河北",
|
||||
"山西",
|
||||
"内蒙古",
|
||||
"辽宁",
|
||||
"吉林",
|
||||
"黑龙江",
|
||||
"江苏",
|
||||
"浙江",
|
||||
"安徽",
|
||||
"福建",
|
||||
"江西",
|
||||
"山东",
|
||||
"河南",
|
||||
"湖北",
|
||||
"湖南",
|
||||
"广东",
|
||||
"广西",
|
||||
"海南",
|
||||
"四川",
|
||||
"贵州",
|
||||
"云南",
|
||||
"陕西",
|
||||
"甘肃",
|
||||
"青海",
|
||||
"宁夏",
|
||||
"新疆",
|
||||
"西藏",
|
||||
"台湾",
|
||||
"香港",
|
||||
"澳门",
|
||||
}
|
||||
|
||||
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def calculate(
|
||||
self,
|
||||
payload: TravelReimbursementCalculatorRequest,
|
||||
current_user: CurrentUserContext,
|
||||
) -> TravelReimbursementCalculatorResponse:
|
||||
days = max(1, int(payload.days))
|
||||
location = str(payload.location or "").strip()
|
||||
if not location:
|
||||
raise ValueError("请先填写出差地点。")
|
||||
|
||||
policy = self._load_travel_policy()
|
||||
grade = self._resolve_grade(payload.grade, current_user)
|
||||
if not grade:
|
||||
raise ValueError("未识别到当前员工职级,请在个人信息中维护职级后再计算。")
|
||||
|
||||
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
|
||||
if not grade_band:
|
||||
raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位。")
|
||||
|
||||
matched_city = self._resolve_city(location, policy)
|
||||
matched_other_region = "" if matched_city else self._resolve_other_region(location)
|
||||
if not matched_city and not matched_other_region:
|
||||
raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。")
|
||||
city_tier = policy.city_tiers.get(matched_city, "tier_3") if matched_city else "tier_3"
|
||||
hotel_rate = self._resolve_hotel_rate(policy, grade_band, matched_city, city_tier)
|
||||
allowance_region = self._resolve_allowance_region(location, matched_city or matched_other_region)
|
||||
meal_rate = self._resolve_allowance_rate(policy, "meal", allowance_region)
|
||||
basic_rate = self._resolve_allowance_rate(policy, "basic", allowance_region)
|
||||
total_allowance_rate = self._resolve_total_allowance_rate(policy, allowance_region, meal_rate, basic_rate)
|
||||
|
||||
hotel_amount = hotel_rate * Decimal(days)
|
||||
allowance_amount = total_allowance_rate * Decimal(days)
|
||||
total_amount = hotel_amount + allowance_amount
|
||||
band_label = policy.band_labels.get(grade_band, grade_band)
|
||||
rule_name = policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则"
|
||||
rule_version = policy.standard_rule_version or policy.rule_version or ""
|
||||
display_city = matched_city or self._format_other_region_display(matched_other_region)
|
||||
formula_text = (
|
||||
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
|
||||
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
|
||||
f"{self._format_money(total_amount)}"
|
||||
)
|
||||
summary_text = (
|
||||
f"按《{rule_name}》{f'({rule_version})' if rule_version else ''}测算:"
|
||||
f"当前职级 {grade} 对应 {band_label} 档,出差地点“{location}”匹配为“{display_city}”,"
|
||||
f"住宿标准 {self._format_money(hotel_rate)} 元/天,补贴区域为“{allowance_region}”,"
|
||||
f"补贴标准 {self._format_money(total_allowance_rate)} 元/天"
|
||||
f"(伙食 {self._format_money(meal_rate)} + 基本 {self._format_money(basic_rate)})。"
|
||||
f"按 {days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
|
||||
f"补贴合计 {self._format_money(allowance_amount)} 元,"
|
||||
f"参考可报销总金额为 {self._format_money(total_amount)} 元。"
|
||||
)
|
||||
|
||||
return TravelReimbursementCalculatorResponse(
|
||||
days=days,
|
||||
location=location,
|
||||
matched_city=display_city,
|
||||
city_tier=city_tier,
|
||||
grade=grade,
|
||||
grade_band=grade_band,
|
||||
grade_band_label=band_label,
|
||||
hotel_rate=hotel_rate,
|
||||
hotel_amount=hotel_amount,
|
||||
allowance_region=allowance_region,
|
||||
meal_allowance_rate=meal_rate,
|
||||
basic_allowance_rate=basic_rate,
|
||||
total_allowance_rate=total_allowance_rate,
|
||||
allowance_amount=allowance_amount,
|
||||
total_amount=total_amount,
|
||||
rule_name=rule_name,
|
||||
rule_version=rule_version,
|
||||
formula_text=formula_text,
|
||||
summary_text=summary_text,
|
||||
)
|
||||
|
||||
def _load_travel_policy(self) -> RuntimeTravelPolicy:
|
||||
AgentAssetService(self.db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
policy = ExpenseRuleRuntimeService(self.db).load_catalog().travel_policy
|
||||
if policy is None:
|
||||
raise ValueError("规则中心暂未配置差旅报销规则。")
|
||||
return policy
|
||||
|
||||
def _resolve_grade(
|
||||
self,
|
||||
grade: str | None,
|
||||
current_user: CurrentUserContext,
|
||||
) -> str:
|
||||
normalized_grade = str(grade or "").strip()
|
||||
if normalized_grade:
|
||||
return normalized_grade
|
||||
|
||||
employee = self._resolve_current_employee(current_user)
|
||||
if employee is not None and str(employee.grade or "").strip():
|
||||
return str(employee.grade).strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_other_region(location: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(location or "").strip())
|
||||
if not normalized:
|
||||
return ""
|
||||
if any(keyword in normalized for keyword in ("国外", "境外", "海外")):
|
||||
return "国外"
|
||||
for keyword in ("香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"):
|
||||
if keyword in normalized:
|
||||
return keyword
|
||||
city_matches = []
|
||||
province_matches = []
|
||||
for keyword in OTHER_REGION_LOCATION_KEYWORDS:
|
||||
if not keyword or keyword not in normalized:
|
||||
continue
|
||||
if keyword in OTHER_REGION_PROVINCE_KEYWORDS:
|
||||
province_matches.append(keyword)
|
||||
else:
|
||||
city_matches.append(keyword)
|
||||
candidates = city_matches or province_matches
|
||||
if candidates:
|
||||
return sorted(candidates, key=len, reverse=True)[0]
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_other_region_display(region: str) -> str:
|
||||
normalized = str(region or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if normalized in {"国外", "香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"}:
|
||||
return normalized
|
||||
return f"{normalized}(其他地区)"
|
||||
|
||||
def _resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None:
|
||||
candidates = [
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
]
|
||||
normalized_candidates = [
|
||||
item
|
||||
for item in dict.fromkeys(candidate for candidate in candidates if candidate)
|
||||
if item
|
||||
]
|
||||
if not normalized_candidates:
|
||||
return None
|
||||
|
||||
for candidate in normalized_candidates:
|
||||
employee = self.db.scalar(
|
||||
select(Employee)
|
||||
.where(
|
||||
or_(
|
||||
func.lower(Employee.email) == candidate.lower(),
|
||||
func.lower(Employee.employee_no) == candidate.lower(),
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if employee is not None:
|
||||
return employee
|
||||
|
||||
for candidate in normalized_candidates:
|
||||
matches = list(
|
||||
self.db.scalars(
|
||||
select(Employee)
|
||||
.where(Employee.name == candidate)
|
||||
.limit(2)
|
||||
).all()
|
||||
)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str:
|
||||
normalized = str(location or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
city_names = set(policy.city_tiers.keys())
|
||||
city_names.update(policy.hotel_city_limits.keys())
|
||||
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
|
||||
if city in AMBIGUOUS_PROVINCE_CITY_NAMES and normalized != city and f"{city}市" not in normalized:
|
||||
continue
|
||||
if city and city in normalized:
|
||||
return city
|
||||
compact = re.sub(r"(省|市|区|县|自治州|特别行政区)$", "", normalized)
|
||||
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
|
||||
if city in AMBIGUOUS_PROVINCE_CITY_NAMES and compact != city and f"{city}市" not in normalized:
|
||||
continue
|
||||
if city and city in compact:
|
||||
return city
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_hotel_rate(
|
||||
policy: RuntimeTravelPolicy,
|
||||
grade_band: str,
|
||||
matched_city: str,
|
||||
city_tier: str,
|
||||
) -> Decimal:
|
||||
city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {}
|
||||
if city_limits.get(grade_band) is not None:
|
||||
return Decimal(city_limits[grade_band])
|
||||
|
||||
band_limits = policy.hotel_limits.get(grade_band, {})
|
||||
if band_limits.get(city_tier) is not None:
|
||||
return Decimal(band_limits[city_tier])
|
||||
if band_limits.get("tier_3") is not None:
|
||||
return Decimal(band_limits["tier_3"])
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_allowance_region(location: str, matched_city: str) -> str:
|
||||
text = f"{location} {matched_city}".strip()
|
||||
if any(keyword in text for keyword in ("国外", "境外", "海外")):
|
||||
return "国外"
|
||||
if any(keyword in text for keyword in ("香港", "澳门", "台湾", "港澳台")):
|
||||
return "港澳台"
|
||||
if "乌鲁木齐" in text:
|
||||
return "新疆-乌鲁木齐"
|
||||
if "新疆" in text:
|
||||
return "新疆-其他"
|
||||
if "西藏" in text or "拉萨" in text:
|
||||
return "西藏"
|
||||
if any(keyword in text for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")):
|
||||
return "直辖市/特区"
|
||||
return "其他地区"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_allowance_rate(policy: RuntimeTravelPolicy, allowance_key: str, region: str) -> Decimal:
|
||||
limits = policy.allowance_limits.get(allowance_key, {})
|
||||
if limits.get(region) is not None:
|
||||
return Decimal(limits[region])
|
||||
if limits.get("其他地区") is not None:
|
||||
return Decimal(limits["其他地区"])
|
||||
return Decimal("0")
|
||||
|
||||
def _resolve_total_allowance_rate(
|
||||
self,
|
||||
policy: RuntimeTravelPolicy,
|
||||
region: str,
|
||||
meal_rate: Decimal,
|
||||
basic_rate: Decimal,
|
||||
) -> Decimal:
|
||||
total_limits = policy.allowance_limits.get("total", {})
|
||||
if total_limits.get(region) is not None:
|
||||
return Decimal(total_limits[region])
|
||||
if total_limits.get("其他地区") is not None:
|
||||
return Decimal(total_limits["其他地区"])
|
||||
return meal_rate + basic_rate
|
||||
|
||||
@staticmethod
|
||||
def _format_money(value: Decimal | int | float | str) -> str:
|
||||
return f"{Decimal(str(value)).quantize(Decimal('0.01'))}"
|
||||
@@ -34,6 +34,7 @@ from app.schemas.user_agent import (
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
|
||||
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
@@ -185,6 +186,7 @@ DOCUMENT_AMOUNT_PATTERN = re.compile(
|
||||
r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
||||
)
|
||||
DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)")
|
||||
TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)")
|
||||
|
||||
SOURCE_LABELS = {
|
||||
"user_text": "用户描述",
|
||||
@@ -197,6 +199,8 @@ SOURCE_LABELS = {
|
||||
"system": "系统判断",
|
||||
}
|
||||
|
||||
DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ("历史报销画像", "用户画像", "制度注意事项", "制度注意")
|
||||
|
||||
SCENE_REQUIRED_SLOT_KEYS = {
|
||||
"hotel": {"merchant_name"},
|
||||
"meeting": {"location"},
|
||||
@@ -2193,8 +2197,8 @@ class UserAgentService:
|
||||
for reason in self._resolve_submission_blocked_reasons(payload):
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="AI预审未通过",
|
||||
level="high",
|
||||
title="提交风险提示",
|
||||
level=self._resolve_submission_blocked_risk_level(reason),
|
||||
content=reason,
|
||||
detail=(
|
||||
"该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明,"
|
||||
@@ -2204,6 +2208,14 @@ class UserAgentService:
|
||||
)
|
||||
)
|
||||
|
||||
briefs.extend(
|
||||
self._build_travel_policy_precheck_briefs(
|
||||
payload,
|
||||
document_cards=document_cards,
|
||||
claim_groups=claim_groups,
|
||||
)
|
||||
)
|
||||
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
employee_name = (
|
||||
str(employee.name).strip()
|
||||
@@ -2211,7 +2223,10 @@ class UserAgentService:
|
||||
else self._collect_entity_values(payload).get("employee_name")
|
||||
or str(payload.context_json.get("name") or "").strip()
|
||||
)
|
||||
if employee_name:
|
||||
current_amount = self._resolve_amount_value(payload) or sum(
|
||||
self._extract_amount_from_card(card) for card in document_cards
|
||||
)
|
||||
if employee_name and current_amount > 0:
|
||||
since = datetime.now(UTC) - timedelta(days=90)
|
||||
claim_identity_conditions = [ExpenseClaim.employee_name == employee_name]
|
||||
if employee is not None:
|
||||
@@ -2228,57 +2243,27 @@ class UserAgentService:
|
||||
stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since)
|
||||
recent_claims = list(self.db.scalars(stmt).all())
|
||||
if recent_claims:
|
||||
risky_count = sum(1 for item in recent_claims if item.risk_flags_json)
|
||||
draft_count = sum(1 for item in recent_claims if item.status == "draft")
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="历史报销画像",
|
||||
level="info",
|
||||
content=(
|
||||
f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销,"
|
||||
f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。"
|
||||
),
|
||||
detail=(
|
||||
"该画像来自员工近 90 天报销记录,用于辅助判断是否存在频繁草稿、"
|
||||
"历史风险或异常重复报销倾向,不会单独阻断审批。"
|
||||
),
|
||||
suggestion="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。",
|
||||
)
|
||||
duplicate_count = sum(
|
||||
1
|
||||
for item in recent_claims
|
||||
if abs(float(item.amount) - current_amount) < 0.01
|
||||
)
|
||||
current_amount = self._resolve_amount_value(payload)
|
||||
if current_amount > 0:
|
||||
duplicate_count = sum(
|
||||
1
|
||||
for item in recent_claims
|
||||
if abs(float(item.amount) - current_amount) < 0.01
|
||||
)
|
||||
if duplicate_count:
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="金额重复预警",
|
||||
level="warning",
|
||||
content=(
|
||||
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
|
||||
"提交前建议核对是否为重复报销或拆分不当。"
|
||||
),
|
||||
detail=(
|
||||
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
|
||||
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
|
||||
),
|
||||
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
|
||||
)
|
||||
if duplicate_count:
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="金额重复预警",
|
||||
level="warning",
|
||||
content=(
|
||||
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
|
||||
"提交前建议核对是否为重复报销或拆分不当。"
|
||||
),
|
||||
detail=(
|
||||
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
|
||||
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
|
||||
),
|
||||
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
|
||||
)
|
||||
|
||||
if citations:
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="制度注意事项",
|
||||
level="info",
|
||||
content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。",
|
||||
detail=f"本条来自规则或知识库引用:{citations[0].title}。提交前应确认当前单据符合该条口径。",
|
||||
suggestion="如当前场景与制度口径存在差异,请补充审批说明或选择更准确的报销分类。",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
warning_count = sum(len(item.warnings) for item in document_cards)
|
||||
if warning_count:
|
||||
@@ -2296,14 +2281,635 @@ class UserAgentService:
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="建议拆单",
|
||||
level="high",
|
||||
level="warning",
|
||||
content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。",
|
||||
detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。",
|
||||
suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。",
|
||||
)
|
||||
)
|
||||
|
||||
return briefs[:4]
|
||||
return self._filter_deprecated_review_risk_briefs(briefs)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_submission_blocked_risk_level(reason: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(reason or ""))
|
||||
amount_keywords = ("金额", "超标", "费用", "价款", "票面金额", "单价", "合计")
|
||||
return "high" if any(keyword in normalized for keyword in amount_keywords) else "warning"
|
||||
|
||||
@staticmethod
|
||||
def _filter_deprecated_review_risk_briefs(
|
||||
briefs: list[UserAgentReviewRiskBrief],
|
||||
) -> list[UserAgentReviewRiskBrief]:
|
||||
filtered: list[UserAgentReviewRiskBrief] = []
|
||||
for brief in briefs:
|
||||
title = str(brief.title or "").strip()
|
||||
if any(keyword in title for keyword in DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS):
|
||||
continue
|
||||
filtered.append(brief)
|
||||
return filtered
|
||||
|
||||
def _build_travel_policy_precheck_briefs(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
document_cards: list[UserAgentReviewDocumentCard],
|
||||
claim_groups: list[UserAgentReviewClaimGroup],
|
||||
) -> list[UserAgentReviewRiskBrief]:
|
||||
if not document_cards or not self._is_travel_review_context(payload, document_cards, claim_groups):
|
||||
return []
|
||||
|
||||
rule_catalog = ExpenseRuleRuntimeService(self.db).load_catalog()
|
||||
policy = rule_catalog.travel_policy
|
||||
if policy is None:
|
||||
return []
|
||||
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
grade = self._resolve_review_employee_grade(payload, employee=employee)
|
||||
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
|
||||
band_label = policy.band_labels.get(grade_band or "", grade or "当前职级")
|
||||
declared_city = self._resolve_declared_travel_city(payload, policy)
|
||||
reason_corpus = self._build_review_reason_corpus(payload)
|
||||
has_exception_note = self._text_contains_any(reason_corpus, policy.standard_exception_keywords)
|
||||
standard_rule_name = str(getattr(policy, "standard_rule_name", "") or policy.rule_name)
|
||||
standard_rule_version = str(getattr(policy, "standard_rule_version", "") or policy.rule_version)
|
||||
|
||||
briefs: list[UserAgentReviewRiskBrief] = []
|
||||
amount_measurement_lines: list[str] = []
|
||||
seen_keys: set[str] = set()
|
||||
|
||||
def append_once(key: str, brief: UserAgentReviewRiskBrief) -> None:
|
||||
if key in seen_keys:
|
||||
return
|
||||
seen_keys.add(key)
|
||||
briefs.append(brief)
|
||||
|
||||
for card in document_cards:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
suggested_type = str(card.suggested_expense_type or "").strip().lower()
|
||||
card_text = self._build_review_document_card_text(card)
|
||||
document_type_label = resolve_document_type_label(document_type)
|
||||
amount = self._extract_amount_decimal_from_card(card)
|
||||
|
||||
if self._is_review_hotel_card(card):
|
||||
hotel_city = self._extract_policy_city_from_text(card_text, policy) or declared_city
|
||||
city_tier = policy.city_tiers.get(hotel_city, "tier_3")
|
||||
city_tier_label = self._format_travel_city_tier(city_tier)
|
||||
|
||||
if amount is None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法完成住宿差标测算。"
|
||||
)
|
||||
append_once(
|
||||
f"hotel-amount-missing-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="住宿金额待补充",
|
||||
level="warning",
|
||||
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算的住宿金额。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),住宿票据需要按员工职级、城市级别和每晚金额进行差标核算。"
|
||||
"当前票据缺少金额,系统无法判断是否超出差旅标准。"
|
||||
),
|
||||
suggestion="请在票据识别结果中补充或更正住宿金额,再继续核对报销单。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if grade_band is None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别住宿金额 {amount:.2f} 元,但缺少员工职级,无法匹配住宿标准。"
|
||||
)
|
||||
append_once(
|
||||
f"hotel-grade-missing-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="职级信息待确认",
|
||||
level="warning",
|
||||
content=f"{card.filename} 已识别住宿金额 {amount:.2f} 元,但当前员工职级缺失,无法匹配住宿标准。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),住宿标准按职级档位和城市级别配置。"
|
||||
"当前未能识别员工职级,因此无法完成创建前差标核算。"
|
||||
),
|
||||
suggestion="请确认员工档案或页面上下文中的职级信息,再重新进行差旅规则预检。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
cap = self._resolve_review_hotel_cap(
|
||||
policy,
|
||||
grade_band=grade_band,
|
||||
city=hotel_city,
|
||||
city_tier=city_tier,
|
||||
)
|
||||
if cap <= Decimal("0.00"):
|
||||
continue
|
||||
night_count = self._extract_review_hotel_night_count(card)
|
||||
nightly_amount = (amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01"))
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元,"
|
||||
f"按 {night_count} 晚折算 {nightly_amount:.2f} 元/晚;"
|
||||
f"适用标准为 {band_label}{city_tier_label} {cap:.2f} 元/晚,"
|
||||
f"{'超出标准' if nightly_amount > cap else '测算通过'}。"
|
||||
)
|
||||
if nightly_amount <= cap:
|
||||
continue
|
||||
|
||||
basis = (
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),{band_label} 在{city_tier_label}"
|
||||
f"住宿标准为 {cap:.2f} 元/晚;{card.filename} 识别为{document_type_label},"
|
||||
f"金额 {amount:.2f} 元,按 {night_count} 晚折算约 {nightly_amount:.2f} 元/晚。"
|
||||
)
|
||||
append_once(
|
||||
f"hotel-over-limit-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="住宿超标待说明" if not has_exception_note else "住宿超标提醒",
|
||||
level="high",
|
||||
content=(
|
||||
f"{card.filename} 住宿金额约 {nightly_amount:.2f} 元/晚,"
|
||||
f"超过 {band_label} {city_tier_label}标准 {cap:.2f} 元/晚。"
|
||||
),
|
||||
detail=(
|
||||
basis
|
||||
+ (
|
||||
"当前未识别到超标说明,创建单据前需要先补充原因。"
|
||||
if not has_exception_note
|
||||
else "当前已识别到例外说明,后续仍需审批人重点复核。"
|
||||
)
|
||||
),
|
||||
suggestion="补充超标说明、协议酒店满房/会议高峰等原因,或调整住宿金额后再继续。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if document_type == "meal_receipt":
|
||||
allowance = self._resolve_review_travel_allowance_standard(
|
||||
policy,
|
||||
declared_city=declared_city,
|
||||
card_text=card_text,
|
||||
)
|
||||
if allowance is not None:
|
||||
region_label, standard_amount = allowance
|
||||
if amount is None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法按{region_label}伙食补助标准测算。"
|
||||
)
|
||||
append_once(
|
||||
f"travel-meal-amount-missing-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅餐饮金额待补充",
|
||||
level="high",
|
||||
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算金额。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),差旅餐饮票据优先按出差补助标准中的伙食补助进行测算。"
|
||||
f"当前匹配区域为{region_label},但票据缺少金额,系统无法判断是否超出补助标准。"
|
||||
),
|
||||
suggestion="请在票据识别结果中补充或更正餐饮金额,再继续创建报销单。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
|
||||
f"适用《{standard_rule_name}》{region_label}伙食补助标准 {standard_amount:.2f} 元/天,"
|
||||
f"{'超出标准' if amount > standard_amount else '测算通过'}。"
|
||||
)
|
||||
if amount > standard_amount:
|
||||
append_once(
|
||||
f"travel-meal-allowance-over-limit-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅餐饮金额超出伙食补助标准",
|
||||
level="high",
|
||||
content=(
|
||||
f"{card.filename} 识别金额 {amount:.2f} 元,"
|
||||
f"超过{region_label}伙食补助标准 {standard_amount:.2f} 元/天。"
|
||||
),
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version})的出差补助标准,"
|
||||
f"{region_label}伙食补助为 {standard_amount:.2f} 元/天;"
|
||||
f"当前票据类型识别为{document_type_label},识别金额 {amount:.2f} 元。"
|
||||
"首轮上传阶段按单张票据先行测算,后续可结合出差天数和实际餐补口径复核。"
|
||||
),
|
||||
suggestion="如该票据属于差旅餐补,请调整金额或补充超标/拆分说明;如属于业务招待或普通餐费,请改为对应费用类型后再提交。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
scene_code = self._resolve_review_amount_scene_code(card, payload)
|
||||
scene_policy = rule_catalog.get_scene_policy(scene_code)
|
||||
scene_limit = self._resolve_review_scene_amount_limit(scene_policy)
|
||||
if scene_policy is not None and scene_limit is not None:
|
||||
metric_label = str(getattr(scene_limit, "metric_label", "") or scene_policy.label or "金额").strip()
|
||||
standard_amount = self._resolve_scene_standard_amount(scene_limit)
|
||||
if amount is None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法按{metric_label}测算。"
|
||||
)
|
||||
append_once(
|
||||
f"{scene_code}-amount-missing-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title=f"{scene_policy.label}金额待补充",
|
||||
level="warning",
|
||||
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算金额。",
|
||||
detail=(
|
||||
f"依据《{scene_policy.rule_name}》({scene_policy.rule_version}),"
|
||||
f"{scene_policy.label}需要按{metric_label}进行金额审核。当前票据缺少金额,系统无法判断是否合规。"
|
||||
),
|
||||
suggestion="请在票据识别结果中补充或更正金额,再继续核对报销单。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if standard_amount is not None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
|
||||
f"适用《{scene_policy.rule_name}》{metric_label}标准 {standard_amount:.2f} 元,"
|
||||
f"{'超出标准' if amount > standard_amount else '测算通过'}。"
|
||||
)
|
||||
|
||||
amount_risk = self._evaluate_review_scene_amount(
|
||||
amount=amount,
|
||||
limit_config=scene_limit,
|
||||
reason_text=reason_corpus,
|
||||
)
|
||||
if amount_risk is not None:
|
||||
severity, threshold = amount_risk
|
||||
append_once(
|
||||
f"{scene_code}-amount-over-limit-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title=f"{scene_policy.label}金额超标待说明",
|
||||
level="high" if severity == "high" else "warning",
|
||||
content=(
|
||||
f"{card.filename} 识别金额 {amount:.2f} 元,"
|
||||
f"超过{metric_label}标准 {threshold:.2f} 元。"
|
||||
),
|
||||
detail=(
|
||||
f"依据《{scene_policy.rule_name}》({scene_policy.rule_version}),"
|
||||
f"{scene_policy.label}按{metric_label}审核,当前票据类型识别为{document_type_label},"
|
||||
f"识别金额 {amount:.2f} 元,标准阈值 {threshold:.2f} 元。"
|
||||
),
|
||||
suggestion="请补充超标原因或拆分到更准确的费用类型;如属于例外场景,请在事由中写明业务背景。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
transport_class = self._detect_review_transport_class(card, policy)
|
||||
if transport_class and grade_band is not None:
|
||||
transport_kind, class_label, class_level = transport_class
|
||||
allowed_level = policy.transport_limits.get(grade_band, {}).get(transport_kind)
|
||||
if allowed_level is not None and class_level > allowed_level:
|
||||
append_once(
|
||||
f"transport-class-over-limit-{card.index}-{class_label}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="交通舱位超标待说明" if not has_exception_note else "交通舱位超标提醒",
|
||||
level="warning",
|
||||
content=f"{card.filename} 识别为 {class_label},{band_label} 当前默认不可报销该舱位/席别。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),{band_label} 的交通席别标准"
|
||||
f"未覆盖 {class_label};票据类型识别为{document_type_label}。"
|
||||
+ (
|
||||
"当前未识别到例外说明,创建单据前需要补充原因。"
|
||||
if not has_exception_note
|
||||
else "当前已识别到例外说明,后续仍需审批人重点复核。"
|
||||
)
|
||||
),
|
||||
suggestion="补充无直达、临时改签、行程变更等例外说明,或更换为符合标准的票据。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if document_type == "meal_receipt" and self._is_travel_review_context(payload, document_cards, claim_groups):
|
||||
if amount is not None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;需确认按餐补、餐费或业务招待口径归口。"
|
||||
)
|
||||
append_once(
|
||||
f"travel-meal-card-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅餐饮票据待归口",
|
||||
level="warning",
|
||||
content=f"{card.filename} 已识别为餐饮票据,当前差旅报销单需要确认是否允许并入差旅费用。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version})的差旅票据预检口径,系统优先核算交通、住宿等差旅核心票据。"
|
||||
"餐饮票据可能需要按餐费或业务招待场景拆分,并补充同行人员或客户信息。"
|
||||
),
|
||||
suggestion="如属于差旅餐补,请补充制度允许口径;如属于招待或普通餐费,建议拆成对应费用类型单据。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if suggested_type in {"travel", "hotel", "transport"} and document_type in {"other", "travel_ticket"}:
|
||||
append_once(
|
||||
f"travel-type-uncertain-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅票据类型待确认",
|
||||
level="warning",
|
||||
content=f"{card.filename} 归入差旅场景,但票据类型仍需确认。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),差旅预检需要先明确票据是机票、火车票、住宿票据、打车票等,"
|
||||
"再匹配对应的金额或舱位规则。当前类型识别不够稳定。"
|
||||
),
|
||||
suggestion="请在附件识别结果中更正票据类型,或重新上传更清晰的附件后再继续。",
|
||||
),
|
||||
)
|
||||
|
||||
if amount_measurement_lines:
|
||||
briefs.insert(
|
||||
0,
|
||||
UserAgentReviewRiskBrief(
|
||||
title="附件金额测算结果",
|
||||
level="info",
|
||||
content="系统已根据首轮上传附件识别金额,并匹配当前可执行的报销标准进行测算。",
|
||||
detail=";".join(dict.fromkeys(amount_measurement_lines)),
|
||||
suggestion="如测算结果超标,请补充超标说明、调整金额或更正票据类型后再继续。",
|
||||
),
|
||||
)
|
||||
|
||||
return briefs
|
||||
|
||||
def _is_travel_review_context(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
document_cards: list[UserAgentReviewDocumentCard],
|
||||
claim_groups: list[UserAgentReviewClaimGroup],
|
||||
) -> bool:
|
||||
entity_expense_type = self._collect_entity_values(payload).get("expense_type_code", "")
|
||||
review_form_values = self._resolve_review_form_values(payload)
|
||||
form_expense_type = str(review_form_values.get("expense_type") or "").strip()
|
||||
message_context = " ".join(
|
||||
[
|
||||
str(payload.message or ""),
|
||||
str(payload.context_json.get("user_input_text") or ""),
|
||||
str(payload.context_json.get("expense_type") or ""),
|
||||
form_expense_type,
|
||||
]
|
||||
)
|
||||
if entity_expense_type in {"travel", "hotel", "transport"}:
|
||||
return True
|
||||
if any(group.group_code == "travel" or group.expense_type in {"travel", "hotel", "transport"} for group in claim_groups):
|
||||
return True
|
||||
if any(card.suggested_expense_type in {"travel", "hotel", "transport"} for card in document_cards):
|
||||
return True
|
||||
return any(keyword in message_context for keyword in ("差旅", "出差", "机票", "火车", "高铁", "酒店", "住宿"))
|
||||
|
||||
def _resolve_review_travel_allowance_standard(
|
||||
self,
|
||||
policy: RuntimeTravelPolicy,
|
||||
*,
|
||||
declared_city: str,
|
||||
card_text: str,
|
||||
) -> tuple[str, Decimal] | None:
|
||||
meal_limits = getattr(policy, "allowance_limits", {}).get("meal", {})
|
||||
if not meal_limits:
|
||||
return None
|
||||
|
||||
region_label = self._resolve_review_travel_allowance_region(
|
||||
" ".join([declared_city or "", card_text or ""])
|
||||
)
|
||||
amount = meal_limits.get(region_label)
|
||||
if amount is None and region_label != "其他地区":
|
||||
amount = meal_limits.get("其他地区")
|
||||
region_label = "其他地区"
|
||||
if amount is None:
|
||||
return None
|
||||
return region_label, Decimal(amount).quantize(Decimal("0.01"))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_review_travel_allowance_region(text: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(text or ""))
|
||||
if not normalized:
|
||||
return "其他地区"
|
||||
if any(keyword in normalized for keyword in ("境外", "国外", "海外")):
|
||||
return "国外"
|
||||
if any(keyword in normalized for keyword in ("香港", "澳门", "台湾", "港澳台")):
|
||||
return "港澳台"
|
||||
if "乌鲁木齐" in normalized:
|
||||
return "新疆-乌鲁木齐"
|
||||
if "新疆" in normalized:
|
||||
return "新疆-其他"
|
||||
if any(keyword in normalized for keyword in ("西藏", "拉萨")):
|
||||
return "西藏"
|
||||
if any(keyword in normalized for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")):
|
||||
return "直辖市/特区"
|
||||
return "其他地区"
|
||||
|
||||
def _resolve_review_amount_scene_code(
|
||||
self,
|
||||
card: UserAgentReviewDocumentCard,
|
||||
payload: UserAgentRequest,
|
||||
) -> str:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
suggested_type = str(card.suggested_expense_type or "").strip().lower()
|
||||
if document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"}:
|
||||
return "transport"
|
||||
if document_type == "meal_receipt":
|
||||
entity_values = self._collect_entity_values(payload)
|
||||
if suggested_type == "entertainment" or entity_values.get("expense_type_code") == "entertainment":
|
||||
return "entertainment"
|
||||
return "meal"
|
||||
if document_type == "hotel_invoice" or suggested_type == "hotel":
|
||||
return "hotel"
|
||||
if suggested_type in {
|
||||
"travel",
|
||||
"transport",
|
||||
"meal",
|
||||
"entertainment",
|
||||
"office",
|
||||
"meeting",
|
||||
"training",
|
||||
"communication",
|
||||
"welfare",
|
||||
"other",
|
||||
}:
|
||||
return suggested_type
|
||||
return self._collect_entity_values(payload).get("expense_type_code") or "other"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_review_scene_amount_limit(scene_policy: Any | None) -> Any | None:
|
||||
if scene_policy is None:
|
||||
return None
|
||||
return getattr(scene_policy, "item_amount_limit", None) or getattr(scene_policy, "claim_amount_limit", None)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_scene_standard_amount(limit_config: Any | None) -> Decimal | None:
|
||||
if limit_config is None:
|
||||
return None
|
||||
warn_amount = getattr(limit_config, "warn_amount", None)
|
||||
block_amount = getattr(limit_config, "block_amount", None)
|
||||
amount = warn_amount if warn_amount is not None else block_amount
|
||||
if amount is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(amount).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_review_scene_amount(
|
||||
*,
|
||||
amount: Decimal,
|
||||
limit_config: Any,
|
||||
reason_text: str,
|
||||
) -> tuple[str, Decimal] | None:
|
||||
block_amount = getattr(limit_config, "block_amount", None)
|
||||
warn_amount = getattr(limit_config, "warn_amount", None)
|
||||
exception_keywords = list(getattr(limit_config, "exception_keywords", []) or [])
|
||||
has_exception = UserAgentService._text_contains_any(reason_text, exception_keywords)
|
||||
|
||||
if block_amount is not None and amount > Decimal(block_amount):
|
||||
return ("high", Decimal(block_amount).quantize(Decimal("0.01")))
|
||||
if warn_amount is not None and amount > Decimal(warn_amount):
|
||||
return ("high", Decimal(warn_amount).quantize(Decimal("0.01")))
|
||||
return None
|
||||
|
||||
def _resolve_review_employee_grade(self, payload: UserAgentRequest, *, employee: Employee | None) -> str:
|
||||
if employee is not None and employee.grade:
|
||||
return str(employee.grade).strip()
|
||||
review_form_values = self._resolve_review_form_values(payload)
|
||||
for source in (
|
||||
review_form_values,
|
||||
payload.context_json,
|
||||
payload.tool_payload,
|
||||
):
|
||||
for key in ("employee_grade", "grade", "user_grade", "position_grade"):
|
||||
value = str(source.get(key) or "").strip() if isinstance(source, dict) else ""
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str:
|
||||
review_form_values = self._resolve_review_form_values(payload)
|
||||
parts = [
|
||||
str(payload.message or ""),
|
||||
str(payload.context_json.get("user_input_text") or ""),
|
||||
str(review_form_values.get("reason") or ""),
|
||||
str(review_form_values.get("business_reason") or ""),
|
||||
str(review_form_values.get("location") or ""),
|
||||
str(review_form_values.get("business_location") or ""),
|
||||
]
|
||||
return "\n".join(part.strip() for part in parts if part and part.strip())
|
||||
|
||||
def _resolve_declared_travel_city(self, payload: UserAgentRequest, policy: RuntimeTravelPolicy) -> str:
|
||||
review_form_values = self._resolve_review_form_values(payload)
|
||||
candidates = [
|
||||
str(review_form_values.get("business_location") or ""),
|
||||
str(review_form_values.get("location") or ""),
|
||||
self._resolve_location_value(payload),
|
||||
str(payload.message or ""),
|
||||
]
|
||||
for candidate in candidates:
|
||||
city = self._extract_policy_city_from_text(candidate, policy)
|
||||
if city:
|
||||
return city
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_review_document_card_text(card: UserAgentReviewDocumentCard) -> str:
|
||||
field_text = " ".join(f"{field.label}:{field.value}" for field in card.fields)
|
||||
return " ".join(
|
||||
[
|
||||
str(card.filename or ""),
|
||||
str(card.document_type or ""),
|
||||
str(card.scene_label or ""),
|
||||
str(card.summary or ""),
|
||||
field_text,
|
||||
]
|
||||
).strip()
|
||||
|
||||
@staticmethod
|
||||
def _is_review_hotel_card(card: UserAgentReviewDocumentCard) -> bool:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
suggested_type = str(card.suggested_expense_type or "").strip().lower()
|
||||
scene_label = str(card.scene_label or "").strip()
|
||||
return document_type == "hotel_invoice" or suggested_type == "hotel" or "住宿" in scene_label
|
||||
|
||||
@staticmethod
|
||||
def _extract_amount_decimal_from_card(card: UserAgentReviewDocumentCard) -> Decimal | None:
|
||||
for field in card.fields:
|
||||
if field.label != "金额":
|
||||
continue
|
||||
normalized = str(field.value or "").replace("元", "").replace("¥", "").replace("¥", "").replace(",", "").strip()
|
||||
try:
|
||||
amount = Decimal(normalized).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
if amount > Decimal("0.00"):
|
||||
return amount
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_review_hotel_night_count(card: UserAgentReviewDocumentCard) -> int:
|
||||
text = f"{card.summary or ''} {' '.join(f'{field.label}:{field.value}' for field in card.fields)}"
|
||||
match = TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN.search(text)
|
||||
if not match:
|
||||
return 1
|
||||
try:
|
||||
return max(1, int(match.group(1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def _extract_policy_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
|
||||
normalized = str(text or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
city_names = set(policy.city_tiers.keys())
|
||||
city_names.update(getattr(policy, "hotel_city_limits", {}).keys())
|
||||
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
|
||||
if city in normalized:
|
||||
return city
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_travel_city_tier(city_tier: str) -> str:
|
||||
return {
|
||||
"tier_1": "一线城市",
|
||||
"tier_2": "重点城市",
|
||||
"tier_3": "其他城市",
|
||||
}.get(str(city_tier or "").strip(), "当前城市")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_review_hotel_cap(
|
||||
policy: RuntimeTravelPolicy,
|
||||
*,
|
||||
grade_band: str,
|
||||
city: str,
|
||||
city_tier: str,
|
||||
) -> Decimal:
|
||||
normalized_city = str(city or "").strip()
|
||||
if normalized_city and getattr(policy, "hotel_city_limits", None):
|
||||
city_limits = policy.hotel_city_limits.get(normalized_city, {})
|
||||
city_cap = city_limits.get(grade_band)
|
||||
if city_cap is not None:
|
||||
return Decimal(city_cap).quantize(Decimal("0.01"))
|
||||
return Decimal(policy.hotel_limits.get(grade_band, {}).get(city_tier, Decimal("0.00"))).quantize(
|
||||
Decimal("0.01")
|
||||
)
|
||||
|
||||
def _detect_review_transport_class(
|
||||
self,
|
||||
card: UserAgentReviewDocumentCard,
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> tuple[str, str, int] | None:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
text = re.sub(r"\s+", "", self._build_review_document_card_text(card))
|
||||
if not text:
|
||||
return None
|
||||
|
||||
if document_type == "flight_itinerary" or any(keyword in text for keyword in ("机票", "航班", "登机牌")):
|
||||
for config in policy.flight_classes:
|
||||
label = str(config.keyword or "").strip()
|
||||
if label and label in text:
|
||||
return "flight", label, int(config.level)
|
||||
|
||||
if document_type == "train_ticket" or any(keyword in text for keyword in ("火车", "高铁", "动车", "铁路")):
|
||||
for config in policy.train_classes:
|
||||
label = str(config.keyword or "").strip()
|
||||
if label and label in text:
|
||||
return "train", label, int(config.level)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _text_contains_any(text: str, keywords: list[str] | tuple[str, ...]) -> bool:
|
||||
compact = re.sub(r"\s+", "", str(text or ""))
|
||||
return bool(compact) and any(str(keyword or "").strip() and str(keyword).strip() in compact for keyword in keywords)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]:
|
||||
@@ -2543,6 +3149,14 @@ class UserAgentService:
|
||||
"系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。"
|
||||
)
|
||||
|
||||
blocked_reasons = self._resolve_submission_blocked_reasons(payload)
|
||||
if blocked_reasons:
|
||||
reason_text = ";".join(dict.fromkeys(reason.strip("。;;") for reason in blocked_reasons if reason))
|
||||
return (
|
||||
f"AI预审未通过:{reason_text}。"
|
||||
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
|
||||
)
|
||||
|
||||
review_payload = UserAgentReviewPayload(
|
||||
intent_summary="",
|
||||
body_message="",
|
||||
@@ -3460,7 +4074,18 @@ class UserAgentService:
|
||||
evidence="来源于用户修改后的结构化表单。",
|
||||
)
|
||||
|
||||
merchant_value = self._extract_document_merchant_name(ocr_documents[0]) if ocr_documents else ""
|
||||
merchant_value = ""
|
||||
for document in ocr_documents:
|
||||
if str(document.get("document_type") or "").strip().lower() != "hotel_invoice":
|
||||
continue
|
||||
merchant_value = self._extract_document_merchant_name(document)
|
||||
if merchant_value:
|
||||
break
|
||||
if not merchant_value:
|
||||
for document in ocr_documents:
|
||||
merchant_value = self._extract_document_merchant_name(document)
|
||||
if merchant_value:
|
||||
break
|
||||
if merchant_value:
|
||||
return self._build_slot_value(
|
||||
value=merchant_value,
|
||||
|
||||
Reference in New Issue
Block a user