feat: 增加差旅报销标准测算和财务终审流程

新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 09:28:33 +08:00
parent 002bf4f756
commit 8f65661809
43 changed files with 4366 additions and 410 deletions

View File

@@ -32,7 +32,21 @@ services:
- >
apt-get update &&
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
python3 python3-pip python3-venv &&
python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra &&
printf '%s\n'
'<?xml version="1.0"?>'
'<!DOCTYPE fontconfig SYSTEM "fonts.dtd">'
'<fontconfig>'
' <alias><family>SimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
' <alias><family>NSimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
' <alias><family>KaiTi</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
' <alias><family>FangSong</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
' <alias><family>SimHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
' <alias><family>DengXian</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
' <alias><family>Microsoft YaHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
'</fontconfig>'
> /etc/fonts/local.conf &&
fc-cache -f &&
mkdir -p /run/sshd && /usr/sbin/sshd &&
printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh &&
chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&

View File

@@ -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,

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
scene_code="travel",
scene_label="差旅票据",
expense_type="travel",
keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"),
keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"),
score_bias=0.32,
),
DocumentRule(

View File

@@ -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 = {
@@ -546,6 +547,11 @@ class ExpenseClaimService:
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,
@@ -620,6 +626,8 @@ class ExpenseClaimService:
"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),
}
@@ -747,6 +755,8 @@ class ExpenseClaimService:
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
@@ -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)
@@ -2216,6 +2242,79 @@ class ExpenseClaimService:
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,
*,
@@ -2265,6 +2364,11 @@ class ExpenseClaimService:
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()
@@ -2287,6 +2391,11 @@ class ExpenseClaimService:
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():
@@ -2466,6 +2575,27 @@ class ExpenseClaimService:
"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,
*,
@@ -2933,6 +3063,15 @@ class ExpenseClaimService:
if not self._is_editable_claim_status(claim.status):
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
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 = [
@@ -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:

View File

@@ -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)

View File

@@ -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):

View 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'))}"

View File

@@ -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,25 +2243,6 @@ 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="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。",
)
)
current_amount = self._resolve_amount_value(payload)
if current_amount > 0:
duplicate_count = sum(
1
for item in recent_claims
@@ -2269,17 +2265,6 @@ class UserAgentService:
)
)
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:
briefs.append(
@@ -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,

View File

@@ -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": []
}

View File

@@ -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

View File

@@ -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"
}
]
}

View File

@@ -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)

View File

@@ -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:",

View File

@@ -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"]

View File

@@ -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 == ""

View File

@@ -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

View File

@@ -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:

View File

@@ -1,14 +1,19 @@
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 UserAgentRequest
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
@@ -1151,6 +1156,456 @@ def test_user_agent_review_payload_keeps_document_preview_data() -> None:
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:

View File

@@ -813,6 +813,24 @@
color: #1d4ed8;
}
.message-meta-chip.high {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.message-meta-chip.medium {
background: #fffbeb;
color: #b45309;
border: 1px solid #fde68a;
}
.message-meta-chip.low {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.risk-chip,
.message-risk-chip {
background: #fff1f2;
@@ -1262,6 +1280,10 @@
position: relative;
}
.travel-calculator-anchor {
position: relative;
}
.tool-btn.composer-side-btn.active {
border-color: rgba(59, 130, 246, 0.42);
background: rgba(239, 246, 255, 0.96);
@@ -1286,6 +1308,84 @@
0 4px 12px rgba(15, 23, 42, 0.06);
}
.travel-calculator-popover {
position: absolute;
bottom: calc(100% + 10px);
left: 0;
z-index: 30;
width: min(300px, calc(100vw - 48px));
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.16),
0 4px 12px rgba(15, 23, 42, 0.06);
}
.travel-calculator-mini-head {
display: grid;
gap: 3px;
}
.travel-calculator-mini-head strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.travel-calculator-mini-head span {
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.travel-calculator-form {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 8px;
}
.travel-calculator-field {
display: grid;
gap: 6px;
min-width: 0;
}
.travel-calculator-field span {
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.travel-calculator-field input {
width: 100%;
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 12px;
font-weight: 700;
}
.travel-calculator-field input:focus {
border-color: rgba(59, 130, 246, 0.46);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
outline: none;
}
.travel-calculator-error {
margin: 0;
color: #dc2626;
font-size: 11px;
font-weight: 750;
line-height: 1.5;
}
.composer-date-mode-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1984,6 +2084,11 @@
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
}
.review-side-metric-card.wide {
grid-column: 1 / -1;
min-height: 104px;
}
.review-side-metric-card.invalid {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(254, 242, 242, 0.72);
@@ -2038,6 +2143,14 @@
font-weight: 700;
}
.review-inline-textarea {
min-height: 82px;
padding: 9px 10px;
resize: vertical;
line-height: 1.55;
font-family: inherit;
}
.review-inline-input.invalid {
border-color: rgba(239, 68, 68, 0.4);
color: #b91c1c;
@@ -2225,16 +2338,6 @@
background: linear-gradient(180deg, rgba(255, 255, 255, 0.84) 0%, rgba(255, 249, 238, 0.8) 100%);
}
.review-side-risk-score {
color: #f97316;
font-size: 13px;
font-weight: 900;
}
.review-side-risk-score.empty {
color: #94a3b8;
}
.review-side-risk-summary {
margin: 0;
color: #334155;
@@ -2281,7 +2384,7 @@
font-size: 16px;
}
.review-side-risk-item.warning .review-side-risk-icon {
.review-side-risk-item.medium .review-side-risk-icon {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
@@ -2291,6 +2394,11 @@
color: #dc2626;
}
.review-side-risk-item.low .review-side-risk-icon {
background: rgba(14, 165, 233, 0.12);
color: #0284c7;
}
.review-side-risk-copy {
min-width: 0;
display: grid;
@@ -4201,93 +4309,6 @@
flex: 1 1 168px;
}
.review-risk-detail-modal {
width: min(560px, calc(100vw - 40px));
max-height: min(760px, calc(100vh - 48px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border-radius: 24px;
border: 1px solid #e7eef6;
background:
radial-gradient(circle at top right, rgba(245, 158, 11, 0.10), transparent 28%),
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
box-shadow:
0 24px 80px rgba(15, 23, 42, 0.22),
0 2px 12px rgba(15, 23, 42, 0.08);
}
.review-risk-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 18px;
border-bottom: 1px solid #eef2f7;
}
.review-risk-detail-head h3 {
margin: 12px 0 0;
color: #0f172a;
font-size: 21px;
font-weight: 900;
line-height: 1.35;
}
.review-risk-detail-body {
min-height: 0;
display: grid;
gap: 14px;
padding: 18px 24px 24px;
overflow-y: auto;
}
.review-risk-detail-level {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 11px;
border-radius: 999px;
background: rgba(14, 165, 233, 0.12);
color: #0284c7;
font-size: 12px;
font-weight: 900;
}
.review-risk-detail-level.warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.review-risk-detail-level.high {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.review-risk-detail-section {
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
}
.review-risk-detail-section strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.review-risk-detail-section p {
margin: 0;
color: #475569;
font-size: 13px;
line-height: 1.7;
}
.review-edit-modal {
max-height: min(860px, calc(100vh - 48px));
display: grid;
@@ -4723,6 +4744,10 @@
min-height: 32px;
}
.travel-calculator-form {
grid-template-columns: 1fr;
}
.dialog-toolbar {
padding: 16px 16px 12px;
}

View File

@@ -21,7 +21,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
])
const REIMBURSEMENT_PROGRESS_LABELS = [
'保存草稿',
'创建单据',
'待提交',
'AI预审',
'直属领导审批',
@@ -270,6 +270,21 @@ function normalizeText(value) {
return String(value || '').trim()
}
function isEmailLike(value) {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
}
function resolveDisplayName(...values) {
for (const value of values) {
const normalized = normalizeText(value)
if (normalized && !isEmailLike(normalized)) {
return normalized
}
}
return ''
}
function getRiskFlags(claim) {
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
}
@@ -344,7 +359,7 @@ function buildCompletedStepMeta(claim, label) {
const stepLabel = normalizeText(label)
const employeeName = normalizeText(claim?.employee_name) || '申请人'
if (stepLabel === '保存草稿') {
if (stepLabel === '创建单据') {
const createdAt = formatDateTime(claim?.created_at)
return buildProgressStepMeta(`${employeeName}创建`, createdAt)
}
@@ -362,7 +377,12 @@ function buildCompletedStepMeta(claim, label) {
if (stepLabel === '直属领导审批' || stepLabel === '财务审批') {
const approvalEvent = findApprovalEventForStep(claim, stepLabel)
if (approvalEvent) {
const operator = normalizeText(approvalEvent.operator) || (stepLabel === '财务审批' ? '财务' : '审批人')
const operator = resolveDisplayName(
approvalEvent.operator,
approvalEvent.operator_name,
approvalEvent.operatorName,
stepLabel === '直属领导审批' ? claim?.manager_name : ''
) || (stepLabel === '财务审批' ? '财务' : '直属领导')
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
}
@@ -383,7 +403,7 @@ function buildCompletedStepMeta(claim, label) {
function resolveCurrentStepStartedAt(claim, label) {
const stepLabel = normalizeText(label)
if (stepLabel === '保存草稿') {
if (stepLabel === '创建单据') {
return claim?.created_at
}
if (stepLabel === '待提交') {
@@ -539,7 +559,7 @@ export function mapExpenseClaimToRequest(claim) {
employeeName: String(claim?.employee_name || '').trim() || '待补充',
employeePosition: String(claim?.employee_position || '').trim(),
employeeGrade: String(claim?.employee_grade || '').trim(),
managerName: String(claim?.manager_name || '').trim(),
managerName: resolveDisplayName(claim?.manager_name),
roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [],
entity: '',
typeCode,

View File

@@ -12,6 +12,13 @@ export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
}
export function calculateTravelReimbursement(payload = {}) {
return apiRequest('/reimbursements/travel-calculator', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function createExpenseClaimItem(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items`, {
method: 'POST',

View File

@@ -19,8 +19,9 @@ const VIEW_ROLE_RULES = {
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive'])
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
function normalizedRoleCodes(user) {
if (!user) {
@@ -60,6 +61,14 @@ export function canReturnExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
export function canApproveLeaderExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false

View File

@@ -1,5 +1,9 @@
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { canManageExpenseClaims } from './accessControl.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
isFinanceUser
} from './accessControl.js'
export function canProcessApprovalRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
@@ -14,12 +18,18 @@ export function canProcessApprovalRequest(request, currentUser) {
return true
}
return (
if (isFinanceUser(currentUser) && node.includes('财务')) {
return true
}
const isLeaderApprovalNode = (
node.includes('直属领导')
|| node.includes('领导审批')
|| node.includes('部门负责人')
|| node.includes('负责人审批')
)
return canApproveLeaderExpenseClaims(currentUser) && isLeaderApprovalNode
}
export function listPendingApprovalRequests(claimsPayload, currentUser) {

View File

@@ -181,6 +181,21 @@ function normalizeRoleLabels(value) {
return text ? [text] : []
}
function isEmailLike(value) {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(value || '').trim())
}
function resolveDisplayName(...values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized && !isEmailLike(normalized)) {
return normalized
}
}
return ''
}
export function normalizeRequestForUi(request) {
if (!request) {
return null
@@ -255,7 +270,12 @@ export function normalizeRequestForUi(request) {
String(request.profilePosition || request.employeePosition || request.employee_position || request.position || '').trim()
|| '待补充',
profileGrade: String(request.profileGrade || request.employeeGrade || request.employee_grade || request.grade || '').trim() || '待补充',
profileManager: String(request.profileManager || request.managerName || request.manager_name || request.manager || '').trim() || '待补充',
profileManager: resolveDisplayName(
request.profileManager,
request.managerName,
request.manager_name,
request.manager
) || '待补充',
roleLabels,
profileAvatar:
String(request.person || request.applicant || request.employeeName || '申').trim().slice(0, 1) || '申'

View File

@@ -121,7 +121,14 @@
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
<span
v-for="item in message.meta"
:key="item"
class="message-meta-chip"
:class="message.metaTone"
>
{{ item }}
</span>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
@@ -548,6 +555,72 @@
</div>
</div>
</div>
<div class="travel-calculator-anchor">
<button
type="button"
class="tool-btn composer-side-btn travel-calculator-trigger"
:class="{ active: travelCalculatorOpen }"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="差旅计算器"
title="差旅计算器"
:aria-expanded="travelCalculatorOpen"
@click.stop="toggleTravelCalculator"
>
<i class="mdi mdi-calculator"></i>
</button>
<div
v-if="travelCalculatorOpen"
class="travel-calculator-popover"
role="dialog"
aria-label="差旅计算器"
@click.stop
>
<div class="travel-calculator-mini-head">
<strong>差旅计算器</strong>
<span>按规则中心差旅表测算</span>
</div>
<div class="travel-calculator-form">
<label class="travel-calculator-field">
<span>实际天数</span>
<input
v-model="travelCalculatorForm.days"
type="number"
min="1"
step="1"
inputmode="numeric"
:disabled="travelCalculatorBusy"
@keydown.enter.prevent="submitTravelCalculator"
/>
</label>
<label class="travel-calculator-field">
<span>出差地点</span>
<input
v-model="travelCalculatorForm.location"
type="text"
placeholder="例如:北京、成都"
:disabled="travelCalculatorBusy"
@keydown.enter.prevent="submitTravelCalculator"
/>
</label>
</div>
<p v-if="travelCalculatorError" class="travel-calculator-error">
{{ travelCalculatorError }}
</p>
<div class="composer-date-popover-actions">
<button type="button" class="composer-date-cancel-btn" :disabled="travelCalculatorBusy" @click="closeTravelCalculator">
取消
</button>
<button
type="button"
class="composer-date-apply-btn"
:disabled="!travelCalculatorCanSubmit"
@click="submitTravelCalculator"
>
{{ travelCalculatorBusy ? '计算中...' : 'AI计算' }}
</button>
</div>
</div>
</div>
</div>
<div class="composer-shell">
@@ -783,7 +856,8 @@
:class="{
editable: item.editor,
editing: reviewInlineEditorKey === item.key,
invalid: Boolean(reviewInlineErrors[item.key])
invalid: Boolean(reviewInlineErrors[item.key]),
wide: item.wide
}"
@click="openInlineReviewEditor(item.key)"
>
@@ -831,6 +905,19 @@
@keydown.enter.prevent="commitInlineReviewEditor"
/>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'textarea'">
<textarea
v-model="reviewInlineForm[item.modelKey]"
class="review-inline-input review-inline-textarea"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
:placeholder="item.placeholder"
rows="3"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.stop
></textarea>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'select'">
<div class="review-inline-select-list" @click.stop>
<button
@@ -1091,12 +1178,9 @@
<section class="review-side-card review-side-risk-card">
<div class="review-side-head">
<div class="review-side-head-copy">
<strong>合规提 / 风险评分</strong>
<p>结合本体附件要求和识别结果集中查看当前票据风险</p>
<strong>差旅合规提</strong>
<p>结合票据识别结果与差旅规则逐项查看需要处理的风险</p>
</div>
<span class="review-side-risk-score" :class="{ empty: reviewRiskScore === null }">
{{ reviewRiskScore === null ? '无' : `${reviewRiskScore}/100` }}
</span>
</div>
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
@@ -1106,9 +1190,9 @@
type="button"
class="review-side-risk-item"
:class="item.level"
@click="openReviewRiskDetail(item)"
@click="appendReviewRiskBriefToConversation(item)"
>
<span class="review-side-risk-icon">
<span class="review-side-risk-icon" :title="item.levelLabel">
<i :class="item.icon"></i>
</span>
<span class="review-side-risk-copy">
@@ -1116,7 +1200,6 @@
<p>{{ item.summary }}</p>
</span>
<span class="review-side-risk-meta">
{{ item.levelLabel }}
<i class="mdi mdi-chevron-right"></i>
</span>
</button>
@@ -1125,8 +1208,8 @@
<span class="review-side-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
</span>
<strong>暂无风险评分</strong>
<p>当前版本还没有返回结构化风险评分结果这里先不展示虚拟分数</p>
<strong>暂无风险提示</strong>
<p>当前没有需要额外处理的结构化风险点</p>
</div>
</section>
</template>
@@ -1222,41 +1305,6 @@
@confirm="confirmCancelReview"
/>
<Transition name="assistant-modal">
<div v-if="reviewRiskDetailDialog.open" class="assistant-overlay review-overlay">
<section class="review-risk-detail-modal">
<header class="review-risk-detail-head">
<div>
<span class="assistant-badge warning">{{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }}</span>
<h3>{{ reviewRiskDetailDialog.item?.title || '风险提示' }}</h3>
</div>
<button class="close-btn" type="button" aria-label="关闭风险说明" @click="closeReviewRiskDetail">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="review-risk-detail-body">
<div class="review-risk-detail-level" :class="reviewRiskDetailDialog.item?.level">
<i :class="reviewRiskDetailDialog.item?.icon || 'mdi mdi-information-outline'"></i>
<span>{{ reviewRiskDetailDialog.item?.levelLabel || '提示' }}</span>
</div>
<article class="review-risk-detail-section">
<strong>提示情况</strong>
<p>{{ reviewRiskDetailDialog.item?.summary }}</p>
</article>
<article class="review-risk-detail-section">
<strong>详细解释</strong>
<p>{{ reviewRiskDetailDialog.item?.detail }}</p>
</article>
<article class="review-risk-detail-section">
<strong>处理建议</strong>
<p>{{ reviewRiskDetailDialog.item?.suggestion }}</p>
</article>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">

View File

@@ -375,15 +375,15 @@
</article>
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
<h3>领导意见</h3>
<h3>{{ approvalOpinionTitle }}</h3>
<textarea
v-model="leaderOpinion"
maxlength="500"
placeholder="请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。"
aria-label="领导意见"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
<div class="leader-opinion-meta">
<span>审批通过后将流转至财务审批</span>
<span>{{ approvalOpinionHint }}</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</article>
@@ -620,10 +620,10 @@
<ConfirmDialog
:open="approveConfirmDialogOpen"
badge="领导审批"
:badge="approvalConfirmBadge"
badge-tone="info"
:title="`确认通过 ${request.id} 吗?`"
description="确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。"
:description="approvalConfirmDescription"
cancel-text="返回核对"
confirm-text="确认通过"
busy-text="通过中..."
@@ -644,10 +644,10 @@
</div>
<div class="submit-confirm-row">
<span>下一节点</span>
<strong>财务审批</strong>
<strong>{{ approvalNextStage }}</strong>
</div>
<div class="submit-confirm-row">
<span>领导意见</span>
<span>{{ approvalOpinionTitle }}</span>
<strong>{{ leaderOpinion.trim() || '未填写' }}</strong>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import {
TRANSPORT_KEYWORD_PATTERN
} from '../../utils/reimbursementTextInference.js'
import {
calculateTravelReimbursement,
fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta,
@@ -55,15 +56,15 @@ const REVIEW_RISK_LEVEL_META = {
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
warning: {
label: '需关注',
medium: {
label: '中风险',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
low: {
label: '低风险',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
}
}
@@ -310,6 +311,7 @@ const FLOW_MISSING_SLOT_LABELS = {
participants: '参与人员',
attachments: '票据附件'
}
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
let messageSeed = 0
function nowTime() {
@@ -1317,6 +1319,7 @@ function createEmptyInlineReviewState() {
return {
occurred_date: '',
amount: '',
transport_type: '',
scene_label: '',
reason_value: '',
customer_name: '',
@@ -1330,6 +1333,67 @@ function createEmptyInlineReviewState() {
}
}
function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const expenseType = resolveExpenseTypeCode(
inlineState?.expense_type ||
buildReviewSlotMap(reviewPayload).expense_type?.normalized_value ||
buildReviewSlotMap(reviewPayload).expense_type?.value ||
''
)
if (['travel', 'hotel', 'transport'].includes(expenseType)) {
return true
}
return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
return (
['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) ||
['travel', 'hotel', 'transport'].includes(suggestedType)
)
})
}
function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const labels = []
const appendLabel = (label) => {
if (label && !labels.includes(label)) {
labels.push(label)
}
}
for (const item of documents) {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const text = [
item?.filename,
item?.summary,
item?.scene_label,
item?.suggested_expense_type,
...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : [])
].join(' ')
const compact = text.replace(/\s+/g, '')
if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) {
appendLabel('飞机')
} else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) {
appendLabel('火车/高铁')
} else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) {
appendLabel('打车/网约车')
}
}
const fallback = String(fallbackText || '').replace(/\s+/g, '')
if (!labels.length) {
if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机')
if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁')
if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车')
}
return labels.join('、')
}
function buildClientTimeContext() {
const now = new Date()
const locale =
@@ -1434,7 +1498,11 @@ function resolveReviewMissingSlotCards(reviewPayload) {
}
function resolveReviewRiskBriefs(reviewPayload) {
return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => {
const title = String(item?.title || '').trim()
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
})
}
function formatConfidenceLabel(value) {
@@ -1792,7 +1860,7 @@ function buildReviewAlertChips(reviewPayload) {
chips.push({
key: item.key,
label: buildReviewAlertLabel(item.key, expenseTypeLabel),
tone: item.key === 'attachments' ? 'danger' : 'warning'
tone: 'warning'
})
}
@@ -1830,7 +1898,7 @@ function buildReviewTodoItems(reviewPayload) {
title: config.title || item.label,
hint: item.hint || config.hint || `请补充${item.label}`,
status: config.status || '待补充',
tone: item.key === 'attachments' ? 'danger' : 'warning'
tone: 'warning'
}
})
}
@@ -2075,6 +2143,9 @@ function buildInlineReviewState(reviewPayload) {
editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
).trim()
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
const transportType = String(
editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue)
).trim()
return {
occurred_date: String(
@@ -2083,6 +2154,7 @@ function buildInlineReviewState(reviewPayload) {
amount: normalizeAmountValue(
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
),
transport_type: transportType,
scene_label: sceneLabel,
reason_value:
sceneLabel === REVIEW_SCENE_OTHER_OPTION
@@ -2129,6 +2201,56 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
: totalAttachmentCount > 0
? `已上传 ${totalAttachmentCount}`
: buildReviewAttachmentStatus(reviewPayload)
if (isTravelReviewPayload(reviewPayload, inlineState)) {
return [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'transport_type',
label: '交通类型',
value: String(inlineState.transport_type || '').trim() || '待确认',
icon: 'mdi mdi-train-car',
editor: 'text',
modelKey: 'transport_type',
placeholder: '例如 火车/高铁、飞机'
},
{
key: 'hotel_name',
label: '酒店名称',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-bed-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店名称'
},
{
key: 'travel_purpose',
label: '出差事宜',
value: String(inlineState.reason_value || '').trim() || '待补充',
icon: 'mdi mdi-briefcase-edit-outline',
editor: 'textarea',
modelKey: 'reason_value',
placeholder: '请填写本次出差的具体工作内容或业务意图',
wide: true
}
]
}
const cards = [
{
key: 'occurred_date',
@@ -2319,14 +2441,6 @@ function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInli
)
}
function buildReviewRiskScore(reviewPayload) {
const score = Number(reviewPayload?.risk_score)
if (!Number.isFinite(score) || score <= 0) {
return null
}
return Math.max(0, Math.min(100, Math.round(score)))
}
function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
if (slotKey === 'customer_name') {
return expenseTypeLabel === '业务招待费'
@@ -2353,17 +2467,30 @@ function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
function buildReviewRiskSummary(reviewPayload) {
if (resolveReviewRiskBriefs(reviewPayload).length) {
return '当前识别到了合规提醒,提交前建议逐项核对。'
return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。'
}
return '当前版本暂未生成风险评分结果。'
return '当前没有需要额外处理的结构化风险点。'
}
function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'medium') return 'warning'
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
return 'info'
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low'
if (normalized === 'high') return normalized
return 'low'
}
function normalizeReviewRiskTitle(title, fallbackTitle) {
const normalized = String(title || '').trim()
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
if (!normalized) return fallback
const cleaned = normalized
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
.replace(/(高风险|中风险|低风险)/g, '')
.replace(/^[:\-—\s]+|[:\-—\s]+$/g, '')
.trim()
return cleaned || fallback
}
function buildReviewRiskItems(reviewPayload) {
@@ -2374,9 +2501,9 @@ function buildReviewRiskItems(reviewPayload) {
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = title || fallbackTitle
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
@@ -2389,12 +2516,30 @@ function buildReviewRiskItems(reviewPayload) {
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
sourceLabel: meta.label,
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
.slice(0, 6)
}
function buildReviewRiskConversationText(item) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const lines = [`${title}`]
if (summary) {
lines.push('', `风险点:${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `修改建议:${suggestion}`)
}
return lines.join('\n')
}
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
@@ -2489,6 +2634,7 @@ function normalizeInlineReviewComparableState(state) {
return {
occurred_date: String(source.occurred_date || '').trim(),
amount: String(source.amount || '').trim(),
transport_type: String(source.transport_type || '').trim(),
scene_label: String(source.scene_label || '').trim(),
reason_value: String(source.reason_value || '').trim(),
customer_name: String(source.customer_name || '').trim(),
@@ -2512,6 +2658,9 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
if (base.amount !== next.amount) {
lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`)
}
if (base.transport_type !== next.transport_type) {
lines.push(`交通类型 ${next.transport_type || '待确认'}`)
}
if (base.scene_label !== next.scene_label) {
lines.push(`场景 ${next.scene_label || '待补充'}`)
}
@@ -2543,6 +2692,7 @@ function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = [])
const fieldConfigs = [
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
{ key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' },
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
@@ -2611,6 +2761,7 @@ function mergeInlineReviewFields(baseFields, inlineState) {
const merged = cloneReviewEditFields(baseFields)
const updateMap = {
expense_type: inlineState.expense_type,
transport_type: inlineState.transport_type,
occurred_date: inlineState.occurred_date,
amount: inlineState.amount,
customer_name: inlineState.customer_name,
@@ -2699,7 +2850,7 @@ function buildReviewRiskHint(reviewPayload) {
if (!riskBriefs.length) {
return ''
}
return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。'
return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。'
}
function buildReviewActionHint(reviewPayload) {
@@ -2839,6 +2990,14 @@ export default {
const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([])
const composerBusinessTimeDraftTouched = ref(false)
const travelCalculatorOpen = ref(false)
const travelCalculatorBusy = ref(false)
const travelCalculatorError = ref('')
const travelCalculatorResult = ref(null)
const travelCalculatorForm = ref({
days: '1',
location: ''
})
const attachedFiles = ref([])
const composerFilesExpanded = ref(false)
const submitting = ref(false)
@@ -2882,10 +3041,6 @@ export default {
const activeReviewDocumentIndex = ref(0)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const insightPanelCollapsed = ref(false)
const reviewRiskDetailDialog = ref({
open: false,
item: null
})
const documentPreviewDialog = ref({
open: false,
filename: '',
@@ -2921,6 +3076,11 @@ export default {
&& composerRangeStartDate.value <= composerRangeEndDate.value
)
})
const travelCalculatorCanSubmit = computed(() =>
!travelCalculatorBusy.value
&& Number(travelCalculatorForm.value.days) >= 1
&& Boolean(String(travelCalculatorForm.value.location || '').trim())
)
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const completedFlowStepCount = computed(
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
@@ -3040,10 +3200,9 @@ export default {
).length > 0
)
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value))
const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value))
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
@@ -3301,7 +3460,9 @@ export default {
activeReviewDocumentIndex.value = nextDocumentDrafts.length
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
: 0
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
? REVIEW_DRAWER_MODE_RISK
: REVIEW_DRAWER_MODE_REVIEW
reviewInlinePendingFiles.value = []
reviewInlineEditorKey.value = ''
reviewInlineErrors.value = {}
@@ -3975,6 +4136,9 @@ export default {
function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value
if (composerDatePickerOpen.value) {
travelCalculatorOpen.value = false
}
}
function closeComposerDatePicker() {
@@ -3998,14 +4162,22 @@ export default {
}
function handleComposerDatePickerOutside(event) {
if (!composerDatePickerOpen.value) {
if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
return
}
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
return
}
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
return
}
if (composerDatePickerOpen.value) {
composerDatePickerOpen.value = false
}
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
travelCalculatorOpen.value = false
}
}
async function applyComposerDateSelection() {
if (!composerCanApplyDateSelection.value) {
@@ -4026,6 +4198,142 @@ export default {
composerTextareaRef.value?.focus()
}
function resolveTravelCalculatorInitialDays() {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return 1
}
const startDate = businessTimeContext.start_date
const endDate = businessTimeContext.end_date || startDate
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return 1
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 1
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function resolveTravelCalculatorInitialLocation() {
const slotMap = buildReviewSlotMap(activeReviewPayload.value)
const candidates = [
reviewInlineForm.value.location,
slotMap.business_location?.normalized_value,
slotMap.business_location?.value,
slotMap.location?.normalized_value,
slotMap.location?.value,
currentUser.value?.location
]
return String(candidates.find((item) => String(item || '').trim()) || '').trim()
}
function openTravelCalculator() {
closeComposerDatePicker()
travelCalculatorError.value = ''
travelCalculatorResult.value = null
travelCalculatorForm.value = {
days: String(resolveTravelCalculatorInitialDays()),
location: resolveTravelCalculatorInitialLocation()
}
travelCalculatorOpen.value = true
}
function toggleTravelCalculator() {
if (travelCalculatorOpen.value) {
closeTravelCalculator()
return
}
openTravelCalculator()
}
function closeTravelCalculator() {
if (travelCalculatorBusy.value) {
return
}
travelCalculatorOpen.value = false
}
function formatTravelCalculatorMoney(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return String(value || '0')
}
return amount.toFixed(2)
}
function buildTravelCalculatorResultText(result) {
const days = Number(result?.days) || 1
const location = String(result?.location || '').trim() || '未填写地点'
const matchedCity = String(result?.matched_city || location).trim()
const grade = String(result?.grade || '').trim() || '当前职级'
const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位'
const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域'
const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则'
const ruleVersion = String(result?.rule_version || '').trim()
const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate)
const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount)
const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate)
const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate)
const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate)
const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount)
const totalAmount = formatTravelCalculatorMoney(result?.total_amount)
const ruleVersionText = ruleVersion ? `${ruleVersion}` : ''
const user = currentUser.value || {}
const displayName = String(user.name || user.display_name || user.username || '').trim()
const greeting = displayName ? `您好,${displayName}` : '您好,'
return [
`${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`,
'',
`**参考可报销合计:${totalAmount} 元**`,
'',
'| 项目 | 标准口径 | 天数 | 小计 |',
'| --- | --- | ---: | ---: |',
`| 住宿费 | ${matchedCity} / ${grade}${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`,
`| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`,
'',
'**计算过程**',
`1. 住宿费:${hotelRate} × ${days} = ${hotelAmount}`,
`2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount}`,
`3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount}`,
'',
`**规则依据**${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`,
'',
'这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。'
].join('\n')
}
async function submitTravelCalculator() {
if (!travelCalculatorCanSubmit.value) {
travelCalculatorError.value = '请填写出差天数和地点后再计算。'
return
}
travelCalculatorBusy.value = true
travelCalculatorError.value = ''
try {
const user = currentUser.value || {}
const payload = await calculateTravelReimbursement({
days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1),
location: String(travelCalculatorForm.value.location || '').trim(),
grade: String(user.grade || '').trim()
})
travelCalculatorResult.value = payload
messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], {
meta: ['差旅计算器'],
metaTone: 'low'
}))
travelCalculatorOpen.value = false
nextTick(scrollToBottom)
} catch (error) {
travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。'
} finally {
travelCalculatorBusy.value = false
}
}
function rememberFilePreviews(filePreviews) {
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
}
@@ -4378,6 +4686,7 @@ export default {
...reviewInlineForm.value,
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
amount: String(reviewInlineForm.value.amount || '').trim(),
transport_type: String(reviewInlineForm.value.transport_type || '').trim(),
customer_name: String(reviewInlineForm.value.customer_name || '').trim(),
location: String(reviewInlineForm.value.location || '').trim(),
merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(),
@@ -4473,19 +4782,13 @@ export default {
})
}
function openReviewRiskDetail(item) {
function appendReviewRiskBriefToConversation(item) {
if (!item) return
reviewRiskDetailDialog.value = {
open: true,
item
}
}
function closeReviewRiskDetail() {
reviewRiskDetailDialog.value = {
...reviewRiskDetailDialog.value,
open: false
}
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low'
}))
nextTick(scrollToBottom)
}
function goReviewDocument(direction) {
@@ -5267,11 +5570,9 @@ export default {
REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible,
reviewPanelConfidence,
reviewRiskScore,
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewRiskDetailDialog,
recognizedNarratives,
reviewRecognitionNotes,
reviewDocumentSummaries,
@@ -5281,6 +5582,12 @@ export default {
reviewCancelDialogOpen,
reviewEditDialogOpen,
uploadDecisionDialogOpen,
travelCalculatorOpen,
travelCalculatorBusy,
travelCalculatorError,
travelCalculatorResult,
travelCalculatorForm,
travelCalculatorCanSubmit,
deleteSessionDialogOpen,
reviewActionBusy,
deleteSessionBusy,
@@ -5331,6 +5638,10 @@ export default {
resolveFlowStepStatusLabel,
resolveFlowStepDetail,
toggleInsightPanel,
openTravelCalculator,
toggleTravelCalculator,
closeTravelCalculator,
submitTravelCalculator,
switchToReviewOverviewDrawer,
toggleReviewDocumentDrawer,
toggleReviewRiskDrawer,
@@ -5357,8 +5668,7 @@ export default {
selectReviewCategory,
selectReviewOtherCategory,
queryDraftByClaimNo,
openReviewRiskDetail,
closeReviewRiskDetail,
appendReviewRiskBriefToConversation,
goReviewDocument,
openActiveReviewDocumentPreview,
closeDocumentPreview,

View File

@@ -17,7 +17,12 @@ import {
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isFinanceUser
} from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
@@ -82,7 +87,7 @@ function resolveLocationDisplay(value, expenseType) {
function buildFallbackProgressSteps() {
return [
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
{ index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
@@ -486,20 +491,51 @@ export default {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
})
const showLeaderApprovalPanel = computed(() =>
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& isDirectManagerApprovalStage.value
&& Boolean(request.value.claimId)
)
const isFinanceApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '财务审批'
})
const canReturnRequest = computed(() =>
canReturnExpenseClaims(currentUser.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
showLeaderApprovalPanel.value
&& canReturnExpenseClaims(currentUser.value)
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& (
(
isDirectManagerApprovalStage.value
&& canApproveLeaderExpenseClaims(currentUser.value)
)
|| (
isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
)
)
)
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() =>
isFinanceApprovalStage.value
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
)
const approvalOpinionHint = computed(() =>
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
)
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() =>
isFinanceApprovalStage.value
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
)
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
const approvalSuccessToast = computed(() =>
isFinanceApprovalStage.value
? `${request.value.id} 已完成财务终审,进入归档入账。`
: `${request.value.id} 已审批通过,流转至财务审批。`
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
@@ -564,7 +600,7 @@ export default {
},
{
key: 'date',
label: '日期',
label: '单据申请日期',
value: request.value.applyTime || request.value.occurredDisplay,
icon: 'mdi mdi-calendar-month-outline',
valueClass: ''
@@ -1011,12 +1047,23 @@ export default {
try {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
expenseAttachmentMeta[item.id] = payload?.attachment || null
applyLocalExpenseItemPatch(item.id, {
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
}
applyLocalExpenseItemPatch(item.id, {
...itemPatch
})
if (editingExpenseId.value === item.id) {
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
expenseEditor.itemAmount = String(recognizedItemAmount)
}
}
emit('request-updated', { claimId: request.value.claimId })
@@ -1322,7 +1369,7 @@ export default {
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
toast('当前节点不支持审批通过。')
return
}
@@ -1345,7 +1392,7 @@ export default {
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
toast('当前节点不支持审批通过。')
approveConfirmDialogOpen.value = false
return
}
@@ -1357,7 +1404,7 @@ export default {
})
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
toast(approvalSuccessToast.value)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
@@ -1396,6 +1443,12 @@ export default {
attachmentPreviewUrl,
approveBusy,
approveConfirmDialogOpen,
approvalConfirmBadge,
approvalConfirmDescription,
approvalNextStage,
approvalOpinionHint,
approvalOpinionPlaceholder,
approvalOpinionTitle,
canDeleteRequest,
canManageCurrentClaim,
canNavigateAttachmentPreview,

View File

@@ -1,7 +1,12 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { canManageExpenseClaims, canReturnExpenseClaims } from '../src/utils/accessControl.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims
} from '../src/utils/accessControl.js'
import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js'
test('direct approvers can return claims without receiving delete permissions', () => {
const managerUser = { roleCodes: ['manager'] }
@@ -9,13 +14,42 @@ test('direct approvers can return claims without receiving delete permissions',
assert.equal(canReturnExpenseClaims(managerUser), true)
assert.equal(canReturnExpenseClaims(approverUser), true)
assert.equal(canApproveLeaderExpenseClaims(managerUser), true)
assert.equal(canApproveLeaderExpenseClaims(approverUser), true)
assert.equal(canManageExpenseClaims(managerUser), false)
assert.equal(canManageExpenseClaims(approverUser), false)
})
test('finance and executives can return and manage claims', () => {
test('finance can return and final approve, but only executives can manage delete permissions', () => {
assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true)
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), true)
assert.equal(canApproveLeaderExpenseClaims({ roleCodes: ['finance'] }), false)
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), false)
assert.equal(canReturnExpenseClaims({ roleCodes: ['executive'] }), true)
assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true)
})
test('finance approval inbox only processes finance-stage requests', () => {
const financeUser = { roleCodes: ['finance'], name: '财务' }
assert.equal(
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, financeUser),
true
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeUser),
false
)
})
test('users with both finance and manager roles can process both relevant stages', () => {
const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' }
assert.equal(
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, financeManagerUser),
true
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeManagerUser),
true
)
})

View File

@@ -39,7 +39,9 @@ test('progress steps show approval operator time and current stay duration', ()
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
const firstStep = request.progressSteps[0]
assert.equal(firstStep.label, '创建单据')
assert.equal(leaderStep.time, '李经理通过')
assert.match(leaderStep.detail, /2026-05-20/)
assert.match(leaderStep.title, /李经理审批通过/)
@@ -52,6 +54,96 @@ test('progress steps show approval operator time and current stay duration', ()
}
})
test('progress steps do not expose approver email when manager name is available', () => {
const originalNow = Date.now
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
try {
const request = mapExpenseClaimToRequest({
id: 'claim-email-operator',
claim_no: 'EXP-202605-003',
employee_name: '张三',
department_name: '市场部',
manager_name: '李经理',
expense_type: 'transport',
reason: '交通报销',
location: '上海',
amount: 88,
invoice_count: 1,
occurred_at: '2026-05-20T01:00:00.000Z',
submitted_at: '2026-05-20T02:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T03:30:00.000Z',
status: 'submitted',
approval_stage: '财务审批',
risk_flags_json: [
{
source: 'manual_approval',
operator: 'manager@example.com',
operator_username: 'manager@example.com',
previous_approval_stage: '直属领导审批',
next_approval_stage: '财务审批',
created_at: '2026-05-20T03:30:00.000Z'
}
],
items: []
})
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
assert.equal(leaderStep.time, '李经理通过')
assert.ok(!leaderStep.title.includes('manager@example.com'))
} finally {
Date.now = originalNow
}
})
test('completed finance approval marks finance and archive progress steps', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-finance-completed',
claim_no: 'EXP-202605-004',
employee_name: '张三',
department_name: '市场部',
expense_type: 'transport',
reason: '交通报销',
location: '上海',
amount: 88,
invoice_count: 1,
occurred_at: '2026-05-20T01:00:00.000Z',
submitted_at: '2026-05-20T02:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T04:00:00.000Z',
status: 'approved',
approval_stage: '归档入账',
risk_flags_json: [
{
source: 'manual_approval',
operator: '李经理',
previous_approval_stage: '直属领导审批',
next_approval_stage: '财务审批',
created_at: '2026-05-20T03:00:00.000Z'
},
{
source: 'finance_approval',
operator: '财务复核',
previous_approval_stage: '财务审批',
next_approval_stage: '归档入账',
created_at: '2026-05-20T04:00:00.000Z'
}
],
items: []
})
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
const archiveStep = request.progressSteps.find((step) => step.label === '归档入账')
assert.equal(request.workflowNode, '归档入账')
assert.equal(financeStep.time, '财务复核通过')
assert.match(financeStep.detail, /2026-05-20/)
assert.equal(archiveStep.time, '归档入账')
assert.equal(archiveStep.done, true)
})
test('current direct manager step shows how long the claim has stayed there', () => {
const originalNow = Date.now
Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime()

View File

@@ -31,3 +31,17 @@ test('normalizes returned backend claims as editable pending submission', () =>
assert.equal(request.approvalStatus, '待提交')
assert.equal(request.node, '待提交')
})
test('does not show manager email as direct supervisor name', () => {
const request = normalizeRequestForUi({
id: 'EXP-202605-003',
claim_id: 'claim-3',
status: 'submitted',
approval_stage: '直属领导审批',
expense_type: 'transport',
amount: 66,
manager_name: 'manager@example.com'
})
assert.equal(request.profileManager, '待补充')
})

View File

@@ -11,6 +11,10 @@ const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
)
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
@@ -35,3 +39,74 @@ test('review drawer tool buttons switch modes instead of toggling the active mod
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
})
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
assert.ok(riskItemsBlock, 'risk item builder should be present')
assert.doesNotMatch(createViewTemplate, /review-side-risk-score/)
assert.doesNotMatch(createViewTemplate, /风险评分/)
assert.doesNotMatch(createViewTemplate, /暂无风险评分/)
assert.doesNotMatch(createViewScript, /function buildReviewRiskScore/)
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
assert.match(createViewScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
assert.match(
createViewScript,
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
)
assert.match(
createViewTemplate,
/class="review-side-risk-item"[\s\S]*@click="appendReviewRiskBriefToConversation\(item\)"/
)
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
assert.match(createViewScript, /sourceLabel:\s*meta\.label/)
assert.doesNotMatch(createViewScript, /normalizedTitle\.includes\('AI预审'\)/)
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/)
assert.doesNotMatch(createViewScript, /reviewRiskDetailDialog/)
assert.doesNotMatch(createViewScript, /function openReviewRiskDetail/)
assert.match(
createViewScript,
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
})
test('review payload with risks opens risk drawer and travel overview uses travel-specific fields', () => {
assert.match(
createViewScript,
/reviewDrawerMode\.value = resolveReviewRiskBriefs\(payload\)\.length[\s\S]*\? REVIEW_DRAWER_MODE_RISK[\s\S]*: REVIEW_DRAWER_MODE_REVIEW/
)
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
assert.match(createViewTemplate, /wide: item\.wide/)
})
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
assert.doesNotMatch(createViewTemplate, /travel-calculator-modal/)
assert.doesNotMatch(createViewTemplate, /travelCalculatorResult\.total_amount/)
assert.match(createViewScript, /calculateTravelReimbursement/)
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
assert.match(createViewScript, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
assert.match(createViewScript, /根据您输入的地点和天数/)
assert.match(createViewScript, /匹配到您要出差的地区为/)
assert.match(createViewScript, /参考可报销合计/)
assert.match(createViewScript, /住宿费:\$\{hotelRate\} × \$\{days\} = \$\{hotelAmount\} 元/)
assert.match(createViewScript, /messages\.value\.push\(createMessage\('assistant', buildTravelCalculatorResultText\(payload\)/)
assert.match(reimbursementService, /export function calculateTravelReimbursement\(payload = \{\}\) \{[\s\S]*\/reimbursements\/travel-calculator/)
})

View File

@@ -44,14 +44,24 @@ test('approval-mode detail collects leader opinion and confirms approval before
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const canApproveRequest = computed/)
assert.match(detailScript, /canApproveLeaderExpenseClaims/)
assert.match(detailScript, /isFinanceApprovalStage/)
assert.match(detailScript, /approvalOpinionTitle/)
assert.match(detailScript, /approvalConfirmDescription/)
assert.match(detailScript, /approvalNextStage/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
assert.match(detailScript, /toast\(approvalSuccessToast\.value\)/)
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
assert.match(detailTemplate, /领导意见/)
assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
assert.match(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
assert.match(detailTemplate, /@click="handleApproveRequest"/)
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
assert.match(detailTemplate, /:badge="approvalConfirmBadge"/)
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /confirm-text="确认通过"/)
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')

View File

@@ -172,6 +172,13 @@ test('expense item upload remains limited to one receipt per detail row', () =>
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
})
test('expense item upload patches OCR amount into the visible detail row', () => {
assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/)
assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
assert.match(detailViewScript, /expenseEditor\.itemAmount = String\(recognizedItemAmount\)/)
})
test('return reason dialog is wired into approval and detail return actions', () => {
assert.match(returnReasonDialog, /missing_attachment/)
assert.match(returnReasonDialog, /invoice_mismatch/)

View File

@@ -52,3 +52,9 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.doesNotMatch(handleSubmit, /submitExpenseClaim/)
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
})
test('detail header and fallback progress use reimbursement wording', () => {
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
assert.match(detailViewScript, /label:\s*'创建单据'/)
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
})