feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -20,9 +20,12 @@ from app.schemas.reimbursement import (
|
||||
ExpenseClaimReturnPayload,
|
||||
ReimbursementCreate,
|
||||
ReimbursementRead,
|
||||
TravelReimbursementCalculatorRequest,
|
||||
TravelReimbursementCalculatorResponse,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.reimbursement import ReimbursementService
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
router = APIRouter()
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -50,6 +53,29 @@ def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> Reimbur
|
||||
return ReimbursementService(db).create_reimbursement(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/travel-calculator",
|
||||
response_model=TravelReimbursementCalculatorResponse,
|
||||
summary="差旅报销标准测算",
|
||||
description="根据规则中心的差旅报销表、当前员工职级、出差天数与地点测算住宿和补贴参考金额。",
|
||||
responses={
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "测算入参或规则匹配失败。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def calculate_travel_reimbursement(
|
||||
payload: TravelReimbursementCalculatorRequest,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> TravelReimbursementCalculatorResponse:
|
||||
try:
|
||||
return TravelReimbursementCalculatorService(db).calculate(payload, current_user)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claims",
|
||||
response_model=list[ExpenseClaimRead],
|
||||
@@ -463,8 +489,8 @@ def return_expense_claim(
|
||||
@router.post(
|
||||
"/claims/{claim_id}/approve",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="直属领导审批通过报销单",
|
||||
description="当前审批人确认报销信息无误后,将报销单从直属领导审批流转到财务审批。",
|
||||
summary="审批通过报销单",
|
||||
description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
@@ -497,7 +523,7 @@ def approve_expense_claim(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除报销单",
|
||||
description="普通用户仅可删除草稿或待补充报销单;财务人员和高级管理人员可删除可见报销单。",
|
||||
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
|
||||
@@ -93,6 +93,10 @@ class ExpenseClaimItem(Base):
|
||||
|
||||
claim = relationship("ExpenseClaim", back_populates="items")
|
||||
|
||||
@property
|
||||
def is_system_generated(self) -> bool:
|
||||
return str(self.item_type or "").strip().lower() in {"travel_allowance"}
|
||||
|
||||
|
||||
class AccountsReceivableRecord(Base):
|
||||
__tablename__ = "accounts_receivable"
|
||||
|
||||
@@ -41,6 +41,7 @@ class ExpenseClaimItemRead(BaseModel):
|
||||
item_location: str
|
||||
item_amount: Decimal
|
||||
invoice_id: str | None
|
||||
is_system_generated: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -157,11 +158,41 @@ class ExpenseClaimApprovalPayload(BaseModel):
|
||||
opinion: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorRequest(BaseModel):
|
||||
days: int = Field(ge=1, le=365)
|
||||
location: str = Field(min_length=1, max_length=120)
|
||||
grade: str | None = Field(default=None, max_length=30)
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorResponse(BaseModel):
|
||||
days: int
|
||||
location: str
|
||||
matched_city: str
|
||||
city_tier: str
|
||||
grade: str
|
||||
grade_band: str
|
||||
grade_band_label: str
|
||||
hotel_rate: Decimal
|
||||
hotel_amount: Decimal
|
||||
allowance_region: str
|
||||
meal_allowance_rate: Decimal
|
||||
basic_allowance_rate: Decimal
|
||||
total_allowance_rate: Decimal
|
||||
allowance_amount: Decimal
|
||||
total_amount: Decimal
|
||||
rule_name: str
|
||||
rule_version: str
|
||||
formula_text: str
|
||||
summary_text: str
|
||||
|
||||
|
||||
class ExpenseClaimAttachmentActionResponse(BaseModel):
|
||||
message: str
|
||||
claim_id: str
|
||||
item_id: str
|
||||
invoice_id: str | None = None
|
||||
item_amount: Decimal | None = None
|
||||
claim_amount: Decimal | None = None
|
||||
attachment: ExpenseClaimAttachmentRead | None = None
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"file_name": "2月23_上海-武汉.pdf",
|
||||
"storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 24940,
|
||||
"uploaded_at": "2026-05-20T13:48:38.616319+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "2月23_上海-武汉.preview.png",
|
||||
"analysis": {
|
||||
"severity": "medium",
|
||||
"label": "中风险",
|
||||
"headline": "AI提示:附件存在明显待整改项",
|
||||
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
|
||||
"points": [
|
||||
"用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。"
|
||||
],
|
||||
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "train_ticket",
|
||||
"document_type_label": "火车/高铁票",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "354元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-05-18"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "中国铁路"
|
||||
},
|
||||
{
|
||||
"key": "invoice_number",
|
||||
"label": "票据号码",
|
||||
"value": "26319166100006175398"
|
||||
},
|
||||
{
|
||||
"key": "route",
|
||||
"label": "行程",
|
||||
"value": "上海-武汉"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "travel",
|
||||
"current_expense_type_label": "差旅费",
|
||||
"allowed_scene_labels": [
|
||||
"差旅"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"机票/航班行程单",
|
||||
"火车/高铁票"
|
||||
],
|
||||
"recognized_scene_code": "travel",
|
||||
"recognized_scene_label": "差旅票据",
|
||||
"recognized_document_type": "train_ticket",
|
||||
"recognized_document_type_label": "火车/高铁票",
|
||||
"mismatch_severity": "high",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
"ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
|
||||
"ocr_summary": "电子发票;(铁路电子客票);州",
|
||||
"ocr_avg_score": 0.9620026834309101,
|
||||
"ocr_line_count": 24,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"铁路电子客票",
|
||||
"电子客票",
|
||||
"铁路",
|
||||
"二等座"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"file_name": "2月20_武汉-上海.pdf",
|
||||
"storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 24995,
|
||||
"uploaded_at": "2026-05-20T13:48:21.652497+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "2月20_武汉-上海.preview.png",
|
||||
"analysis": {
|
||||
"severity": "medium",
|
||||
"label": "中风险",
|
||||
"headline": "AI提示:附件存在明显待整改项",
|
||||
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
|
||||
"points": [
|
||||
"用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。"
|
||||
],
|
||||
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "train_ticket",
|
||||
"document_type_label": "火车/高铁票",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "354元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-05-18"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "中国铁路"
|
||||
},
|
||||
{
|
||||
"key": "invoice_number",
|
||||
"label": "票据号码",
|
||||
"value": "26429165800002785705"
|
||||
},
|
||||
{
|
||||
"key": "route",
|
||||
"label": "行程",
|
||||
"value": "武汉-上海"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "travel",
|
||||
"current_expense_type_label": "差旅费",
|
||||
"allowed_scene_labels": [
|
||||
"差旅"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"机票/航班行程单",
|
||||
"火车/高铁票"
|
||||
],
|
||||
"recognized_scene_code": "travel",
|
||||
"recognized_scene_label": "差旅票据",
|
||||
"recognized_document_type": "train_ticket",
|
||||
"recognized_document_type_label": "火车/高铁票",
|
||||
"mismatch_severity": "high",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
"ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
|
||||
"ocr_summary": "电子发票;(铁路电子客票);州",
|
||||
"ocr_avg_score": 0.9580968717734019,
|
||||
"ocr_line_count": 24,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"铁路电子客票",
|
||||
"电子客票",
|
||||
"铁路",
|
||||
"二等座"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
BIN
server/storage/font-test-after-install.png
Normal file
BIN
server/storage/font-test-after-install.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -20,7 +20,7 @@
|
||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
||||
"ingest_agent_run_id": "run_8b0ead1e3c734a53"
|
||||
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||
},
|
||||
{
|
||||
"id": "a8f8465df08e455ebe133351721d49f8",
|
||||
@@ -36,12 +36,12 @@
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1,
|
||||
"ingest_status": 4,
|
||||
"ingest_status_updated_at": "2026-05-19T16:00:57.418443+00:00",
|
||||
"ingest_status_updated_at": "2026-05-20T16:00:02.515903+00:00",
|
||||
"ingest_completed_at": "",
|
||||
"ingest_document_name": "",
|
||||
"ingest_document_updated_at": "",
|
||||
"ingest_document_sha256": "",
|
||||
"ingest_agent_run_id": "run_57f2d8727aaa4374"
|
||||
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -24,11 +24,14 @@ from app.core.agent_enums import (
|
||||
)
|
||||
from app.core.config import SERVER_DIR
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetCreate,
|
||||
AgentAssetReviewCreate,
|
||||
AgentAssetVersionCreate,
|
||||
)
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
@@ -41,6 +44,7 @@ from app.services.agent_runs import AgentRunService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||
from app.services.settings import OnlyOfficeRuntimeConfig
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -618,6 +622,126 @@ def test_agent_asset_service_returns_travel_policy_rule_detail() -> None:
|
||||
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
|
||||
|
||||
|
||||
def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -> None:
|
||||
with build_session() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
travel_spreadsheet_rule = db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||
)
|
||||
assert travel_spreadsheet_rule is not None
|
||||
travel_spreadsheet_rule.status = AgentAssetStatus.REVIEW.value
|
||||
db.commit()
|
||||
|
||||
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||||
|
||||
assert catalog.travel_policy is not None
|
||||
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则"
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500
|
||||
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
|
||||
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
|
||||
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
|
||||
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1
|
||||
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9001",
|
||||
name="测试员工",
|
||||
email="traveler@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"),
|
||||
CurrentUserContext(
|
||||
username="traveler@example.com",
|
||||
name="测试员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.rule_name == "公司差旅费报销规则"
|
||||
assert result.grade == "P4"
|
||||
assert result.grade_band == "mid"
|
||||
assert result.matched_city == "北京"
|
||||
assert result.hotel_rate == 450
|
||||
assert result.hotel_amount == 1350
|
||||
assert result.allowance_region == "直辖市/特区"
|
||||
assert result.total_allowance_rate == 100
|
||||
assert result.allowance_amount == 300
|
||||
assert result.total_amount == 1650
|
||||
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text
|
||||
assert "参考可报销总金额为 1650.00 元" in result.summary_text
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9002",
|
||||
name="其他地区员工",
|
||||
email="other-region@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=2, location="吉林延边"),
|
||||
CurrentUserContext(
|
||||
username="other-region@example.com",
|
||||
name="其他地区员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.matched_city == "延边(其他地区)"
|
||||
assert result.city_tier == "tier_3"
|
||||
assert result.hotel_rate == 380
|
||||
assert result.hotel_amount == 760
|
||||
assert result.allowance_region == "其他地区"
|
||||
assert result.total_allowance_rate == 90
|
||||
assert result.allowance_amount == 180
|
||||
assert result.total_amount == 940
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9003",
|
||||
name="无效地点员工",
|
||||
email="invalid-location@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="未识别为有效出差地区"):
|
||||
TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=2, location="背景"),
|
||||
CurrentUserContext(
|
||||
username="invalid-location@example.com",
|
||||
name="无效地点员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
|
||||
@@ -51,6 +51,27 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
|
||||
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
|
||||
|
||||
|
||||
def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None:
|
||||
insight = build_document_insight(
|
||||
filename="铁路电子客票.pdf",
|
||||
summary="电子发票(铁路电子客票)",
|
||||
text=(
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26319166100006175398\n"
|
||||
"上海虹桥站\n"
|
||||
"武汉站\n"
|
||||
"G456\n"
|
||||
"二等座\n"
|
||||
"票价:¥354.00"
|
||||
),
|
||||
)
|
||||
|
||||
assert insight.document_type == "train_ticket"
|
||||
assert insight.document_type_label == "火车/高铁票"
|
||||
assert insight.scene_code == "travel"
|
||||
assert any(field.label == "金额" and field.value == "354元" for field in insight.fields)
|
||||
|
||||
|
||||
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.organization import OrganizationUnit
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.ocr import OcrService
|
||||
@@ -722,6 +723,82 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
|
||||
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
|
||||
|
||||
|
||||
def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
) -> OcrRecognizeBatchRead:
|
||||
return OcrRecognizeBatchRead(
|
||||
total_file_count=1,
|
||||
success_count=1,
|
||||
documents=[
|
||||
OcrRecognizeDocumentRead(
|
||||
filename="train-ticket.png",
|
||||
media_type="image/png",
|
||||
text="中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00",
|
||||
summary="铁路电子客票,票价 354 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅费",
|
||||
document_fields=[
|
||||
{"key": "fare", "label": "票价", "value": "¥354.00"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="北京")
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.items[0].item_amount = Decimal("0.00")
|
||||
claim.items[0].invoice_id = None
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
updated = service.upload_claim_item_attachment(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
filename="train-ticket.png",
|
||||
content=b"fake-image-bytes",
|
||||
media_type="image/png",
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated["item_amount"] == Decimal("354.00")
|
||||
assert updated["claim_amount"] == Decimal("354.00")
|
||||
db.refresh(claim)
|
||||
assert claim.items[0].item_amount == Decimal("354.00")
|
||||
assert claim.amount == Decimal("354.00")
|
||||
uploaded_meta = service.get_claim_item_attachment_meta(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
assert uploaded_meta is not None
|
||||
assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
|
||||
assert any(
|
||||
field["label"] == "票价" and field["value"] == "¥354.00"
|
||||
for field in uploaded_meta["document_info"]["fields"]
|
||||
)
|
||||
|
||||
|
||||
def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
@@ -1502,7 +1579,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
|
||||
|
||||
|
||||
def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
@@ -1545,10 +1622,46 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
deleted = service.delete_claim(claim_id, current_user)
|
||||
with pytest.raises(ValueError, match="只有高级管理人员可以删除"):
|
||||
service.delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_executive_can_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-delete@example.com",
|
||||
name="高管",
|
||||
role_codes=["executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-DEL-EXEC-101",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-RET-101"
|
||||
assert deleted.claim_no == "EXP-DEL-EXEC-101"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
@@ -1675,6 +1788,56 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
|
||||
)
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
name="财务复核",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-FIN-APP-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion="票据与明细一致,同意入账。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "归档入账"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "finance_approval"
|
||||
and flag.get("event_type") == "expense_claim_finance_approval"
|
||||
and flag.get("opinion") == "票据与明细一致,同意入账。"
|
||||
and flag.get("previous_approval_stage") == "财务审批"
|
||||
and flag.get("next_approval_stage") == "归档入账"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-returned@example.com",
|
||||
@@ -1836,6 +1999,16 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
claim.risk_flags_json = [return_flag]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
conversation = AgentConversationService(db).get_or_create_conversation(
|
||||
conversation_id=None,
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
context_json={
|
||||
"session_type": "expense",
|
||||
"draft_claim_id": claim.id,
|
||||
},
|
||||
)
|
||||
conversation_id = conversation.conversation_id
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
@@ -1848,6 +2021,7 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
and flag.get("return_event_id") == "return-event-submit"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
assert AgentConversationService(db).get_conversation(conversation_id) is None
|
||||
|
||||
|
||||
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
|
||||
@@ -2001,3 +2175,57 @@ def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_a
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-MGR-201"
|
||||
|
||||
|
||||
def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approval-list@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-LIST-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="transport",
|
||||
reason="直属领导待审",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-LIST-202",
|
||||
employee_name="李四",
|
||||
department_name="销售部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="meal",
|
||||
reason="财务待审",
|
||||
location="杭州",
|
||||
amount=Decimal("188.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
|
||||
|
||||
@@ -177,3 +177,80 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
assert any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields)
|
||||
assert recognized.lines[0].page_index == 0
|
||||
assert recognized.lines[1].page_index == 1
|
||||
|
||||
|
||||
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-page")
|
||||
return [page]
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
*,
|
||||
python_bin: str,
|
||||
worker_path: str,
|
||||
input_paths: list[Path],
|
||||
) -> dict:
|
||||
return {
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"documents": [
|
||||
{
|
||||
"input_path": str(input_paths[0]),
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"text": "□□□□□□\n□□□□:26319166100006175398\nG456\n□□:□354.00",
|
||||
"summary": "□□□□□□;□□□□:26319166100006175398",
|
||||
"avg_score": 0.88,
|
||||
"line_count": 4,
|
||||
"page_count": 1,
|
||||
"warnings": [],
|
||||
"lines": [
|
||||
{
|
||||
"text": "□□□□□□",
|
||||
"score": 0.88,
|
||||
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
|
||||
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
|
||||
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||
monkeypatch.setattr(
|
||||
OcrService,
|
||||
"_extract_pdf_text_layer",
|
||||
lambda self, pdf_path: (
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26319166100006175398\n"
|
||||
"上海虹桥站\n"
|
||||
"武汉站\n"
|
||||
"G456\n"
|
||||
"票价:¥354.00"
|
||||
),
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
result = OcrService().recognize_files(
|
||||
[
|
||||
("train-ticket.pdf", b"%PDF-1.4 fake", "application/pdf"),
|
||||
]
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
recognized = result.documents[0]
|
||||
assert "电子发票(铁路电子客票)" in recognized.text
|
||||
assert "上海虹桥站" in recognized.text
|
||||
assert "□□□□" not in recognized.summary
|
||||
assert recognized.document_type == "train_ticket"
|
||||
assert recognized.preview_kind == ""
|
||||
assert recognized.preview_data_url == ""
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
|
||||
|
||||
@@ -96,6 +97,8 @@ def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert claim.submitted_at is not None
|
||||
assert response.conversation_id
|
||||
assert AgentConversationService(db).get_conversation(response.conversation_id) is None
|
||||
|
||||
|
||||
def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
@@ -165,6 +168,8 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
|
||||
assert response.status == "succeeded"
|
||||
assert result["draft_payload"]["status"] == "draft"
|
||||
assert response.conversation_id
|
||||
assert AgentConversationService(db).get_conversation(response.conversation_id) is not None
|
||||
assert "AI预审暂未通过" in result["answer"]
|
||||
assert "所属部门未完善" in result["answer"]
|
||||
assert "next_step" not in actions
|
||||
|
||||
@@ -345,9 +345,17 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["opinion"] == "情况属实,同意报销。"
|
||||
and item["operator"] == "李经理"
|
||||
and item["next_approval_stage"] == "财务审批"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
approval_events = [
|
||||
item
|
||||
for item in payload["risk_flags_json"]
|
||||
if item["source"] == "manual_approval"
|
||||
]
|
||||
assert approval_events[0]["operator"] == "李经理"
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
|
||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.user_agent import UserAgentService
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
@@ -1096,11 +1101,11 @@ def test_user_agent_prefers_larger_decimal_amount_from_ocr_text_candidates() ->
|
||||
assert slot_map["amount"].value == "13.40元"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我上传了打车票据,帮我生成报销草稿",
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
@@ -1147,15 +1152,465 @@ def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.review_payload.document_cards[0].preview_kind == "image"
|
||||
assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
assert response.review_payload.document_cards[0].preview_kind == "image"
|
||||
assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prechecks_travel_receipts_against_policy_and_hides_old_briefs(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
employee = Employee(
|
||||
employee_no="E-TRAVEL-001",
|
||||
name="张三",
|
||||
email="pytest-travel@example.com",
|
||||
position="实施顾问",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-HISTORY-001",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_name="交付部",
|
||||
expense_type="travel",
|
||||
reason="历史差旅记录",
|
||||
location="北京",
|
||||
amount=Decimal("680.00"),
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC) - timedelta(days=7),
|
||||
status="draft",
|
||||
risk_flags_json=[{"label": "历史风险"}],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
query = "我去北京出差住酒店,上传了北京酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京中心酒店 住宿 1 晚 金额 680 元",
|
||||
"text": "北京中心酒店 住宿 1 晚 金额 680 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "680"},
|
||||
{"key": "merchant", "label": "酒店", "value": "北京中心酒店"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
service = UserAgentService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_build_citations",
|
||||
lambda payload: [
|
||||
UserAgentCitation(
|
||||
source_type="rule",
|
||||
code="rule.expense.travel_risk_control_standard",
|
||||
title="差旅报销风险管控制度",
|
||||
version="v1.1.0",
|
||||
excerpt="住宿费按职级和城市分级限额执行。",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
titles = [item.title for item in response.review_payload.risk_briefs]
|
||||
assert "历史报销画像" not in titles
|
||||
assert "制度注意事项" not in titles
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.title}\n{hotel_brief.content}\n{hotel_brief.detail}\n{hotel_brief.suggestion}"
|
||||
assert "北京酒店发票.png" in combined
|
||||
assert "P4-P5" in combined
|
||||
assert "680.00" in combined
|
||||
assert "450.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
assert "补充超标说明" in combined
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["merchant_name"].value == "北京中心酒店"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prefers_hotel_invoice_for_hotel_name() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,上传了火车票和酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京南站火车票.png", "北京中心酒店发票.png"],
|
||||
"attachment_count": 2,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京南站火车票.png",
|
||||
"document_type": "train_ticket",
|
||||
"summary": "广州南至北京南 高铁二等座 金额 560 元",
|
||||
"text": "广州南至北京南 高铁二等座 金额 560 元",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "560"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
{
|
||||
"filename": "北京中心酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"text": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "450"},
|
||||
{"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-hotel-name@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-hotel-name@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["merchant_name"].value == "北京中心酒店"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,上传了一张打车票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["北京打车票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京打车票.png",
|
||||
"document_type": "taxi_receipt",
|
||||
"summary": "北京网约车 打车票 支付金额 360 元",
|
||||
"text": "北京网约车 打车票 支付金额 360 元",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "支付金额", "value": "360"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
amount_brief = next(item for item in response.review_payload.risk_briefs if "交通费金额超标" in item.title)
|
||||
combined = f"{amount_brief.title}\n{amount_brief.content}\n{amount_brief.detail}\n{amount_brief.suggestion}"
|
||||
assert "北京打车票.png" in combined
|
||||
assert "360.00" in combined
|
||||
assert "300.00" in combined
|
||||
assert "单笔交通金额" in combined
|
||||
assert "报销场景提交与附件标准" in combined
|
||||
assert amount_brief.level == "high"
|
||||
assert any(item.title == "附件金额测算结果" for item in response.review_payload.risk_briefs)
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_finance_spreadsheet_hotel_amount_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
employee = Employee(
|
||||
employee_no="E-TRAVEL-XLSX-001",
|
||||
name="测算员工",
|
||||
email="pytest-travel-xlsx@example.com",
|
||||
position="基层经理",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
query = "测算员工去北京出差住宿,上传了北京酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "测算员工",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京酒店 住宿 1 晚 金额 480 元",
|
||||
"text": "北京酒店 住宿 1 晚 金额 480 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "480"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-xlsx@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-xlsx@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.content}\n{hotel_brief.detail}"
|
||||
assert "480.00" in combined
|
||||
assert "450.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_spreadsheet_city_hotel_standard_not_default_tier() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
|
||||
query = "我去张家口出差住宿,上传了张家口酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["张家口酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "张家口酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "张家口酒店 住宿 1 晚 金额 320 元",
|
||||
"text": "张家口酒店 住宿 1 晚 金额 320 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "320"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-city@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-city@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.content}\n{hotel_brief.detail}"
|
||||
assert "320.00" in combined
|
||||
assert "300.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_finance_spreadsheet_meal_allowance_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
|
||||
query = "我去北京出差,上传了一张餐饮发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["北京餐饮发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京餐饮发票.png",
|
||||
"document_type": "meal_receipt",
|
||||
"summary": "北京餐饮发票 金额 90 元",
|
||||
"text": "北京餐饮发票 金额 90 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "90"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-meal@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-meal@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
meal_brief = next(item for item in response.review_payload.risk_briefs if "伙食补助标准" in item.title)
|
||||
combined = f"{meal_brief.title}\n{meal_brief.content}\n{meal_brief.detail}\n{meal_brief.suggestion}"
|
||||
assert "北京餐饮发票.png" in combined
|
||||
assert "90.00" in combined
|
||||
assert "65.00" in combined
|
||||
assert "直辖市/特区" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
assert meal_brief.level == "high"
|
||||
measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算结果")
|
||||
assert "伙食补助标准 65.00" in measurement.detail
|
||||
|
||||
|
||||
def test_user_agent_filters_deprecated_review_risk_briefs() -> None:
|
||||
filtered = UserAgentService._filter_deprecated_review_risk_briefs(
|
||||
[
|
||||
UserAgentReviewRiskBrief(title="历史报销画像", level="info", content="旧画像"),
|
||||
UserAgentReviewRiskBrief(title="用户画像", level="info", content="旧画像"),
|
||||
UserAgentReviewRiskBrief(title="制度注意事项", level="info", content="旧制度提示"),
|
||||
UserAgentReviewRiskBrief(title="住宿超标待说明", level="high", content="保留"),
|
||||
]
|
||||
)
|
||||
|
||||
assert [item.title for item in filtered] == ["住宿超标待说明"]
|
||||
|
||||
|
||||
def test_user_agent_submission_blocked_risk_level_only_marks_amount_reasons_high() -> None:
|
||||
assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high"
|
||||
assert UserAgentService._resolve_submission_blocked_risk_level("缺少直属领导或参与人员信息") == "warning"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差住酒店,帮我生成差旅费报销草稿并进入下一步提交"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京酒店 住宿 1 晚 金额 680 元",
|
||||
"text": "北京酒店 住宿 1 晚 金额 680 元",
|
||||
"avg_score": 0.94,
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={
|
||||
"submission_blocked": True,
|
||||
"submission_blocked_reasons": ["住宿金额超出当前职级差标,且未补充超标说明。"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.answer == response.review_payload.body_message
|
||||
assert response.answer.startswith("AI预审未通过:住宿金额超出当前职级差标")
|
||||
assert "整改后再继续提交" in response.answer
|
||||
assert response.review_payload.can_proceed is False
|
||||
blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示")
|
||||
assert blocked_brief.level == "high"
|
||||
assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs)
|
||||
|
||||
|
||||
def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我上传了两张票据,帮我生成报销草稿",
|
||||
user_id="pytest",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user