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 && apt-get update &&
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends 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 && 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 && 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 && chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&

View File

@@ -20,9 +20,12 @@ from app.schemas.reimbursement import (
ExpenseClaimReturnPayload, ExpenseClaimReturnPayload,
ReimbursementCreate, ReimbursementCreate,
ReimbursementRead, ReimbursementRead,
TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse,
) )
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
router = APIRouter() router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]
@@ -50,6 +53,29 @@ def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> Reimbur
return ReimbursementService(db).create_reimbursement(payload) 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( @router.get(
"/claims", "/claims",
response_model=list[ExpenseClaimRead], response_model=list[ExpenseClaimRead],
@@ -463,8 +489,8 @@ def return_expense_claim(
@router.post( @router.post(
"/claims/{claim_id}/approve", "/claims/{claim_id}/approve",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,
summary="直属领导审批通过报销单", summary="审批通过报销单",
description="当前审批人确认报销信息无误后,将报销单从直属领导审批流转到财务审批。", description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账",
responses={ responses={
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse, "model": ErrorResponse,
@@ -497,7 +523,7 @@ def approve_expense_claim(
"/claims/{claim_id}", "/claims/{claim_id}",
response_model=ExpenseClaimActionResponse, response_model=ExpenseClaimActionResponse,
summary="删除报销单", summary="删除报销单",
description="普通用户仅可删除草稿待补充报销单;财务人员和高级管理人员可删除可见报销单。", description="申请人仅可删除自己的草稿待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限",
responses={ responses={
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse, "model": ErrorResponse,

View File

@@ -93,6 +93,10 @@ class ExpenseClaimItem(Base):
claim = relationship("ExpenseClaim", back_populates="items") 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): class AccountsReceivableRecord(Base):
__tablename__ = "accounts_receivable" __tablename__ = "accounts_receivable"

View File

@@ -41,6 +41,7 @@ class ExpenseClaimItemRead(BaseModel):
item_location: str item_location: str
item_amount: Decimal item_amount: Decimal
invoice_id: str | None invoice_id: str | None
is_system_generated: bool = False
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -157,11 +158,41 @@ class ExpenseClaimApprovalPayload(BaseModel):
opinion: str | None = Field(default=None, max_length=500) 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): class ExpenseClaimAttachmentActionResponse(BaseModel):
message: str message: str
claim_id: str claim_id: str
item_id: str item_id: str
invoice_id: str | None = None invoice_id: str | None = None
item_amount: Decimal | None = None
claim_amount: Decimal | None = None
attachment: ExpenseClaimAttachmentRead | None = None attachment: ExpenseClaimAttachmentRead | None = None

View File

@@ -39,6 +39,7 @@ class AgentConversationService:
normalized_id = str(conversation_id or "").strip() normalized_id = str(conversation_id or "").strip()
normalized_user_id = str(user_id or "").strip() or None normalized_user_id = str(user_id or "").strip() or None
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense" 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 conversation = self.get_conversation(normalized_id) if normalized_id else None
if conversation is not None and conversation.user_id != normalized_user_id: if conversation is not None and conversation.user_id != normalized_user_id:
normalized_id = "" normalized_id = ""
@@ -56,6 +57,7 @@ class AgentConversationService:
source=source, source=source,
entry_source=str(context_json.get("entry_source") or "").strip() or None, entry_source=str(context_json.get("entry_source") or "").strip() or None,
title=self._resolve_title(context_json), title=self._resolve_title(context_json),
draft_claim_id=incoming_draft_claim_id or None,
state_json=self._extract_state_json(context_json), state_json=self._extract_state_json(context_json),
) )
self.db.add(conversation) self.db.add(conversation)
@@ -69,6 +71,8 @@ class AgentConversationService:
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
if not conversation.title: if not conversation.title:
conversation.title = self._resolve_title(context_json) 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._merge_state_json(
conversation.state_json, conversation.state_json,
self._extract_state_json(context_json), self._extract_state_json(context_json),
@@ -354,6 +358,38 @@ class AgentConversationService:
self.db.commit() self.db.commit()
return len(conversations) 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( def delete_conversation(
self, self,
*, *,
@@ -478,11 +514,28 @@ class AgentConversationService:
continue continue
state_json[key] = value 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: if draft_claim_id:
state_json["draft_claim_id"] = draft_claim_id state_json["draft_claim_id"] = draft_claim_id
return state_json 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 @staticmethod
def _merge_state_json( def _merge_state_json(
current_state: dict[str, Any] | None, current_state: dict[str, Any] | None,

View File

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

View File

@@ -57,6 +57,7 @@ EXPENSE_TYPE_LABELS = {
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
CLAIM_DELETE_ROLE_CODES = {"executive"}
MAX_DRAFT_CLAIMS_PER_USER = 3 MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
LOCATION_REQUIRED_EXPENSE_TYPES = { LOCATION_REQUIRED_EXPENSE_TYPES = {
@@ -546,6 +547,11 @@ class ExpenseClaimService:
ocr_document = documents[0] ocr_document = documents[0]
ocr_status = "recognized" ocr_status = "recognized"
document_info = self._build_attachment_document_info(ocr_document) 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( requirement_check = self._build_attachment_requirement_check(
item=item, item=item,
document_info=document_info, document_info=document_info,
@@ -620,6 +626,8 @@ class ExpenseClaimService:
"claim_id": claim.id, "claim_id": claim.id,
"item_id": item.id, "item_id": item.id,
"invoice_id": item.invoice_id, "invoice_id": item.invoice_id,
"item_amount": item.item_amount,
"claim_amount": claim.amount,
"attachment": self._build_attachment_payload(item), "attachment": self._build_attachment_payload(item),
} }
@@ -747,6 +755,8 @@ class ExpenseClaimService:
before_json=before_json, before_json=before_json,
after_json=self._serialize_claim(claim), after_json=self._serialize_claim(claim),
) )
if str(claim.status or "").strip().lower() == "submitted":
self._delete_submitted_claim_assistant_sessions(claim.id)
return claim return claim
@@ -858,8 +868,10 @@ class ExpenseClaimService:
if claim is None: if claim is None:
return 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) 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) before_json = self._serialize_claim(claim)
resource_id = claim.id resource_id = claim.id
@@ -903,7 +915,7 @@ class ExpenseClaimService:
raise ValueError("已完成单据不允许退回。") raise ValueError("已完成单据不允许退回。")
before_json = self._serialize_claim(claim) 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_status = str(claim.status or "").strip()
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节" previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
previous_stage_key = self._normalize_return_stage_key(previous_stage) previous_stage_key = self._normalize_return_stage_key(previous_stage)
@@ -987,29 +999,43 @@ class ExpenseClaimService:
if claim is None: if claim is None:
return None return None
if not self._can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
normalized_status = str(claim.status or "").strip().lower() normalized_status = str(claim.status or "").strip().lower()
if normalized_status != "submitted": if normalized_status != "submitted":
raise ValueError("只有审批中的报销单可以审批通过。") raise ValueError("只有审批中的报销单可以审批通过。")
previous_stage = str(claim.approval_stage or "").strip() previous_stage = str(claim.approval_stage or "").strip()
if previous_stage != "直属领导审批": if previous_stage == "直属领导审批":
raise ValueError("当前节点不是直属领导审批,不能执行领导审批通过。") 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) before_json = self._serialize_claim(claim)
operator = current_user.name or current_user.username operator = self._resolve_current_user_display_name(current_user)
leader_opinion = str(opinion or "").strip() approval_opinion = str(opinion or "").strip()
next_stage = "财务审批"
approval_flag = { approval_flag = {
"source": "manual_approval", "source": approval_source,
"event_type": "expense_claim_approval", "event_type": event_type,
"approval_event_id": str(uuid.uuid4()), "approval_event_id": str(uuid.uuid4()),
"severity": "info", "severity": "info",
"label": "领导审批通过", "label": label,
"message": leader_opinion or f"{operator} 已审批通过,流转至{next_stage}", "message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"opinion": leader_opinion, "opinion": approval_opinion,
"operator": operator, "operator": operator,
"operator_username": current_user.username, "operator_username": current_user.username,
"operator_role_codes": [ "operator_role_codes": [
@@ -1024,7 +1050,7 @@ class ExpenseClaimService:
"created_at": datetime.now(UTC).isoformat(), "created_at": datetime.now(UTC).isoformat(),
} }
claim.status = "submitted" claim.status = next_status
claim.approval_stage = next_stage claim.approval_stage = next_stage
if claim.submitted_at is None: if claim.submitted_at is None:
claim.submitted_at = datetime.now(UTC) claim.submitted_at = datetime.now(UTC)
@@ -2216,6 +2242,79 @@ class ExpenseClaimService:
return {} return {}
return payload if isinstance(payload, dict) else {} 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( def _build_attachment_preview_meta(
self, self,
*, *,
@@ -2265,6 +2364,11 @@ class ExpenseClaimService:
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]: def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
file_path, media_type, filename = self._resolve_item_attachment_content(item) file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._read_attachment_meta(file_path) 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_storage_key = str(metadata.get("preview_storage_key") or "").strip()
preview_file_name = str(metadata.get("preview_file_name") or "").strip() preview_file_name = str(metadata.get("preview_file_name") or "").strip()
preview_media_type = str(metadata.get("preview_media_type") 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]: def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]:
file_path, media_type, filename = self._resolve_item_attachment_content(item) file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._read_attachment_meta(file_path) 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_value = metadata.get("uploaded_at")
uploaded_at = None uploaded_at = None
if isinstance(uploaded_at_value, str) and uploaded_at_value.strip(): if isinstance(uploaded_at_value, str) and uploaded_at_value.strip():
@@ -2466,6 +2575,27 @@ class ExpenseClaimService:
"fields": normalized_fields, "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( def _build_attachment_requirement_check(
self, self,
*, *,
@@ -2933,6 +3063,15 @@ class ExpenseClaimService:
if not self._is_editable_claim_status(claim.status): if not self._is_editable_claim_status(claim.status):
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。") 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]: def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
base_flags = list(claim.risk_flags_json or []) base_flags = list(claim.risk_flags_json or [])
attachment_flags = [ attachment_flags = [
@@ -4604,6 +4743,17 @@ class ExpenseClaimService:
} }
return bool(role_codes & PRIVILEGED_CLAIM_ROLE_CODES) 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: def _can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
if self._has_privileged_claim_access(current_user): if self._has_privileged_claim_access(current_user):
return True return True
@@ -4636,7 +4786,41 @@ class ExpenseClaimService:
return self._resolve_claim_manager_name(claim) == approver_name return self._resolve_claim_manager_name(claim) == approver_name
def _can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: 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 @staticmethod
def _normalize_role_codes(current_user: CurrentUserContext) -> set[str]: 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: def _resolve_claim_employee_for_backfill(self, claim: ExpenseClaim) -> Employee | None:
if claim.employee is not None: if claim.employee is not None:
employee = self.db.scalar( employee = self.db.scalar(
@@ -4850,8 +5072,14 @@ class ExpenseClaimService:
return conditions return conditions
def _apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: 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") 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) conditions = self._build_approval_claim_conditions(current_user)
if not conditions: if not conditions:

View File

@@ -6,12 +6,17 @@ from dataclasses import dataclass, field
from decimal import Decimal from decimal import Decimal
from typing import Any, Literal from typing import Any, Literal
from openpyxl import load_workbook
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetVersion 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) 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) band_labels: dict[str, str] = Field(default_factory=dict)
city_tiers: 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_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) transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
flight_classes: list[TravelClassConfig] = Field(default_factory=list) flight_classes: list[TravelClassConfig] = Field(default_factory=list)
train_classes: list[TravelClassConfig] = Field(default_factory=list) train_classes: list[TravelClassConfig] = Field(default_factory=list)
@@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService:
).all() ).all()
) )
if not assets: 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: for asset in assets:
version = self._get_current_version(asset) version = self._get_current_version(asset)
if version is None: if version is None:
continue 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( runtime_payload = self._extract_runtime_payload(
markdown_content=str(version.content or ""), markdown_content=str(version.content or ""),
config_json=asset.config_json, config_json=asset.config_json,
) )
if not isinstance(runtime_payload, dict): if not isinstance(runtime_payload, dict):
spreadsheet_assets.append((asset, version))
continue continue
self._apply_runtime_payload( self._apply_runtime_payload(
catalog, catalog,
@@ -594,6 +622,15 @@ class ExpenseRuleRuntimeService:
asset=asset, asset=asset,
version=version, 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 return catalog
@@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService:
) )
except ValidationError: except ValidationError:
return 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 base64
import json import json
import re
import shutil import shutil
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -27,6 +28,7 @@ class PreparedOcrInput:
page_index: int | None = None page_index: int | None = None
preview_kind: str = "" preview_kind: str = ""
preview_data_url: str = "" preview_data_url: str = ""
text_layer: str = ""
@dataclass(slots=True) @dataclass(slots=True)
@@ -38,6 +40,7 @@ class AggregatedOcrDocument:
model: str = "PP-OCRv5_mobile" model: str = "PP-OCRv5_mobile"
summary_fragments: list[str] = field(default_factory=list) summary_fragments: list[str] = field(default_factory=list)
text_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) score_values: list[float] = field(default_factory=list)
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
lines: list[OcrRecognizeLineRead] = field(default_factory=list) lines: list[OcrRecognizeLineRead] = field(default_factory=list)
@@ -112,12 +115,14 @@ class OcrService:
if suffix == ".pdf": if suffix == ".pdf":
try: try:
text_layer = self._extract_pdf_text_layer(temp_path)
prepared_inputs.extend( prepared_inputs.extend(
self._prepare_pdf_inputs( self._prepare_pdf_inputs(
pdf_path=temp_path, pdf_path=temp_path,
filename=normalized_name, filename=normalized_name,
media_type=resolved_media_type, media_type=resolved_media_type,
cleanup_paths=cleanup_paths, cleanup_paths=cleanup_paths,
text_layer=text_layer,
) )
) )
except RuntimeError as exc: except RuntimeError as exc:
@@ -261,6 +266,7 @@ class OcrService:
filename: str, filename: str,
media_type: str, media_type: str,
cleanup_paths: list[Path], cleanup_paths: list[Path],
text_layer: str = "",
) -> list[PreparedOcrInput]: ) -> list[PreparedOcrInput]:
output_dir = pdf_path.with_suffix("") output_dir = pdf_path.with_suffix("")
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
@@ -283,10 +289,33 @@ class OcrService:
page_index=page_index, page_index=page_index,
preview_kind="image" if page_index == 0 else "", preview_kind="image" if page_index == 0 else "",
preview_data_url=preview_data_url 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 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]: def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
prefix = output_dir / "page" prefix = output_dir / "page"
completed = subprocess.run( completed = subprocess.run(
@@ -367,6 +396,8 @@ class OcrService:
aggregated.preview_kind = descriptor.preview_kind aggregated.preview_kind = descriptor.preview_kind
if descriptor.preview_data_url and not aggregated.preview_data_url: if descriptor.preview_data_url and not aggregated.preview_data_url:
aggregated.preview_data_url = descriptor.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() page_summary = str(payload.get("summary", "") or "").strip()
if page_summary: if page_summary:
@@ -401,6 +432,20 @@ class OcrService:
aggregated = aggregated_by_source.get(source_key) aggregated = aggregated_by_source.get(source_key)
if aggregated is None: if aggregated is None:
first_descriptor = descriptors[0] 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( documents.append(
OcrRecognizeDocumentRead( OcrRecognizeDocumentRead(
filename=first_descriptor.filename, filename=first_descriptor.filename,
@@ -416,6 +461,13 @@ class OcrService:
return documents 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 @staticmethod
def _build_lines( def _build_lines(
items: list[dict], items: list[dict],
@@ -451,13 +503,26 @@ class OcrService:
return summary return summary
def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead: 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) 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( insight = self.document_intelligence_service.build_document_insight(
filename=aggregated.filename, filename=aggregated.filename,
summary=summary, summary=summary,
text=full_text, text=full_text,
preview_data_url=aggregated.preview_data_url, preview_data_url=preview_data_url,
) )
warnings = list(aggregated.warnings) warnings = list(aggregated.warnings)
for warning in insight.warnings: for warning in insight.warnings:
@@ -493,8 +558,8 @@ class OcrService:
) )
for field in insight.fields for field in insight.fields
], ],
preview_kind=aggregated.preview_kind, preview_kind=preview_kind,
preview_data_url=aggregated.preview_data_url, preview_data_url=preview_data_url,
warnings=warnings, warnings=warnings,
lines=sorted( lines=sorted(
aggregated.lines, 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 @staticmethod
def _cleanup_temp_paths(paths: list[Path]) -> None: def _cleanup_temp_paths(paths: list[Path]) -> None:
for path in reversed(paths): 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_assets import AgentAssetService
from app.services.agent_foundation import AgentFoundationService from app.services.agent_foundation import AgentFoundationService
from app.services.expense_claims import ExpenseClaimService 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.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.runtime_chat import RuntimeChatService from app.services.runtime_chat import RuntimeChatService
@@ -185,6 +186,7 @@ DOCUMENT_AMOUNT_PATTERN = re.compile(
r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
) )
DOCUMENT_CURRENCY_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 = { SOURCE_LABELS = {
"user_text": "用户描述", "user_text": "用户描述",
@@ -197,6 +199,8 @@ SOURCE_LABELS = {
"system": "系统判断", "system": "系统判断",
} }
DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ("历史报销画像", "用户画像", "制度注意事项", "制度注意")
SCENE_REQUIRED_SLOT_KEYS = { SCENE_REQUIRED_SLOT_KEYS = {
"hotel": {"merchant_name"}, "hotel": {"merchant_name"},
"meeting": {"location"}, "meeting": {"location"},
@@ -2193,8 +2197,8 @@ class UserAgentService:
for reason in self._resolve_submission_blocked_reasons(payload): for reason in self._resolve_submission_blocked_reasons(payload):
briefs.append( briefs.append(
UserAgentReviewRiskBrief( UserAgentReviewRiskBrief(
title="AI预审未通过", title="提交风险提示",
level="high", level=self._resolve_submission_blocked_risk_level(reason),
content=reason, content=reason,
detail=( 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 = self._resolve_employee_profile(payload)
employee_name = ( employee_name = (
str(employee.name).strip() str(employee.name).strip()
@@ -2211,7 +2223,10 @@ class UserAgentService:
else self._collect_entity_values(payload).get("employee_name") else self._collect_entity_values(payload).get("employee_name")
or str(payload.context_json.get("name") or "").strip() 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) since = datetime.now(UTC) - timedelta(days=90)
claim_identity_conditions = [ExpenseClaim.employee_name == employee_name] claim_identity_conditions = [ExpenseClaim.employee_name == employee_name]
if employee is not None: if employee is not None:
@@ -2228,25 +2243,6 @@ class UserAgentService:
stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since) stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since)
recent_claims = list(self.db.scalars(stmt).all()) recent_claims = list(self.db.scalars(stmt).all())
if recent_claims: 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( duplicate_count = sum(
1 1
for item in recent_claims 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) warning_count = sum(len(item.warnings) for item in document_cards)
if warning_count: if warning_count:
briefs.append( briefs.append(
@@ -2296,14 +2281,635 @@ class UserAgentService:
briefs.append( briefs.append(
UserAgentReviewRiskBrief( UserAgentReviewRiskBrief(
title="建议拆单", title="建议拆单",
level="high", level="warning",
content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。", content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。",
detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。", detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。",
suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。", 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 @staticmethod
def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]: 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( review_payload = UserAgentReviewPayload(
intent_summary="", intent_summary="",
body_message="", body_message="",
@@ -3460,7 +4074,18 @@ class UserAgentService:
evidence="来源于用户修改后的结构化表单。", 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: if merchant_value:
return self._build_slot_value( return self._build_slot_value(
value=merchant_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_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", "ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
"ingest_agent_run_id": "run_8b0ead1e3c734a53" "ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
}, },
{ {
"id": "a8f8465df08e455ebe133351721d49f8", "id": "a8f8465df08e455ebe133351721d49f8",
@@ -36,12 +36,12 @@
"uploaded_by": "admin", "uploaded_by": "admin",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "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_completed_at": "",
"ingest_document_name": "", "ingest_document_name": "",
"ingest_document_updated_at": "", "ingest_document_updated_at": "",
"ingest_document_sha256": "", "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 import pytest
from openpyxl import Workbook, load_workbook 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.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -24,11 +24,14 @@ from app.core.agent_enums import (
) )
from app.core.config import SERVER_DIR from app.core.config import SERVER_DIR
from app.db.base import Base 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 ( from app.schemas.agent_asset import (
AgentAssetCreate, AgentAssetCreate,
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetVersionCreate, AgentAssetVersionCreate,
) )
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, 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.audit import AuditLogService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
from app.services.settings import OnlyOfficeRuntimeConfig from app.services.settings import OnlyOfficeRuntimeConfig
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
@pytest.fixture(autouse=True) @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) 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: def test_agent_run_service_lists_seeded_trace_data() -> None:
with build_session() as db: with build_session() as db:
service = AgentRunService(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) 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: def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
engine = create_engine( engine = create_engine(
"sqlite+pysqlite:///:memory:", "sqlite+pysqlite:///:memory:",

View File

@@ -16,6 +16,7 @@ from app.models.organization import OrganizationUnit
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
from app.services.agent_conversations import AgentConversationService
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.ontology import SemanticOntologyService from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService 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"]) 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: def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-1", 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"} 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( current_user = CurrentUserContext(
username="finance@example.com", username="finance@example.com",
name="财务", name="财务",
@@ -1545,10 +1622,46 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
for flag in returned.risk_flags_json 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 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 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: def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="finance-returned@example.com", 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] claim.risk_flags_json = [return_flag]
db.add_all([manager, employee, claim]) db.add_all([manager, employee, claim])
db.commit() 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) 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" and flag.get("return_event_id") == "return-event-submit"
for flag in list(submitted.risk_flags_json or []) 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: 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 len(claims) == 1
assert claims[0].claim_no == "EXP-MGR-201" 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 any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields)
assert recognized.lines[0].page_index == 0 assert recognized.lines[0].page_index == 0
assert recognized.lines[1].page_index == 1 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.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.orchestrator import OrchestratorRequest from app.schemas.orchestrator import OrchestratorRequest
from app.services.agent_conversations import AgentConversationService
from app.services.orchestrator import OrchestratorService 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.status == "submitted"
assert claim.approval_stage == "直属领导审批" assert claim.approval_stage == "直属领导审批"
assert claim.submitted_at is not None 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( 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 response.status == "succeeded"
assert result["draft_payload"]["status"] == "draft" 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 "AI预审暂未通过" in result["answer"]
assert "所属部门未完善" in result["answer"] assert "所属部门未完善" in result["answer"]
assert "next_step" not in actions 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( assert any(
item["source"] == "manual_approval" item["source"] == "manual_approval"
and item["opinion"] == "情况属实,同意报销。" and item["opinion"] == "情况属实,同意报销。"
and item["operator"] == "李经理"
and item["next_approval_stage"] == "财务审批" and item["next_approval_stage"] == "财务审批"
for item in payload["risk_flags_json"] 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: 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 __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.db.base import Base 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.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.ontology import SemanticOntologyService
from app.services.user_agent import UserAgentService 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,") 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: def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

View File

@@ -813,6 +813,24 @@
color: #1d4ed8; 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, .risk-chip,
.message-risk-chip { .message-risk-chip {
background: #fff1f2; background: #fff1f2;
@@ -1262,6 +1280,10 @@
position: relative; position: relative;
} }
.travel-calculator-anchor {
position: relative;
}
.tool-btn.composer-side-btn.active { .tool-btn.composer-side-btn.active {
border-color: rgba(59, 130, 246, 0.42); border-color: rgba(59, 130, 246, 0.42);
background: rgba(239, 246, 255, 0.96); background: rgba(239, 246, 255, 0.96);
@@ -1286,6 +1308,84 @@
0 4px 12px rgba(15, 23, 42, 0.06); 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 { .composer-date-mode-tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); 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; 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 { .review-side-metric-card.invalid {
border-color: rgba(239, 68, 68, 0.34); border-color: rgba(239, 68, 68, 0.34);
background: rgba(254, 242, 242, 0.72); background: rgba(254, 242, 242, 0.72);
@@ -2038,6 +2143,14 @@
font-weight: 700; 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 { .review-inline-input.invalid {
border-color: rgba(239, 68, 68, 0.4); border-color: rgba(239, 68, 68, 0.4);
color: #b91c1c; color: #b91c1c;
@@ -2225,16 +2338,6 @@
background: linear-gradient(180deg, rgba(255, 255, 255, 0.84) 0%, rgba(255, 249, 238, 0.8) 100%); 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 { .review-side-risk-summary {
margin: 0; margin: 0;
color: #334155; color: #334155;
@@ -2281,7 +2384,7 @@
font-size: 16px; 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); background: rgba(245, 158, 11, 0.14);
color: #b45309; color: #b45309;
} }
@@ -2291,6 +2394,11 @@
color: #dc2626; color: #dc2626;
} }
.review-side-risk-item.low .review-side-risk-icon {
background: rgba(14, 165, 233, 0.12);
color: #0284c7;
}
.review-side-risk-copy { .review-side-risk-copy {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -4201,93 +4309,6 @@
flex: 1 1 168px; 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 { .review-edit-modal {
max-height: min(860px, calc(100vh - 48px)); max-height: min(860px, calc(100vh - 48px));
display: grid; display: grid;
@@ -4723,6 +4744,10 @@
min-height: 32px; min-height: 32px;
} }
.travel-calculator-form {
grid-template-columns: 1fr;
}
.dialog-toolbar { .dialog-toolbar {
padding: 16px 16px 12px; padding: 16px 16px 12px;
} }

View File

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

View File

@@ -12,6 +12,13 @@ export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`) 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 = {}) { export function createExpenseClaimItem(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items`, { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items`, {
method: 'POST', method: 'POST',

View File

@@ -19,8 +19,9 @@ const VIEW_ROLE_RULES = {
employees: ['manager'], employees: ['manager'],
settings: ['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_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
function normalizedRoleCodes(user) { function normalizedRoleCodes(user) {
if (!user) { if (!user) {
@@ -60,6 +61,14 @@ export function canReturnExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode)) 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) { export function canAccessAppView(user, viewId) {
if (!viewId || !user) { if (!viewId || !user) {
return false return false

View File

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

View File

@@ -181,6 +181,21 @@ function normalizeRoleLabels(value) {
return text ? [text] : [] 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) { export function normalizeRequestForUi(request) {
if (!request) { if (!request) {
return null return null
@@ -255,7 +270,12 @@ export function normalizeRequestForUi(request) {
String(request.profilePosition || request.employeePosition || request.employee_position || request.position || '').trim() String(request.profilePosition || request.employeePosition || request.employee_position || request.position || '').trim()
|| '待补充', || '待补充',
profileGrade: String(request.profileGrade || request.employeeGrade || request.employee_grade || request.grade || '').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, roleLabels,
profileAvatar: profileAvatar:
String(request.person || request.applicant || request.employeeName || '申').trim().slice(0, 1) || '申' String(request.person || request.applicant || request.employeeName || '申').trim().slice(0, 1) || '申'

View File

@@ -121,7 +121,14 @@
</div> </div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row"> <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>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block"> <div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
@@ -548,6 +555,72 @@
</div> </div>
</div> </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>
<div class="composer-shell"> <div class="composer-shell">
@@ -783,7 +856,8 @@
:class="{ :class="{
editable: item.editor, editable: item.editor,
editing: reviewInlineEditorKey === item.key, editing: reviewInlineEditorKey === item.key,
invalid: Boolean(reviewInlineErrors[item.key]) invalid: Boolean(reviewInlineErrors[item.key]),
wide: item.wide
}" }"
@click="openInlineReviewEditor(item.key)" @click="openInlineReviewEditor(item.key)"
> >
@@ -831,6 +905,19 @@
@keydown.enter.prevent="commitInlineReviewEditor" @keydown.enter.prevent="commitInlineReviewEditor"
/> />
</template> </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'"> <template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'select'">
<div class="review-inline-select-list" @click.stop> <div class="review-inline-select-list" @click.stop>
<button <button
@@ -1091,12 +1178,9 @@
<section class="review-side-card review-side-risk-card"> <section class="review-side-card review-side-risk-card">
<div class="review-side-head"> <div class="review-side-head">
<div class="review-side-head-copy"> <div class="review-side-head-copy">
<strong>合规提 / 风险评分</strong> <strong>差旅合规提</strong>
<p>结合本体附件要求和识别结果集中查看当前票据风险</p> <p>结合票据识别结果与差旅规则逐项查看需要处理的风险</p>
</div> </div>
<span class="review-side-risk-score" :class="{ empty: reviewRiskScore === null }">
{{ reviewRiskScore === null ? '无' : `${reviewRiskScore}/100` }}
</span>
</div> </div>
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p> <p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
<div v-if="reviewRiskItems.length" class="review-side-risk-list"> <div v-if="reviewRiskItems.length" class="review-side-risk-list">
@@ -1106,9 +1190,9 @@
type="button" type="button"
class="review-side-risk-item" class="review-side-risk-item"
:class="item.level" :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> <i :class="item.icon"></i>
</span> </span>
<span class="review-side-risk-copy"> <span class="review-side-risk-copy">
@@ -1116,7 +1200,6 @@
<p>{{ item.summary }}</p> <p>{{ item.summary }}</p>
</span> </span>
<span class="review-side-risk-meta"> <span class="review-side-risk-meta">
{{ item.levelLabel }}
<i class="mdi mdi-chevron-right"></i> <i class="mdi mdi-chevron-right"></i>
</span> </span>
</button> </button>
@@ -1125,8 +1208,8 @@
<span class="review-side-empty-icon"> <span class="review-side-empty-icon">
<i class="mdi mdi-shield-check-outline"></i> <i class="mdi mdi-shield-check-outline"></i>
</span> </span>
<strong>暂无风险评分</strong> <strong>暂无风险提示</strong>
<p>当前版本还没有返回结构化风险评分结果这里先不展示虚拟分数</p> <p>当前没有需要额外处理的结构化风险点</p>
</div> </div>
</section> </section>
</template> </template>
@@ -1222,41 +1305,6 @@
@confirm="confirmCancelReview" @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"> <Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay"> <div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal"> <section class="review-confirm-modal review-upload-decision-modal">

View File

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

View File

@@ -15,6 +15,7 @@ import {
TRANSPORT_KEYWORD_PATTERN TRANSPORT_KEYWORD_PATTERN
} from '../../utils/reimbursementTextInference.js' } from '../../utils/reimbursementTextInference.js'
import { import {
calculateTravelReimbursement,
fetchExpenseClaimAttachmentAsset, fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail, fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta, fetchExpenseClaimItemAttachmentMeta,
@@ -55,15 +56,15 @@ const REVIEW_RISK_LEVEL_META = {
icon: 'mdi mdi-alert-octagon-outline', icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。' suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
}, },
warning: { medium: {
label: '需关注', label: '中风险',
icon: 'mdi mdi-alert-circle-outline', icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。' suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
}, },
info: { low: {
label: '提示', label: '低风险',
icon: 'mdi mdi-information-outline', icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。' suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
} }
} }
@@ -310,6 +311,7 @@ const FLOW_MISSING_SLOT_LABELS = {
participants: '参与人员', participants: '参与人员',
attachments: '票据附件' attachments: '票据附件'
} }
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
let messageSeed = 0 let messageSeed = 0
function nowTime() { function nowTime() {
@@ -1317,6 +1319,7 @@ function createEmptyInlineReviewState() {
return { return {
occurred_date: '', occurred_date: '',
amount: '', amount: '',
transport_type: '',
scene_label: '', scene_label: '',
reason_value: '', reason_value: '',
customer_name: '', 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() { function buildClientTimeContext() {
const now = new Date() const now = new Date()
const locale = const locale =
@@ -1434,7 +1498,11 @@ function resolveReviewMissingSlotCards(reviewPayload) {
} }
function resolveReviewRiskBriefs(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) { function formatConfidenceLabel(value) {
@@ -1792,7 +1860,7 @@ function buildReviewAlertChips(reviewPayload) {
chips.push({ chips.push({
key: item.key, key: item.key,
label: buildReviewAlertLabel(item.key, expenseTypeLabel), 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, title: config.title || item.label,
hint: item.hint || config.hint || `请补充${item.label}`, hint: item.hint || config.hint || `请补充${item.label}`,
status: config.status || '待补充', 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 || '' editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
).trim() ).trim()
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType) const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
const transportType = String(
editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue)
).trim()
return { return {
occurred_date: String( occurred_date: String(
@@ -2083,6 +2154,7 @@ function buildInlineReviewState(reviewPayload) {
amount: normalizeAmountValue( amount: normalizeAmountValue(
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim() String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
), ),
transport_type: transportType,
scene_label: sceneLabel, scene_label: sceneLabel,
reason_value: reason_value:
sceneLabel === REVIEW_SCENE_OTHER_OPTION sceneLabel === REVIEW_SCENE_OTHER_OPTION
@@ -2129,6 +2201,56 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
: totalAttachmentCount > 0 : totalAttachmentCount > 0
? `已上传 ${totalAttachmentCount}` ? `已上传 ${totalAttachmentCount}`
: buildReviewAttachmentStatus(reviewPayload) : 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 = [ const cards = [
{ {
key: 'occurred_date', 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 = '') { function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
if (slotKey === 'customer_name') { if (slotKey === 'customer_name') {
return expenseTypeLabel === '业务招待费' return expenseTypeLabel === '业务招待费'
@@ -2353,17 +2467,30 @@ function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
function buildReviewRiskSummary(reviewPayload) { function buildReviewRiskSummary(reviewPayload) {
if (resolveReviewRiskBriefs(reviewPayload).length) { if (resolveReviewRiskBriefs(reviewPayload).length) {
return '当前识别到了合规提醒,提交前建议逐项核对。' return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。'
} }
return '当前版本暂未生成风险评分结果。' return '当前没有需要额外处理的结构化风险点。'
} }
function normalizeReviewRiskLevel(level) { function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase() const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high' if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'medium') return 'warning' if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low'
return 'info' 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) { function buildReviewRiskItems(reviewPayload) {
@@ -2374,9 +2501,9 @@ function buildReviewRiskItems(reviewPayload) {
const detail = String(brief?.detail || '').trim() const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim() const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level) 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 fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = title || fallbackTitle const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
const summary = content || normalizedTitle const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null if (!normalizedTitle && !summary) return null
@@ -2389,12 +2516,30 @@ function buildReviewRiskItems(reviewPayload) {
level, level,
levelLabel: meta.label, levelLabel: meta.label,
icon: meta.icon, icon: meta.icon,
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审', sourceLabel: meta.label,
suggestion: suggestion || meta.suggestion suggestion: suggestion || meta.suggestion
} }
}) })
.filter(Boolean) .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()) { function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
@@ -2489,6 +2634,7 @@ function normalizeInlineReviewComparableState(state) {
return { return {
occurred_date: String(source.occurred_date || '').trim(), occurred_date: String(source.occurred_date || '').trim(),
amount: String(source.amount || '').trim(), amount: String(source.amount || '').trim(),
transport_type: String(source.transport_type || '').trim(),
scene_label: String(source.scene_label || '').trim(), scene_label: String(source.scene_label || '').trim(),
reason_value: String(source.reason_value || '').trim(), reason_value: String(source.reason_value || '').trim(),
customer_name: String(source.customer_name || '').trim(), customer_name: String(source.customer_name || '').trim(),
@@ -2512,6 +2658,9 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
if (base.amount !== next.amount) { if (base.amount !== next.amount) {
lines.push(`金额 ${formatAmountDisplay(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) { if (base.scene_label !== next.scene_label) {
lines.push(`场景 ${next.scene_label || '待补充'}`) lines.push(`场景 ${next.scene_label || '待补充'}`)
} }
@@ -2543,6 +2692,7 @@ function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = [])
const fieldConfigs = [ const fieldConfigs = [
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' }, { key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' }, { key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
{ key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' },
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' }, { key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' }, { key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' }, { key: 'location', label: '业务地点', format: (value) => value || '待补充' },
@@ -2611,6 +2761,7 @@ function mergeInlineReviewFields(baseFields, inlineState) {
const merged = cloneReviewEditFields(baseFields) const merged = cloneReviewEditFields(baseFields)
const updateMap = { const updateMap = {
expense_type: inlineState.expense_type, expense_type: inlineState.expense_type,
transport_type: inlineState.transport_type,
occurred_date: inlineState.occurred_date, occurred_date: inlineState.occurred_date,
amount: inlineState.amount, amount: inlineState.amount,
customer_name: inlineState.customer_name, customer_name: inlineState.customer_name,
@@ -2699,7 +2850,7 @@ function buildReviewRiskHint(reviewPayload) {
if (!riskBriefs.length) { if (!riskBriefs.length) {
return '' return ''
} }
return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。' return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。'
} }
function buildReviewActionHint(reviewPayload) { function buildReviewActionHint(reviewPayload) {
@@ -2839,6 +2990,14 @@ export default {
const composerRangeEndDate = ref(formatDateInputValue()) const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([]) const composerBusinessTimeTags = ref([])
const composerBusinessTimeDraftTouched = ref(false) 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 attachedFiles = ref([])
const composerFilesExpanded = ref(false) const composerFilesExpanded = ref(false)
const submitting = ref(false) const submitting = ref(false)
@@ -2882,10 +3041,6 @@ export default {
const activeReviewDocumentIndex = ref(0) const activeReviewDocumentIndex = ref(0)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW) const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const insightPanelCollapsed = ref(false) const insightPanelCollapsed = ref(false)
const reviewRiskDetailDialog = ref({
open: false,
item: null
})
const documentPreviewDialog = ref({ const documentPreviewDialog = ref({
open: false, open: false,
filename: '', filename: '',
@@ -2921,6 +3076,11 @@ export default {
&& composerRangeStartDate.value <= composerRangeEndDate.value && 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 isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const completedFlowStepCount = computed( const completedFlowStepCount = computed(
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length () => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
@@ -3040,10 +3200,9 @@ export default {
).length > 0 ).length > 0
) )
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value)) const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value))
const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value))
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
const reviewRiskItems = computed(() => buildReviewRiskItems(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 reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value) const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0) const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
@@ -3301,7 +3460,9 @@ export default {
activeReviewDocumentIndex.value = nextDocumentDrafts.length activeReviewDocumentIndex.value = nextDocumentDrafts.length
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1) ? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
: 0 : 0
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
? REVIEW_DRAWER_MODE_RISK
: REVIEW_DRAWER_MODE_REVIEW
reviewInlinePendingFiles.value = [] reviewInlinePendingFiles.value = []
reviewInlineEditorKey.value = '' reviewInlineEditorKey.value = ''
reviewInlineErrors.value = {} reviewInlineErrors.value = {}
@@ -3975,6 +4136,9 @@ export default {
function toggleComposerDatePicker() { function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value composerDatePickerOpen.value = !composerDatePickerOpen.value
if (composerDatePickerOpen.value) {
travelCalculatorOpen.value = false
}
} }
function closeComposerDatePicker() { function closeComposerDatePicker() {
@@ -3998,14 +4162,22 @@ export default {
} }
function handleComposerDatePickerOutside(event) { function handleComposerDatePickerOutside(event) {
if (!composerDatePickerOpen.value) { if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
return return
} }
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) { if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
return return
} }
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
return
}
if (composerDatePickerOpen.value) {
composerDatePickerOpen.value = false composerDatePickerOpen.value = false
} }
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
travelCalculatorOpen.value = false
}
}
async function applyComposerDateSelection() { async function applyComposerDateSelection() {
if (!composerCanApplyDateSelection.value) { if (!composerCanApplyDateSelection.value) {
@@ -4026,6 +4198,142 @@ export default {
composerTextareaRef.value?.focus() 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) { function rememberFilePreviews(filePreviews) {
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
} }
@@ -4378,6 +4686,7 @@ export default {
...reviewInlineForm.value, ...reviewInlineForm.value,
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
amount: String(reviewInlineForm.value.amount || '').trim(), amount: String(reviewInlineForm.value.amount || '').trim(),
transport_type: String(reviewInlineForm.value.transport_type || '').trim(),
customer_name: String(reviewInlineForm.value.customer_name || '').trim(), customer_name: String(reviewInlineForm.value.customer_name || '').trim(),
location: String(reviewInlineForm.value.location || '').trim(), location: String(reviewInlineForm.value.location || '').trim(),
merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(), merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(),
@@ -4473,19 +4782,13 @@ export default {
}) })
} }
function openReviewRiskDetail(item) { function appendReviewRiskBriefToConversation(item) {
if (!item) return if (!item) return
reviewRiskDetailDialog.value = { messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
open: true, meta: [item.sourceLabel || item.levelLabel || '风险提示'],
item metaTone: item.level || 'low'
} }))
} nextTick(scrollToBottom)
function closeReviewRiskDetail() {
reviewRiskDetailDialog.value = {
...reviewRiskDetailDialog.value,
open: false
}
} }
function goReviewDocument(direction) { function goReviewDocument(direction) {
@@ -5267,11 +5570,9 @@ export default {
REVIEW_OTHER_CATEGORY_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible, workbenchVisible,
reviewPanelConfidence, reviewPanelConfidence,
reviewRiskScore,
reviewRiskSummary, reviewRiskSummary,
reviewRiskItems, reviewRiskItems,
reviewRiskEmpty, reviewRiskEmpty,
reviewRiskDetailDialog,
recognizedNarratives, recognizedNarratives,
reviewRecognitionNotes, reviewRecognitionNotes,
reviewDocumentSummaries, reviewDocumentSummaries,
@@ -5281,6 +5582,12 @@ export default {
reviewCancelDialogOpen, reviewCancelDialogOpen,
reviewEditDialogOpen, reviewEditDialogOpen,
uploadDecisionDialogOpen, uploadDecisionDialogOpen,
travelCalculatorOpen,
travelCalculatorBusy,
travelCalculatorError,
travelCalculatorResult,
travelCalculatorForm,
travelCalculatorCanSubmit,
deleteSessionDialogOpen, deleteSessionDialogOpen,
reviewActionBusy, reviewActionBusy,
deleteSessionBusy, deleteSessionBusy,
@@ -5331,6 +5638,10 @@ export default {
resolveFlowStepStatusLabel, resolveFlowStepStatusLabel,
resolveFlowStepDetail, resolveFlowStepDetail,
toggleInsightPanel, toggleInsightPanel,
openTravelCalculator,
toggleTravelCalculator,
closeTravelCalculator,
submitTravelCalculator,
switchToReviewOverviewDrawer, switchToReviewOverviewDrawer,
toggleReviewDocumentDrawer, toggleReviewDocumentDrawer,
toggleReviewRiskDrawer, toggleReviewRiskDrawer,
@@ -5357,8 +5668,7 @@ export default {
selectReviewCategory, selectReviewCategory,
selectReviewOtherCategory, selectReviewOtherCategory,
queryDraftByClaimNo, queryDraftByClaimNo,
openReviewRiskDetail, appendReviewRiskBriefToConversation,
closeReviewRiskDetail,
goReviewDocument, goReviewDocument,
openActiveReviewDocumentPreview, openActiveReviewDocumentPreview,
closeDocumentPreview, closeDocumentPreview,

View File

@@ -17,7 +17,12 @@ import {
uploadExpenseClaimItemAttachment, uploadExpenseClaimItemAttachment,
updateExpenseClaimItem updateExpenseClaimItem
} from '../../services/reimbursements.js' } 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 { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import { import {
buildAiAdviceViewModel, buildAiAdviceViewModel,
@@ -82,7 +87,7 @@ function resolveLocationDisplay(value, expenseType) {
function buildFallbackProgressSteps() { function buildFallbackProgressSteps() {
return [ 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: 2, label: '待提交', time: '进行中', active: true, current: true },
{ index: 3, label: 'AI预审', time: '待处理' }, { index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' }, { index: 4, label: '直属领导审批', time: '待处理' },
@@ -486,20 +491,51 @@ export default {
const node = String(request.value.node || request.value.approvalStage || '').trim() const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批' return node === '直属领导审批'
}) })
const showLeaderApprovalPanel = computed(() => const isFinanceApprovalStage = computed(() => {
Boolean(props.approvalMode) const node = String(request.value.node || request.value.approvalStage || '').trim()
&& request.value.approvalKey === 'in_progress' return node === '财务审批'
&& isDirectManagerApprovalStage.value })
&& Boolean(request.value.claimId)
)
const canReturnRequest = computed(() => const canReturnRequest = computed(() =>
canReturnExpenseClaims(currentUser.value) canReturnExpenseClaims(currentUser.value)
&& request.value.approvalKey === 'in_progress' && request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId) && Boolean(request.value.claimId)
) )
const canApproveRequest = computed(() => const canApproveRequest = computed(() =>
showLeaderApprovalPanel.value Boolean(props.approvalMode)
&& canReturnExpenseClaims(currentUser.value) && 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 deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`) const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
@@ -564,7 +600,7 @@ export default {
}, },
{ {
key: 'date', key: 'date',
label: '日期', label: '单据申请日期',
value: request.value.applyTime || request.value.occurredDisplay, value: request.value.applyTime || request.value.occurredDisplay,
icon: 'mdi mdi-calendar-month-outline', icon: 'mdi mdi-calendar-month-outline',
valueClass: '' valueClass: ''
@@ -1011,12 +1047,23 @@ export default {
try { try {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file) const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
expenseAttachmentMeta[item.id] = payload?.attachment || null 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(), invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').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) { if (editingExpenseId.value === item.id) {
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim() expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
expenseEditor.itemAmount = String(recognizedItemAmount)
}
} }
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId: request.value.claimId })
@@ -1322,7 +1369,7 @@ export default {
} }
if (!canApproveRequest.value) { if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。') toast('当前节点不支持审批通过。')
return return
} }
@@ -1345,7 +1392,7 @@ export default {
} }
if (!canApproveRequest.value) { if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。') toast('当前节点不支持审批通过。')
approveConfirmDialogOpen.value = false approveConfirmDialogOpen.value = false
return return
} }
@@ -1357,7 +1404,7 @@ export default {
}) })
approveConfirmDialogOpen.value = false approveConfirmDialogOpen.value = false
leaderOpinion.value = '' leaderOpinion.value = ''
toast(`${request.value.id} 已审批通过,流转至财务审批。`) toast(approvalSuccessToast.value)
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId: request.value.claimId })
} catch (error) { } catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。') toast(error?.message || '审批通过失败,请稍后重试。')
@@ -1396,6 +1443,12 @@ export default {
attachmentPreviewUrl, attachmentPreviewUrl,
approveBusy, approveBusy,
approveConfirmDialogOpen, approveConfirmDialogOpen,
approvalConfirmBadge,
approvalConfirmDescription,
approvalNextStage,
approvalOpinionHint,
approvalOpinionPlaceholder,
approvalOpinionTitle,
canDeleteRequest, canDeleteRequest,
canManageCurrentClaim, canManageCurrentClaim,
canNavigateAttachmentPreview, canNavigateAttachmentPreview,

View File

@@ -1,7 +1,12 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' 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', () => { test('direct approvers can return claims without receiving delete permissions', () => {
const managerUser = { roleCodes: ['manager'] } 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(managerUser), true)
assert.equal(canReturnExpenseClaims(approverUser), true) assert.equal(canReturnExpenseClaims(approverUser), true)
assert.equal(canApproveLeaderExpenseClaims(managerUser), true)
assert.equal(canApproveLeaderExpenseClaims(approverUser), true)
assert.equal(canManageExpenseClaims(managerUser), false) assert.equal(canManageExpenseClaims(managerUser), false)
assert.equal(canManageExpenseClaims(approverUser), 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(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(canReturnExpenseClaims({ roleCodes: ['executive'] }), true)
assert.equal(canManageExpenseClaims({ 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 leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
const financeStep = request.progressSteps.find((step) => step.label === '财务审批') const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审') const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
const firstStep = request.progressSteps[0]
assert.equal(firstStep.label, '创建单据')
assert.equal(leaderStep.time, '李经理通过') assert.equal(leaderStep.time, '李经理通过')
assert.match(leaderStep.detail, /2026-05-20/) assert.match(leaderStep.detail, /2026-05-20/)
assert.match(leaderStep.title, /李经理审批通过/) 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', () => { test('current direct manager step shows how long the claim has stayed there', () => {
const originalNow = Date.now const originalNow = Date.now
Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime() 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.approvalStatus, '待提交')
assert.equal(request.node, '待提交') 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)), fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8' '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', () => { test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/) 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_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\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 leaderOpinion = ref\(''\)/)
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/) assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const canApproveRequest = computed/) 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, /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, /v-if="showLeaderApprovalPanel"/)
assert.match(detailTemplate, /领导意见/) assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
assert.match(detailTemplate, /v-model="leaderOpinion"/) assert.match(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
assert.match(detailTemplate, /@click="handleApproveRequest"/) assert.match(detailTemplate, /@click="handleApproveRequest"/)
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/) assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
assert.match(detailTemplate, /:badge="approvalConfirmBadge"/)
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /confirm-text="确认通过"/) assert.match(detailTemplate, /confirm-text="确认通过"/)
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/) assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest') 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]*一条费用明细只能上传一张单据/) 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', () => { test('return reason dialog is wired into approval and detail return actions', () => {
assert.match(returnReasonDialog, /missing_attachment/) assert.match(returnReasonDialog, /missing_attachment/)
assert.match(returnReasonDialog, /invoice_mismatch/) 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.doesNotMatch(handleSubmit, /submitExpenseClaim/)
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/) 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*'保存草稿'/)
})