feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -30,10 +30,24 @@ services:
|
||||
- /bin/sh
|
||||
- -lc
|
||||
- >
|
||||
apt-get update &&
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
||||
python3 python3-pip python3-venv &&
|
||||
mkdir -p /run/sshd && /usr/sbin/sshd &&
|
||||
apt-get update &&
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
||||
python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra &&
|
||||
printf '%s\n'
|
||||
'<?xml version="1.0"?>'
|
||||
'<!DOCTYPE fontconfig SYSTEM "fonts.dtd">'
|
||||
'<fontconfig>'
|
||||
' <alias><family>SimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||
' <alias><family>NSimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||
' <alias><family>KaiTi</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||
' <alias><family>FangSong</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||
' <alias><family>SimHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||
' <alias><family>DengXian</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||
' <alias><family>Microsoft YaHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||
'</fontconfig>'
|
||||
> /etc/fonts/local.conf &&
|
||||
fc-cache -f &&
|
||||
mkdir -p /run/sshd && /usr/sbin/sshd &&
|
||||
printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh &&
|
||||
chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&
|
||||
touch /root/.bashrc /root/.profile &&
|
||||
|
||||
@@ -20,9 +20,12 @@ from app.schemas.reimbursement import (
|
||||
ExpenseClaimReturnPayload,
|
||||
ReimbursementCreate,
|
||||
ReimbursementRead,
|
||||
TravelReimbursementCalculatorRequest,
|
||||
TravelReimbursementCalculatorResponse,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.reimbursement import ReimbursementService
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
router = APIRouter()
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -50,6 +53,29 @@ def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> Reimbur
|
||||
return ReimbursementService(db).create_reimbursement(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/travel-calculator",
|
||||
response_model=TravelReimbursementCalculatorResponse,
|
||||
summary="差旅报销标准测算",
|
||||
description="根据规则中心的差旅报销表、当前员工职级、出差天数与地点测算住宿和补贴参考金额。",
|
||||
responses={
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "测算入参或规则匹配失败。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def calculate_travel_reimbursement(
|
||||
payload: TravelReimbursementCalculatorRequest,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> TravelReimbursementCalculatorResponse:
|
||||
try:
|
||||
return TravelReimbursementCalculatorService(db).calculate(payload, current_user)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claims",
|
||||
response_model=list[ExpenseClaimRead],
|
||||
@@ -463,8 +489,8 @@ def return_expense_claim(
|
||||
@router.post(
|
||||
"/claims/{claim_id}/approve",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="直属领导审批通过报销单",
|
||||
description="当前审批人确认报销信息无误后,将报销单从直属领导审批流转到财务审批。",
|
||||
summary="审批通过报销单",
|
||||
description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
@@ -497,7 +523,7 @@ def approve_expense_claim(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除报销单",
|
||||
description="普通用户仅可删除草稿或待补充报销单;财务人员和高级管理人员可删除可见报销单。",
|
||||
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
|
||||
@@ -93,6 +93,10 @@ class ExpenseClaimItem(Base):
|
||||
|
||||
claim = relationship("ExpenseClaim", back_populates="items")
|
||||
|
||||
@property
|
||||
def is_system_generated(self) -> bool:
|
||||
return str(self.item_type or "").strip().lower() in {"travel_allowance"}
|
||||
|
||||
|
||||
class AccountsReceivableRecord(Base):
|
||||
__tablename__ = "accounts_receivable"
|
||||
|
||||
@@ -41,6 +41,7 @@ class ExpenseClaimItemRead(BaseModel):
|
||||
item_location: str
|
||||
item_amount: Decimal
|
||||
invoice_id: str | None
|
||||
is_system_generated: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -157,11 +158,41 @@ class ExpenseClaimApprovalPayload(BaseModel):
|
||||
opinion: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorRequest(BaseModel):
|
||||
days: int = Field(ge=1, le=365)
|
||||
location: str = Field(min_length=1, max_length=120)
|
||||
grade: str | None = Field(default=None, max_length=30)
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorResponse(BaseModel):
|
||||
days: int
|
||||
location: str
|
||||
matched_city: str
|
||||
city_tier: str
|
||||
grade: str
|
||||
grade_band: str
|
||||
grade_band_label: str
|
||||
hotel_rate: Decimal
|
||||
hotel_amount: Decimal
|
||||
allowance_region: str
|
||||
meal_allowance_rate: Decimal
|
||||
basic_allowance_rate: Decimal
|
||||
total_allowance_rate: Decimal
|
||||
allowance_amount: Decimal
|
||||
total_amount: Decimal
|
||||
rule_name: str
|
||||
rule_version: str
|
||||
formula_text: str
|
||||
summary_text: str
|
||||
|
||||
|
||||
class ExpenseClaimAttachmentActionResponse(BaseModel):
|
||||
message: str
|
||||
claim_id: str
|
||||
item_id: str
|
||||
invoice_id: str | None = None
|
||||
item_amount: Decimal | None = None
|
||||
claim_amount: Decimal | None = None
|
||||
attachment: ExpenseClaimAttachmentRead | None = None
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class AgentConversationService:
|
||||
normalized_id = str(conversation_id or "").strip()
|
||||
normalized_user_id = str(user_id or "").strip() or None
|
||||
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense"
|
||||
incoming_draft_claim_id = self._resolve_draft_claim_id(context_json)
|
||||
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
||||
if conversation is not None and conversation.user_id != normalized_user_id:
|
||||
normalized_id = ""
|
||||
@@ -56,6 +57,7 @@ class AgentConversationService:
|
||||
source=source,
|
||||
entry_source=str(context_json.get("entry_source") or "").strip() or None,
|
||||
title=self._resolve_title(context_json),
|
||||
draft_claim_id=incoming_draft_claim_id or None,
|
||||
state_json=self._extract_state_json(context_json),
|
||||
)
|
||||
self.db.add(conversation)
|
||||
@@ -69,6 +71,8 @@ class AgentConversationService:
|
||||
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
|
||||
if not conversation.title:
|
||||
conversation.title = self._resolve_title(context_json)
|
||||
if incoming_draft_claim_id:
|
||||
conversation.draft_claim_id = incoming_draft_claim_id
|
||||
conversation.state_json = self._merge_state_json(
|
||||
conversation.state_json,
|
||||
self._extract_state_json(context_json),
|
||||
@@ -354,6 +358,38 @@ class AgentConversationService:
|
||||
self.db.commit()
|
||||
return len(conversations)
|
||||
|
||||
def delete_conversations_for_draft_claim(
|
||||
self,
|
||||
*,
|
||||
claim_id: str | None,
|
||||
source: str | None = "user_message",
|
||||
session_type: str | None = "expense",
|
||||
) -> int:
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
if not normalized_claim_id:
|
||||
return 0
|
||||
|
||||
stmt = select(AgentConversation).where(AgentConversation.draft_claim_id == normalized_claim_id)
|
||||
if source:
|
||||
stmt = stmt.where(AgentConversation.source == source)
|
||||
conversations = list(self.db.scalars(stmt).all())
|
||||
normalized_session_type = str(session_type or "").strip()
|
||||
if normalized_session_type:
|
||||
conversations = [
|
||||
conversation
|
||||
for conversation in conversations
|
||||
if (str((conversation.state_json or {}).get("session_type") or "").strip() or "expense")
|
||||
== normalized_session_type
|
||||
]
|
||||
if not conversations:
|
||||
return 0
|
||||
|
||||
for conversation in conversations:
|
||||
self.db.delete(conversation)
|
||||
|
||||
self.db.commit()
|
||||
return len(conversations)
|
||||
|
||||
def delete_conversation(
|
||||
self,
|
||||
*,
|
||||
@@ -478,11 +514,28 @@ class AgentConversationService:
|
||||
continue
|
||||
state_json[key] = value
|
||||
|
||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||
draft_claim_id = AgentConversationService._resolve_draft_claim_id(context_json)
|
||||
if draft_claim_id:
|
||||
state_json["draft_claim_id"] = draft_claim_id
|
||||
return state_json
|
||||
|
||||
@staticmethod
|
||||
def _resolve_draft_claim_id(context_json: dict[str, Any]) -> str:
|
||||
draft_claim_id = str((context_json or {}).get("draft_claim_id") or "").strip()
|
||||
if draft_claim_id:
|
||||
return draft_claim_id
|
||||
|
||||
request_context = (context_json or {}).get("request_context")
|
||||
if isinstance(request_context, dict):
|
||||
return str(
|
||||
request_context.get("claim_id")
|
||||
or request_context.get("claimId")
|
||||
or request_context.get("draft_claim_id")
|
||||
or request_context.get("draftClaimId")
|
||||
or ""
|
||||
).strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _merge_state_json(
|
||||
current_state: dict[str, Any] | None,
|
||||
|
||||
@@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
||||
scene_code="travel",
|
||||
scene_label="差旅票据",
|
||||
expense_type="travel",
|
||||
keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"),
|
||||
keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"),
|
||||
score_bias=0.32,
|
||||
),
|
||||
DocumentRule(
|
||||
|
||||
@@ -57,6 +57,7 @@ EXPENSE_TYPE_LABELS = {
|
||||
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
MAX_DRAFT_CLAIMS_PER_USER = 3
|
||||
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES = {
|
||||
@@ -542,14 +543,19 @@ class ExpenseClaimService:
|
||||
[(normalized_name, content, media_type or "application/octet-stream")]
|
||||
)
|
||||
documents = list(ocr_result.documents or [])
|
||||
if documents:
|
||||
ocr_document = documents[0]
|
||||
ocr_status = "recognized"
|
||||
document_info = self._build_attachment_document_info(ocr_document)
|
||||
requirement_check = self._build_attachment_requirement_check(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
if documents:
|
||||
ocr_document = documents[0]
|
||||
ocr_status = "recognized"
|
||||
document_info = self._build_attachment_document_info(ocr_document)
|
||||
self._backfill_item_amount_from_attachment(
|
||||
item=item,
|
||||
document=ocr_document,
|
||||
document_info=document_info,
|
||||
)
|
||||
requirement_check = self._build_attachment_requirement_check(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
attachment_analysis = self._build_attachment_analysis(
|
||||
document=ocr_document,
|
||||
item=item,
|
||||
@@ -615,13 +621,15 @@ class ExpenseClaimService:
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"{normalized_name} 已上传并关联到当前费用明细。",
|
||||
"claim_id": claim.id,
|
||||
"item_id": item.id,
|
||||
"invoice_id": item.invoice_id,
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
}
|
||||
return {
|
||||
"message": f"{normalized_name} 已上传并关联到当前费用明细。",
|
||||
"claim_id": claim.id,
|
||||
"item_id": item.id,
|
||||
"invoice_id": item.invoice_id,
|
||||
"item_amount": item.item_amount,
|
||||
"claim_amount": claim.amount,
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
}
|
||||
|
||||
def get_claim_item_attachment_meta(
|
||||
self,
|
||||
@@ -739,16 +747,18 @@ class ExpenseClaimService:
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.submit",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.submit",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
if str(claim.status or "").strip().lower() == "submitted":
|
||||
self._delete_submitted_claim_assistant_sessions(claim.id)
|
||||
|
||||
return claim
|
||||
|
||||
def save_or_submit_from_ontology(
|
||||
self,
|
||||
@@ -858,8 +868,10 @@ class ExpenseClaimService:
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._has_privileged_claim_access(current_user):
|
||||
if not self._has_claim_delete_access(current_user):
|
||||
self._ensure_draft_claim(claim)
|
||||
if not self._is_claim_owned_by_current_user(claim, current_user):
|
||||
raise ValueError("只有高级管理人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
resource_id = claim.id
|
||||
@@ -903,7 +915,7 @@ class ExpenseClaimService:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = current_user.name or current_user.username
|
||||
operator = self._resolve_current_user_display_name(current_user)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
@@ -987,29 +999,43 @@ class ExpenseClaimService:
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
raise ValueError("只有审批中的报销单可以审批通过。")
|
||||
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
if previous_stage != "直属领导审批":
|
||||
raise ValueError("当前节点不是直属领导审批,不能执行领导审批通过。")
|
||||
if previous_stage == "直属领导审批":
|
||||
if not self._can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
|
||||
approval_source = "manual_approval"
|
||||
event_type = "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "submitted"
|
||||
next_stage = "财务审批"
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
elif previous_stage == "财务审批":
|
||||
if not self._can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员可以完成财务终审。")
|
||||
approval_source = "finance_approval"
|
||||
event_type = "expense_claim_finance_approval"
|
||||
label = "财务审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = "归档入账"
|
||||
default_message = "{operator} 已完成财务审核,进入归档入账。"
|
||||
else:
|
||||
raise ValueError("当前节点不支持审批通过。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = current_user.name or current_user.username
|
||||
leader_opinion = str(opinion or "").strip()
|
||||
next_stage = "财务审批"
|
||||
operator = self._resolve_current_user_display_name(current_user)
|
||||
approval_opinion = str(opinion or "").strip()
|
||||
approval_flag = {
|
||||
"source": "manual_approval",
|
||||
"event_type": "expense_claim_approval",
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "领导审批通过",
|
||||
"message": leader_opinion or f"{operator} 已审批通过,流转至{next_stage}。",
|
||||
"opinion": leader_opinion,
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
@@ -1024,7 +1050,7 @@ class ExpenseClaimService:
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
claim.status = "submitted"
|
||||
claim.status = next_status
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
@@ -2205,16 +2231,89 @@ class ExpenseClaimService:
|
||||
meta_path = self._attachment_meta_path(file_path)
|
||||
meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def _read_attachment_meta(self, file_path: Path) -> dict[str, Any]:
|
||||
meta_path = self._attachment_meta_path(file_path)
|
||||
if not meta_path.exists():
|
||||
return {}
|
||||
def _read_attachment_meta(self, file_path: Path) -> dict[str, Any]:
|
||||
meta_path = self._attachment_meta_path(file_path)
|
||||
if not meta_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
payload = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
def _repair_pdf_text_layer_metadata_if_needed(
|
||||
self,
|
||||
*,
|
||||
file_path: Path,
|
||||
metadata: dict[str, Any],
|
||||
item: ExpenseClaimItem | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not metadata:
|
||||
return metadata
|
||||
|
||||
media_type = str(metadata.get("media_type") or self._resolve_attachment_media_type(file_path.name)).strip()
|
||||
if media_type != "application/pdf":
|
||||
return metadata
|
||||
|
||||
ocr_text = str(metadata.get("ocr_text") or "")
|
||||
ocr_summary = str(metadata.get("ocr_summary") or "")
|
||||
if OcrService._placeholder_ratio(f"{ocr_summary}\n{ocr_text}") < 0.12:
|
||||
return metadata
|
||||
|
||||
text_layer = OcrService(self.db)._extract_pdf_text_layer(file_path)
|
||||
repaired_text, used_text_layer = OcrService._choose_document_text(
|
||||
ocr_text=ocr_text,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
if not used_text_layer or not repaired_text:
|
||||
return metadata
|
||||
|
||||
repaired_summary = OcrService._summarize_text(repaired_text)
|
||||
document = SimpleNamespace(
|
||||
filename=str(metadata.get("file_name") or file_path.name),
|
||||
text=repaired_text,
|
||||
summary=repaired_summary,
|
||||
avg_score=float(metadata.get("ocr_avg_score") or 0.0),
|
||||
line_count=int(metadata.get("ocr_line_count") or 0),
|
||||
document_type="",
|
||||
document_type_label="",
|
||||
scene_code="",
|
||||
scene_label="",
|
||||
document_fields=[],
|
||||
warnings=[str(value) for value in list(metadata.get("ocr_warnings") or []) if str(value).strip()],
|
||||
)
|
||||
document_info = self._build_attachment_document_info(document)
|
||||
document.document_type = document_info.get("document_type", "")
|
||||
document.document_type_label = document_info.get("document_type_label", "")
|
||||
document.scene_code = document_info.get("scene_code", "")
|
||||
document.scene_label = document_info.get("scene_label", "")
|
||||
document.document_fields = list(document_info.get("fields") or [])
|
||||
|
||||
metadata["ocr_text"] = repaired_text
|
||||
metadata["ocr_summary"] = repaired_summary
|
||||
metadata["document_info"] = document_info
|
||||
metadata["previewable"] = True
|
||||
metadata["preview_kind"] = "pdf"
|
||||
metadata["preview_storage_key"] = str(metadata.get("storage_key") or self._to_attachment_storage_key(file_path))
|
||||
metadata["preview_media_type"] = "application/pdf"
|
||||
metadata["preview_file_name"] = str(metadata.get("file_name") or file_path.name)
|
||||
|
||||
if item is not None:
|
||||
requirement_check = self._build_attachment_requirement_check(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
metadata["requirement_check"] = requirement_check
|
||||
metadata["analysis"] = self._build_attachment_analysis(
|
||||
document=document,
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
requirement_check=requirement_check,
|
||||
)
|
||||
|
||||
self._write_attachment_meta(file_path, metadata)
|
||||
return metadata
|
||||
|
||||
def _build_attachment_preview_meta(
|
||||
self,
|
||||
@@ -2262,12 +2361,17 @@ class ExpenseClaimService:
|
||||
"preview_file_name": "",
|
||||
}
|
||||
|
||||
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
|
||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||
metadata = self._read_attachment_meta(file_path)
|
||||
preview_storage_key = str(metadata.get("preview_storage_key") or "").strip()
|
||||
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
|
||||
preview_media_type = str(metadata.get("preview_media_type") or "").strip()
|
||||
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
|
||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||
metadata = self._read_attachment_meta(file_path)
|
||||
metadata = self._repair_pdf_text_layer_metadata_if_needed(
|
||||
file_path=file_path,
|
||||
metadata=metadata,
|
||||
item=item,
|
||||
)
|
||||
preview_storage_key = str(metadata.get("preview_storage_key") or "").strip()
|
||||
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
|
||||
preview_media_type = str(metadata.get("preview_media_type") or "").strip()
|
||||
|
||||
if preview_storage_key:
|
||||
preview_path = self._resolve_attachment_path(preview_storage_key)
|
||||
@@ -2284,10 +2388,15 @@ class ExpenseClaimService:
|
||||
|
||||
raise FileNotFoundError("Attachment preview not found")
|
||||
|
||||
def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]:
|
||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||
metadata = self._read_attachment_meta(file_path)
|
||||
uploaded_at_value = metadata.get("uploaded_at")
|
||||
def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]:
|
||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||
metadata = self._read_attachment_meta(file_path)
|
||||
metadata = self._repair_pdf_text_layer_metadata_if_needed(
|
||||
file_path=file_path,
|
||||
metadata=metadata,
|
||||
item=item,
|
||||
)
|
||||
uploaded_at_value = metadata.get("uploaded_at")
|
||||
uploaded_at = None
|
||||
if isinstance(uploaded_at_value, str) and uploaded_at_value.strip():
|
||||
try:
|
||||
@@ -2402,11 +2511,11 @@ class ExpenseClaimService:
|
||||
|
||||
return normalized_next
|
||||
|
||||
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
|
||||
insight = build_document_insight(
|
||||
filename=str(getattr(document, "filename", "") or ""),
|
||||
summary=str(getattr(document, "summary", "") or ""),
|
||||
text=str(getattr(document, "text", "") or ""),
|
||||
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
|
||||
insight = build_document_insight(
|
||||
filename=str(getattr(document, "filename", "") or ""),
|
||||
summary=str(getattr(document, "summary", "") or ""),
|
||||
text=str(getattr(document, "text", "") or ""),
|
||||
)
|
||||
raw_fields = list(getattr(document, "document_fields", []) or [])
|
||||
normalized_fields: list[dict[str, str]] = []
|
||||
@@ -2463,14 +2572,35 @@ class ExpenseClaimService:
|
||||
"document_type_label": document_type_label,
|
||||
"scene_code": scene_code,
|
||||
"scene_label": scene_label,
|
||||
"fields": normalized_fields,
|
||||
}
|
||||
|
||||
def _build_attachment_requirement_check(
|
||||
self,
|
||||
*,
|
||||
item: ExpenseClaimItem,
|
||||
document_info: dict[str, Any],
|
||||
"fields": normalized_fields,
|
||||
}
|
||||
|
||||
def _backfill_item_amount_from_attachment(
|
||||
self,
|
||||
*,
|
||||
item: ExpenseClaimItem,
|
||||
document: Any,
|
||||
document_info: dict[str, Any],
|
||||
) -> None:
|
||||
current_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
|
||||
if current_amount > Decimal("0.00"):
|
||||
return
|
||||
|
||||
amount = self._resolve_document_item_amount(
|
||||
{
|
||||
"document_fields": document_info.get("fields") or [],
|
||||
"summary": str(getattr(document, "summary", "") or ""),
|
||||
"text": str(getattr(document, "text", "") or ""),
|
||||
}
|
||||
)
|
||||
if amount is not None and amount > Decimal("0.00"):
|
||||
item.item_amount = amount
|
||||
|
||||
def _build_attachment_requirement_check(
|
||||
self,
|
||||
*,
|
||||
item: ExpenseClaimItem,
|
||||
document_info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
expense_type = str(item.item_type or "").strip().lower() or "other"
|
||||
policy = self._get_expense_scene_policy(expense_type)
|
||||
@@ -2932,8 +3062,17 @@ class ExpenseClaimService:
|
||||
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
|
||||
if not self._is_editable_claim_status(claim.status):
|
||||
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
|
||||
|
||||
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
|
||||
def _delete_submitted_claim_assistant_sessions(self, claim_id: str | None) -> None:
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
|
||||
AgentConversationService(self.db).delete_conversations_for_draft_claim(
|
||||
claim_id=claim_id,
|
||||
source="user_message",
|
||||
session_type="expense",
|
||||
)
|
||||
|
||||
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
base_flags = list(claim.risk_flags_json or [])
|
||||
attachment_flags = [
|
||||
flag
|
||||
@@ -4593,7 +4732,7 @@ class ExpenseClaimService:
|
||||
return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES
|
||||
return bool(policy.location_required)
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def _has_privileged_claim_access(current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
@@ -4604,6 +4743,17 @@ class ExpenseClaimService:
|
||||
}
|
||||
return bool(role_codes & PRIVILEGED_CLAIM_ROLE_CODES)
|
||||
|
||||
@staticmethod
|
||||
def _has_claim_delete_access(current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = {
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
}
|
||||
return bool(role_codes & CLAIM_DELETE_ROLE_CODES)
|
||||
|
||||
def _can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
if self._has_privileged_claim_access(current_user):
|
||||
return True
|
||||
@@ -4636,7 +4786,41 @@ class ExpenseClaimService:
|
||||
return self._resolve_claim_manager_name(claim) == approver_name
|
||||
|
||||
def _can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
return self._can_return_claim(current_user, claim)
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage == "直属领导审批":
|
||||
return self._is_current_direct_manager_approver(current_user, claim)
|
||||
if stage == "财务审批":
|
||||
role_codes = self._normalize_role_codes(current_user)
|
||||
return current_user.is_admin or "finance" in role_codes
|
||||
return False
|
||||
|
||||
def _is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
role_codes = self._normalize_role_codes(current_user)
|
||||
if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES):
|
||||
return False
|
||||
if str(claim.status or "").strip().lower() != "submitted":
|
||||
return False
|
||||
if str(claim.approval_stage or "").strip() != "直属领导审批":
|
||||
return False
|
||||
|
||||
current_employee = self._resolve_current_employee(current_user)
|
||||
if current_employee is not None and str(claim.employee_id or "").strip() == current_employee.id:
|
||||
return False
|
||||
|
||||
claim_employee = claim.employee
|
||||
if current_employee is not None and claim_employee is not None:
|
||||
if claim_employee.manager_id == current_employee.id:
|
||||
return True
|
||||
if claim_employee.manager is not None and claim_employee.manager.id == current_employee.id:
|
||||
return True
|
||||
|
||||
approver_name = str(
|
||||
current_employee.name if current_employee is not None and current_employee.name else current_user.name or ""
|
||||
).strip()
|
||||
if not approver_name:
|
||||
return False
|
||||
|
||||
return self._resolve_claim_manager_name(claim) == approver_name
|
||||
|
||||
@staticmethod
|
||||
def _normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
|
||||
@@ -4654,6 +4838,44 @@ class ExpenseClaimService:
|
||||
]
|
||||
)
|
||||
|
||||
def _resolve_current_user_display_name(self, current_user: CurrentUserContext) -> str:
|
||||
current_employee = self._resolve_current_employee(current_user)
|
||||
if current_employee is not None and str(current_employee.name or "").strip():
|
||||
return str(current_employee.name).strip()
|
||||
|
||||
for candidate in (current_user.name, current_user.username):
|
||||
normalized = str(candidate or "").strip()
|
||||
if normalized and not self._is_email_like(normalized):
|
||||
return normalized
|
||||
|
||||
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
|
||||
|
||||
def _is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool:
|
||||
current_employee = self._resolve_current_employee(current_user)
|
||||
if current_employee is not None:
|
||||
if str(claim.employee_id or "").strip() == current_employee.id:
|
||||
return True
|
||||
identity_values = {
|
||||
str(current_employee.name or "").strip(),
|
||||
str(current_employee.email or "").strip(),
|
||||
str(current_employee.employee_no or "").strip(),
|
||||
}
|
||||
else:
|
||||
identity_values = set()
|
||||
|
||||
identity_values.update(
|
||||
{
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
}
|
||||
)
|
||||
identity_values.discard("")
|
||||
return str(claim.employee_name or "").strip() in identity_values
|
||||
|
||||
@staticmethod
|
||||
def _is_email_like(value: str) -> bool:
|
||||
return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", str(value or "").strip()))
|
||||
|
||||
def _resolve_claim_employee_for_backfill(self, claim: ExpenseClaim) -> Employee | None:
|
||||
if claim.employee is not None:
|
||||
employee = self.db.scalar(
|
||||
@@ -4850,8 +5072,14 @@ class ExpenseClaimService:
|
||||
return conditions
|
||||
|
||||
def _apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||
if self._has_privileged_claim_access(current_user):
|
||||
role_codes = self._normalize_role_codes(current_user)
|
||||
if current_user.is_admin or "executive" in role_codes:
|
||||
return stmt.where(ExpenseClaim.status == "submitted")
|
||||
if "finance" in role_codes:
|
||||
return stmt.where(
|
||||
ExpenseClaim.status == "submitted",
|
||||
ExpenseClaim.approval_stage == "财务审批",
|
||||
)
|
||||
|
||||
conditions = self._build_approval_claim_conditions(current_user)
|
||||
if not conditions:
|
||||
|
||||
@@ -6,12 +6,17 @@ from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
AgentAssetSpreadsheetManager,
|
||||
)
|
||||
|
||||
EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL)
|
||||
|
||||
@@ -351,6 +356,11 @@ class TravelPolicyConfig(BaseModel):
|
||||
band_labels: dict[str, str] = Field(default_factory=dict)
|
||||
city_tiers: dict[str, str] = Field(default_factory=dict)
|
||||
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
standard_rule_code: str = ""
|
||||
standard_rule_name: str = ""
|
||||
standard_rule_version: str = ""
|
||||
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||
train_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||
@@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService:
|
||||
).all()
|
||||
)
|
||||
if not assets:
|
||||
return catalog
|
||||
assets = []
|
||||
|
||||
asset_ids = {asset.id for asset in assets}
|
||||
travel_spreadsheet_asset = self.db.scalar(
|
||||
select(AgentAsset)
|
||||
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
|
||||
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
|
||||
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids:
|
||||
assets.append(travel_spreadsheet_asset)
|
||||
|
||||
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
|
||||
for asset in assets:
|
||||
version = self._get_current_version(asset)
|
||||
if version is None:
|
||||
continue
|
||||
is_travel_spreadsheet_asset = (
|
||||
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
|
||||
)
|
||||
runtime_payload = self._extract_runtime_payload(
|
||||
markdown_content=str(version.content or ""),
|
||||
config_json=asset.config_json,
|
||||
)
|
||||
if not isinstance(runtime_payload, dict):
|
||||
spreadsheet_assets.append((asset, version))
|
||||
continue
|
||||
self._apply_runtime_payload(
|
||||
catalog,
|
||||
@@ -594,6 +622,15 @@ class ExpenseRuleRuntimeService:
|
||||
asset=asset,
|
||||
version=version,
|
||||
)
|
||||
if is_travel_spreadsheet_asset:
|
||||
spreadsheet_assets.append((asset, version))
|
||||
|
||||
for asset, version in spreadsheet_assets:
|
||||
self._apply_spreadsheet_runtime_payload(
|
||||
catalog,
|
||||
asset=asset,
|
||||
version=version,
|
||||
)
|
||||
|
||||
return catalog
|
||||
|
||||
@@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService:
|
||||
)
|
||||
except ValidationError:
|
||||
return
|
||||
|
||||
def _apply_spreadsheet_runtime_payload(
|
||||
self,
|
||||
catalog: ExpenseRuleCatalog,
|
||||
*,
|
||||
asset: AgentAsset,
|
||||
version: AgentAssetVersion,
|
||||
) -> None:
|
||||
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||
return
|
||||
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
|
||||
return
|
||||
|
||||
manager = AgentAssetSpreadsheetManager()
|
||||
metadata = manager.parse_version_markdown(str(version.content or ""))
|
||||
rule_document = (asset.config_json or {}).get("rule_document")
|
||||
if not isinstance(rule_document, dict):
|
||||
rule_document = {}
|
||||
storage_key = str(metadata.storage_key if metadata is not None else "").strip()
|
||||
if storage_key:
|
||||
try:
|
||||
workbook_path = manager.resolve_storage_path(storage_key)
|
||||
except FileNotFoundError:
|
||||
workbook_path = None
|
||||
if workbook_path is not None and not workbook_path.exists():
|
||||
workbook_path = None
|
||||
else:
|
||||
workbook_path = None
|
||||
|
||||
if workbook_path is None:
|
||||
fallback_storage_key = str(rule_document.get("storage_key") or "").strip()
|
||||
if not fallback_storage_key:
|
||||
return
|
||||
try:
|
||||
workbook_path = manager.resolve_storage_path(fallback_storage_key)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if not workbook_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
workbook = load_workbook(
|
||||
workbook_path,
|
||||
read_only=True,
|
||||
data_only=True,
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
return
|
||||
|
||||
try:
|
||||
standards = self._extract_travel_amount_standards_from_workbook(workbook)
|
||||
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
|
||||
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
|
||||
transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
standard_rule_version = str(
|
||||
rule_document.get("asset_version") or asset.current_version or version.version
|
||||
).strip()
|
||||
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None:
|
||||
payload = catalog.travel_policy.model_dump()
|
||||
payload["standard_rule_code"] = asset.code
|
||||
payload["standard_rule_name"] = asset.name
|
||||
payload["standard_rule_version"] = standard_rule_version
|
||||
if hotel_city_limits:
|
||||
payload["hotel_city_limits"] = {
|
||||
**payload.get("hotel_city_limits", {}),
|
||||
**hotel_city_limits,
|
||||
}
|
||||
if allowance_limits:
|
||||
payload["allowance_limits"] = {
|
||||
**payload.get("allowance_limits", {}),
|
||||
**allowance_limits,
|
||||
}
|
||||
if transport_limits:
|
||||
payload["transport_limits"] = {
|
||||
**payload.get("transport_limits", {}),
|
||||
**transport_limits,
|
||||
}
|
||||
catalog.travel_policy = RuntimeTravelPolicy(**payload)
|
||||
|
||||
for expense_type, amount in standards.items():
|
||||
current = catalog.scene_policies.get(expense_type)
|
||||
if current is None:
|
||||
continue
|
||||
limit_attr = "item_amount_limit" if expense_type == "transport" else "claim_amount_limit"
|
||||
base_limit = getattr(current, limit_attr, None)
|
||||
next_limit = self._replace_amount_limit_warn_amount(
|
||||
base_limit,
|
||||
amount=amount,
|
||||
metric_label=self._spreadsheet_metric_label(expense_type),
|
||||
)
|
||||
payload = current.model_dump()
|
||||
payload["rule_code"] = asset.code
|
||||
payload["rule_name"] = asset.name
|
||||
payload["rule_version"] = standard_rule_version
|
||||
payload[limit_attr] = next_limit.model_dump()
|
||||
catalog.scene_policies[expense_type] = ExpenseScenePolicy(**payload)
|
||||
|
||||
@staticmethod
|
||||
def _extract_travel_amount_standards_from_workbook(workbook: Any) -> dict[str, Decimal]:
|
||||
standards: dict[str, Decimal] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
header_index = -1
|
||||
category_index = -1
|
||||
standard_index = -1
|
||||
for index, row in enumerate(rows[:8]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "费用分类" in values and "报销标准" in values:
|
||||
header_index = index
|
||||
category_index = values.index("费用分类")
|
||||
standard_index = values.index("报销标准")
|
||||
break
|
||||
if header_index < 0:
|
||||
continue
|
||||
|
||||
for row in rows[header_index + 1 :]:
|
||||
category = str(row[category_index] or "").strip() if len(row) > category_index else ""
|
||||
standard_text = str(row[standard_index] or "").strip() if len(row) > standard_index else ""
|
||||
amount = ExpenseRuleRuntimeService._extract_first_standard_amount(standard_text)
|
||||
if not category or amount is None:
|
||||
continue
|
||||
normalized_type = ExpenseRuleRuntimeService._map_spreadsheet_category_to_expense_type(category)
|
||||
if normalized_type:
|
||||
standards[normalized_type] = amount
|
||||
return standards
|
||||
|
||||
@staticmethod
|
||||
def _extract_hotel_city_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||
city_limits: dict[str, dict[str, Decimal]] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
header_index = -1
|
||||
city_index = -1
|
||||
band_indexes: dict[str, int] = {}
|
||||
for index, row in enumerate(rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
for candidate in ("地区(城市)", "城市", "地区"):
|
||||
if candidate in values:
|
||||
city_index = values.index(candidate)
|
||||
break
|
||||
if city_index < 0:
|
||||
continue
|
||||
for column_index, header in enumerate(values):
|
||||
compact = re.sub(r"\s+", "", header)
|
||||
if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
|
||||
band_indexes["junior"] = column_index
|
||||
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
|
||||
band_indexes["mid"] = column_index
|
||||
band_indexes["senior"] = column_index
|
||||
if any(keyword in compact for keyword in ("P7", "高层经理", "公司级管理")):
|
||||
band_indexes["manager"] = column_index
|
||||
band_indexes["executive"] = column_index
|
||||
if band_indexes:
|
||||
header_index = index
|
||||
break
|
||||
|
||||
if header_index < 0:
|
||||
continue
|
||||
|
||||
for row in rows[header_index + 1 :]:
|
||||
raw_city = str(row[city_index] or "").strip() if len(row) > city_index else ""
|
||||
cities = ExpenseRuleRuntimeService._extract_city_names_from_cell(raw_city)
|
||||
if not cities:
|
||||
continue
|
||||
for city in cities:
|
||||
city_entry = city_limits.setdefault(city, {})
|
||||
for band, column_index in band_indexes.items():
|
||||
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||
row[column_index] if len(row) > column_index else None
|
||||
)
|
||||
if amount is not None:
|
||||
city_entry[band] = amount
|
||||
return city_limits
|
||||
|
||||
@staticmethod
|
||||
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||
allowance_limits: dict[str, dict[str, Decimal]] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
header_index = -1
|
||||
type_index = -1
|
||||
region_indexes: dict[str, int] = {}
|
||||
for index, row in enumerate(rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "补助类型" not in values:
|
||||
continue
|
||||
header_index = index
|
||||
type_index = values.index("补助类型")
|
||||
for column_index, header in enumerate(values):
|
||||
if column_index <= type_index:
|
||||
continue
|
||||
normalized = str(header or "").strip()
|
||||
if not normalized or normalized == "项目":
|
||||
continue
|
||||
region_indexes[normalized] = column_index
|
||||
break
|
||||
|
||||
if header_index < 0 or type_index < 0 or not region_indexes:
|
||||
continue
|
||||
|
||||
for row in rows[header_index + 1 :]:
|
||||
raw_type = str(row[type_index] or "").strip() if len(row) > type_index else ""
|
||||
allowance_key = ExpenseRuleRuntimeService._map_allowance_type_to_key(raw_type)
|
||||
if not allowance_key:
|
||||
continue
|
||||
|
||||
entry: dict[str, Decimal] = {}
|
||||
for region_label, column_index in region_indexes.items():
|
||||
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||
row[column_index] if len(row) > column_index else None
|
||||
)
|
||||
if amount is not None:
|
||||
entry[region_label] = amount
|
||||
if entry:
|
||||
allowance_limits[allowance_key] = entry
|
||||
return allowance_limits
|
||||
|
||||
@staticmethod
|
||||
def _map_allowance_type_to_key(value: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
if "伙食" in normalized or "餐" in normalized:
|
||||
return "meal"
|
||||
if "基本" in normalized:
|
||||
return "basic"
|
||||
if "合计" in normalized or "总计" in normalized:
|
||||
return "total"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_transport_class_limits_from_workbook(workbook: Any) -> dict[str, dict[str, int]]:
|
||||
limits: dict[str, dict[str, int]] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
employee_index = -1
|
||||
flight_index = -1
|
||||
train_index = -1
|
||||
for row_index, row in enumerate(rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "员工职级" in values:
|
||||
employee_index = values.index("员工职级")
|
||||
for next_row in rows[row_index + 1 : row_index + 4]:
|
||||
next_values = [str(value or "").strip() for value in next_row]
|
||||
if "飞机" in next_values:
|
||||
flight_index = next_values.index("飞机")
|
||||
if "火车" in next_values:
|
||||
train_index = next_values.index("火车")
|
||||
if flight_index >= 0 and train_index >= 0:
|
||||
break
|
||||
break
|
||||
|
||||
if employee_index < 0 or (flight_index < 0 and train_index < 0):
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
|
||||
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text)
|
||||
if not bands:
|
||||
continue
|
||||
flight_level = (
|
||||
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||
row[flight_index] if len(row) > flight_index else None,
|
||||
kind="flight",
|
||||
)
|
||||
if flight_index >= 0
|
||||
else None
|
||||
)
|
||||
train_level = (
|
||||
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||
row[train_index] if len(row) > train_index else None,
|
||||
kind="train",
|
||||
)
|
||||
if train_index >= 0
|
||||
else None
|
||||
)
|
||||
for band in bands:
|
||||
entry = limits.setdefault(band, {})
|
||||
if flight_level is not None:
|
||||
entry["flight"] = flight_level
|
||||
if train_level is not None:
|
||||
entry["train"] = train_level
|
||||
return limits
|
||||
|
||||
@staticmethod
|
||||
def _map_transport_grade_row_to_bands(value: str) -> list[str]:
|
||||
normalized = re.sub(r"\s+", "", str(value or "").upper())
|
||||
if not normalized or normalized.startswith("注"):
|
||||
return []
|
||||
bands: list[str] = []
|
||||
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")):
|
||||
bands.extend(["junior", "mid"])
|
||||
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")):
|
||||
bands.extend(["mid", "senior", "manager", "executive"])
|
||||
return list(dict.fromkeys(bands))
|
||||
|
||||
@staticmethod
|
||||
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
if not normalized:
|
||||
return None
|
||||
if kind == "flight":
|
||||
if any(keyword in normalized for keyword in ("头等舱",)):
|
||||
return 4
|
||||
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
|
||||
return 3
|
||||
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
|
||||
return 2
|
||||
if "经济舱" in normalized:
|
||||
return 1
|
||||
if kind == "train":
|
||||
if "商务座" in normalized:
|
||||
return 3
|
||||
if any(keyword in normalized for keyword in ("一等座", "软卧")):
|
||||
return 2
|
||||
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
|
||||
return 1
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_city_names_from_cell(value: str) -> list[str]:
|
||||
normalized = re.sub(r"[;;,,、/]+", "、", str(value or "").strip())
|
||||
if not normalized:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for part in normalized.split("、"):
|
||||
cleaned = re.sub(r"\s+", "", part)
|
||||
cleaned = re.sub(r"[((].*?[))]", "", cleaned)
|
||||
if not cleaned or any(keyword in cleaned for keyword in ("不含", "中心城区", "新区")):
|
||||
continue
|
||||
if len(cleaned) <= 12:
|
||||
names.append(cleaned)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
@staticmethod
|
||||
def _coerce_decimal_cell(value: Any) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
|
||||
except (ArithmeticError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_first_standard_amount(text: str) -> Decimal | None:
|
||||
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)\s*/\s*(?:天|人|晚|次|笔)", str(text or ""))
|
||||
if match is None:
|
||||
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", str(text or ""))
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(match.group(1)).quantize(Decimal("0.01"))
|
||||
except (ArithmeticError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _map_spreadsheet_category_to_expense_type(category: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(category or ""))
|
||||
if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")):
|
||||
return "transport"
|
||||
if "招待" in normalized and "餐" in normalized:
|
||||
return "entertainment"
|
||||
if "餐补" in normalized or normalized == "餐费":
|
||||
return "meal"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _spreadsheet_metric_label(expense_type: str) -> str:
|
||||
return {
|
||||
"transport": "单笔交通金额",
|
||||
"meal": "差旅餐补金额",
|
||||
"entertainment": "人均招待餐费",
|
||||
}.get(expense_type, "金额")
|
||||
|
||||
@staticmethod
|
||||
def _replace_amount_limit_warn_amount(
|
||||
base_limit: AmountLimitConfig | None,
|
||||
*,
|
||||
amount: Decimal,
|
||||
metric_label: str,
|
||||
) -> AmountLimitConfig:
|
||||
if base_limit is None:
|
||||
return AmountLimitConfig(
|
||||
warn_amount=amount,
|
||||
block_amount=None,
|
||||
metric_label=metric_label,
|
||||
)
|
||||
payload = base_limit.model_dump()
|
||||
payload["warn_amount"] = amount
|
||||
payload["metric_label"] = metric_label
|
||||
return AmountLimitConfig(**payload)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
@@ -27,6 +28,7 @@ class PreparedOcrInput:
|
||||
page_index: int | None = None
|
||||
preview_kind: str = ""
|
||||
preview_data_url: str = ""
|
||||
text_layer: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -38,6 +40,7 @@ class AggregatedOcrDocument:
|
||||
model: str = "PP-OCRv5_mobile"
|
||||
summary_fragments: list[str] = field(default_factory=list)
|
||||
text_fragments: list[str] = field(default_factory=list)
|
||||
text_layer_fragments: list[str] = field(default_factory=list)
|
||||
score_values: list[float] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
lines: list[OcrRecognizeLineRead] = field(default_factory=list)
|
||||
@@ -112,12 +115,14 @@ class OcrService:
|
||||
|
||||
if suffix == ".pdf":
|
||||
try:
|
||||
text_layer = self._extract_pdf_text_layer(temp_path)
|
||||
prepared_inputs.extend(
|
||||
self._prepare_pdf_inputs(
|
||||
pdf_path=temp_path,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
@@ -261,6 +266,7 @@ class OcrService:
|
||||
filename: str,
|
||||
media_type: str,
|
||||
cleanup_paths: list[Path],
|
||||
text_layer: str = "",
|
||||
) -> list[PreparedOcrInput]:
|
||||
output_dir = pdf_path.with_suffix("")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -283,10 +289,33 @@ class OcrService:
|
||||
page_index=page_index,
|
||||
preview_kind="image" if page_index == 0 else "",
|
||||
preview_data_url=preview_data_url if page_index == 0 else "",
|
||||
text_layer=text_layer if page_index == 0 else "",
|
||||
)
|
||||
)
|
||||
return descriptors
|
||||
|
||||
def _extract_pdf_text_layer(self, pdf_path: Path) -> str:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"pdftotext",
|
||||
"-layout",
|
||||
str(pdf_path),
|
||||
"-",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.settings.ocr_timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, UnicodeError):
|
||||
return ""
|
||||
|
||||
if completed.returncode != 0:
|
||||
return ""
|
||||
|
||||
return self._normalize_extracted_text(completed.stdout)
|
||||
|
||||
def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
prefix = output_dir / "page"
|
||||
completed = subprocess.run(
|
||||
@@ -367,6 +396,8 @@ class OcrService:
|
||||
aggregated.preview_kind = descriptor.preview_kind
|
||||
if descriptor.preview_data_url and not aggregated.preview_data_url:
|
||||
aggregated.preview_data_url = descriptor.preview_data_url
|
||||
if descriptor.text_layer and descriptor.text_layer not in aggregated.text_layer_fragments:
|
||||
aggregated.text_layer_fragments.append(descriptor.text_layer)
|
||||
|
||||
page_summary = str(payload.get("summary", "") or "").strip()
|
||||
if page_summary:
|
||||
@@ -401,6 +432,20 @@ class OcrService:
|
||||
aggregated = aggregated_by_source.get(source_key)
|
||||
if aggregated is None:
|
||||
first_descriptor = descriptors[0]
|
||||
text_layer = self._collect_descriptor_text_layer(descriptors)
|
||||
if text_layer:
|
||||
fallback = AggregatedOcrDocument(
|
||||
filename=first_descriptor.filename,
|
||||
media_type=first_descriptor.media_type,
|
||||
source_key=first_descriptor.source_key,
|
||||
page_count=max(1, len(descriptors)),
|
||||
preview_kind=first_descriptor.preview_kind,
|
||||
preview_data_url=first_descriptor.preview_data_url,
|
||||
warnings=["OCR worker 未返回该文件的识别结果,已使用 PDF 文本层。"],
|
||||
)
|
||||
fallback.text_layer_fragments.append(text_layer)
|
||||
documents.append(self._finalize_document(fallback))
|
||||
continue
|
||||
documents.append(
|
||||
OcrRecognizeDocumentRead(
|
||||
filename=first_descriptor.filename,
|
||||
@@ -416,6 +461,13 @@ class OcrService:
|
||||
|
||||
return documents
|
||||
|
||||
@staticmethod
|
||||
def _collect_descriptor_text_layer(descriptors: list[PreparedOcrInput]) -> str:
|
||||
for descriptor in descriptors:
|
||||
if descriptor.text_layer:
|
||||
return descriptor.text_layer
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_lines(
|
||||
items: list[dict],
|
||||
@@ -451,13 +503,26 @@ class OcrService:
|
||||
return summary
|
||||
|
||||
def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead:
|
||||
full_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
|
||||
ocr_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
|
||||
text_layer = "\n".join(fragment for fragment in aggregated.text_layer_fragments if fragment).strip()
|
||||
full_text, used_text_layer = self._choose_document_text(ocr_text=ocr_text, text_layer=text_layer)
|
||||
summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments)
|
||||
if used_text_layer or self._placeholder_ratio(summary) >= 0.12:
|
||||
summary = self._summarize_text(full_text)
|
||||
preview_kind = aggregated.preview_kind
|
||||
preview_data_url = aggregated.preview_data_url
|
||||
if (
|
||||
used_text_layer
|
||||
and aggregated.media_type == "application/pdf"
|
||||
and self._placeholder_ratio(ocr_text) >= 0.12
|
||||
):
|
||||
preview_kind = ""
|
||||
preview_data_url = ""
|
||||
insight = self.document_intelligence_service.build_document_insight(
|
||||
filename=aggregated.filename,
|
||||
summary=summary,
|
||||
text=full_text,
|
||||
preview_data_url=aggregated.preview_data_url,
|
||||
preview_data_url=preview_data_url,
|
||||
)
|
||||
warnings = list(aggregated.warnings)
|
||||
for warning in insight.warnings:
|
||||
@@ -493,8 +558,8 @@ class OcrService:
|
||||
)
|
||||
for field in insight.fields
|
||||
],
|
||||
preview_kind=aggregated.preview_kind,
|
||||
preview_data_url=aggregated.preview_data_url,
|
||||
preview_kind=preview_kind,
|
||||
preview_data_url=preview_data_url,
|
||||
warnings=warnings,
|
||||
lines=sorted(
|
||||
aggregated.lines,
|
||||
@@ -502,6 +567,45 @@ class OcrService:
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _choose_document_text(cls, *, ocr_text: str, text_layer: str) -> tuple[str, bool]:
|
||||
normalized_ocr_text = cls._normalize_extracted_text(ocr_text)
|
||||
normalized_text_layer = cls._normalize_extracted_text(text_layer)
|
||||
if not normalized_text_layer:
|
||||
return normalized_ocr_text, False
|
||||
if not normalized_ocr_text:
|
||||
return normalized_text_layer, True
|
||||
if cls._placeholder_ratio(normalized_ocr_text) >= 0.12 and cls._meaningful_char_count(normalized_text_layer) >= 8:
|
||||
return normalized_text_layer, True
|
||||
if cls._meaningful_char_count(normalized_text_layer) > cls._meaningful_char_count(normalized_ocr_text) * 1.3:
|
||||
return normalized_text_layer, True
|
||||
return normalized_ocr_text, False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_extracted_text(value: str) -> str:
|
||||
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in str(value or "").replace("\r", "\n").split("\n")]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
@staticmethod
|
||||
def _summarize_text(value: str) -> str:
|
||||
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
|
||||
summary = ";".join(lines[:3])
|
||||
if len(summary) > 180:
|
||||
return f"{summary[:177]}..."
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def _meaningful_char_count(value: str) -> int:
|
||||
return len(re.findall(r"[0-9A-Za-z\u4e00-\u9fff]", str(value or "")))
|
||||
|
||||
@staticmethod
|
||||
def _placeholder_ratio(value: str) -> float:
|
||||
chars = [char for char in str(value or "") if not char.isspace()]
|
||||
if not chars:
|
||||
return 0.0
|
||||
placeholder_count = sum(1 for char in chars if char in {"□", "<EFBFBD>"})
|
||||
return placeholder_count / len(chars)
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_temp_paths(paths: list[Path]) -> None:
|
||||
for path in reversed(paths):
|
||||
|
||||
593
server/src/app/services/travel_reimbursement_calculator.py
Normal file
593
server/src/app/services/travel_reimbursement_calculator.py
Normal file
@@ -0,0 +1,593 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.models.employee import Employee
|
||||
from app.schemas.reimbursement import (
|
||||
TravelReimbursementCalculatorRequest,
|
||||
TravelReimbursementCalculatorResponse,
|
||||
)
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService
|
||||
|
||||
OTHER_REGION_LOCATION_KEYWORDS = {
|
||||
"河北",
|
||||
"石家庄",
|
||||
"唐山",
|
||||
"秦皇岛",
|
||||
"邯郸",
|
||||
"邢台",
|
||||
"保定",
|
||||
"张家口",
|
||||
"承德",
|
||||
"沧州",
|
||||
"廊坊",
|
||||
"衡水",
|
||||
"山西",
|
||||
"太原",
|
||||
"大同",
|
||||
"长治",
|
||||
"晋城",
|
||||
"晋中",
|
||||
"运城",
|
||||
"临汾",
|
||||
"吕梁",
|
||||
"内蒙古",
|
||||
"呼和浩特",
|
||||
"包头",
|
||||
"赤峰",
|
||||
"通辽",
|
||||
"鄂尔多斯",
|
||||
"辽宁",
|
||||
"鞍山",
|
||||
"抚顺",
|
||||
"本溪",
|
||||
"丹东",
|
||||
"锦州",
|
||||
"营口",
|
||||
"盘锦",
|
||||
"吉林",
|
||||
"长春",
|
||||
"吉林市",
|
||||
"四平",
|
||||
"通化",
|
||||
"白山",
|
||||
"松原",
|
||||
"延边",
|
||||
"黑龙江",
|
||||
"哈尔滨",
|
||||
"齐齐哈尔",
|
||||
"牡丹江",
|
||||
"佳木斯",
|
||||
"大庆",
|
||||
"江苏",
|
||||
"常州",
|
||||
"南通",
|
||||
"连云港",
|
||||
"淮安",
|
||||
"盐城",
|
||||
"扬州",
|
||||
"镇江",
|
||||
"泰州",
|
||||
"宿迁",
|
||||
"浙江",
|
||||
"温州",
|
||||
"嘉兴",
|
||||
"湖州",
|
||||
"绍兴",
|
||||
"金华",
|
||||
"衢州",
|
||||
"舟山",
|
||||
"台州",
|
||||
"丽水",
|
||||
"安徽",
|
||||
"芜湖",
|
||||
"蚌埠",
|
||||
"淮南",
|
||||
"马鞍山",
|
||||
"淮北",
|
||||
"铜陵",
|
||||
"安庆",
|
||||
"黄山",
|
||||
"滁州",
|
||||
"阜阳",
|
||||
"宿州",
|
||||
"六安",
|
||||
"亳州",
|
||||
"池州",
|
||||
"宣城",
|
||||
"福建",
|
||||
"泉州",
|
||||
"漳州",
|
||||
"莆田",
|
||||
"三明",
|
||||
"南平",
|
||||
"龙岩",
|
||||
"宁德",
|
||||
"江西",
|
||||
"南昌",
|
||||
"景德镇",
|
||||
"萍乡",
|
||||
"九江",
|
||||
"新余",
|
||||
"鹰潭",
|
||||
"赣州",
|
||||
"吉安",
|
||||
"宜春",
|
||||
"抚州",
|
||||
"上饶",
|
||||
"山东",
|
||||
"淄博",
|
||||
"枣庄",
|
||||
"东营",
|
||||
"烟台",
|
||||
"潍坊",
|
||||
"济宁",
|
||||
"泰安",
|
||||
"威海",
|
||||
"日照",
|
||||
"临沂",
|
||||
"德州",
|
||||
"聊城",
|
||||
"滨州",
|
||||
"菏泽",
|
||||
"河南",
|
||||
"洛阳",
|
||||
"开封",
|
||||
"平顶山",
|
||||
"安阳",
|
||||
"鹤壁",
|
||||
"新乡",
|
||||
"焦作",
|
||||
"濮阳",
|
||||
"许昌",
|
||||
"漯河",
|
||||
"三门峡",
|
||||
"南阳",
|
||||
"商丘",
|
||||
"信阳",
|
||||
"周口",
|
||||
"驻马店",
|
||||
"湖北",
|
||||
"黄石",
|
||||
"十堰",
|
||||
"宜昌",
|
||||
"襄阳",
|
||||
"鄂州",
|
||||
"荆门",
|
||||
"孝感",
|
||||
"荆州",
|
||||
"黄冈",
|
||||
"咸宁",
|
||||
"随州",
|
||||
"恩施",
|
||||
"湖南",
|
||||
"株洲",
|
||||
"湘潭",
|
||||
"衡阳",
|
||||
"邵阳",
|
||||
"岳阳",
|
||||
"常德",
|
||||
"张家界",
|
||||
"益阳",
|
||||
"郴州",
|
||||
"永州",
|
||||
"怀化",
|
||||
"娄底",
|
||||
"湘西",
|
||||
"广东",
|
||||
"惠州",
|
||||
"江门",
|
||||
"湛江",
|
||||
"茂名",
|
||||
"肇庆",
|
||||
"梅州",
|
||||
"汕尾",
|
||||
"河源",
|
||||
"阳江",
|
||||
"清远",
|
||||
"潮州",
|
||||
"揭阳",
|
||||
"云浮",
|
||||
"广西",
|
||||
"南宁",
|
||||
"柳州",
|
||||
"桂林",
|
||||
"梧州",
|
||||
"北海",
|
||||
"防城港",
|
||||
"钦州",
|
||||
"贵港",
|
||||
"玉林",
|
||||
"百色",
|
||||
"贺州",
|
||||
"河池",
|
||||
"来宾",
|
||||
"崇左",
|
||||
"海南",
|
||||
"儋州",
|
||||
"四川",
|
||||
"自贡",
|
||||
"攀枝花",
|
||||
"泸州",
|
||||
"德阳",
|
||||
"绵阳",
|
||||
"广元",
|
||||
"遂宁",
|
||||
"内江",
|
||||
"乐山",
|
||||
"南充",
|
||||
"眉山",
|
||||
"宜宾",
|
||||
"广安",
|
||||
"达州",
|
||||
"雅安",
|
||||
"巴中",
|
||||
"资阳",
|
||||
"阿坝",
|
||||
"甘孜",
|
||||
"凉山",
|
||||
"贵州",
|
||||
"贵阳",
|
||||
"遵义",
|
||||
"六盘水",
|
||||
"安顺",
|
||||
"毕节",
|
||||
"铜仁",
|
||||
"黔东南",
|
||||
"黔南",
|
||||
"黔西南",
|
||||
"云南",
|
||||
"曲靖",
|
||||
"玉溪",
|
||||
"保山",
|
||||
"昭通",
|
||||
"丽江",
|
||||
"普洱",
|
||||
"临沧",
|
||||
"楚雄",
|
||||
"红河",
|
||||
"文山",
|
||||
"西双版纳",
|
||||
"大理",
|
||||
"德宏",
|
||||
"怒江",
|
||||
"迪庆",
|
||||
"陕西",
|
||||
"宝鸡",
|
||||
"咸阳",
|
||||
"铜川",
|
||||
"渭南",
|
||||
"延安",
|
||||
"汉中",
|
||||
"榆林",
|
||||
"安康",
|
||||
"商洛",
|
||||
"甘肃",
|
||||
"兰州",
|
||||
"嘉峪关",
|
||||
"金昌",
|
||||
"白银",
|
||||
"天水",
|
||||
"武威",
|
||||
"张掖",
|
||||
"平凉",
|
||||
"酒泉",
|
||||
"庆阳",
|
||||
"定西",
|
||||
"陇南",
|
||||
"临夏",
|
||||
"甘南",
|
||||
"青海",
|
||||
"西宁",
|
||||
"海东",
|
||||
"海北",
|
||||
"黄南",
|
||||
"海南州",
|
||||
"果洛",
|
||||
"玉树",
|
||||
"海西",
|
||||
"宁夏",
|
||||
"银川",
|
||||
"石嘴山",
|
||||
"吴忠",
|
||||
"固原",
|
||||
"中卫",
|
||||
}
|
||||
|
||||
OTHER_REGION_PROVINCE_KEYWORDS = {
|
||||
"河北",
|
||||
"山西",
|
||||
"内蒙古",
|
||||
"辽宁",
|
||||
"吉林",
|
||||
"黑龙江",
|
||||
"江苏",
|
||||
"浙江",
|
||||
"安徽",
|
||||
"福建",
|
||||
"江西",
|
||||
"山东",
|
||||
"河南",
|
||||
"湖北",
|
||||
"湖南",
|
||||
"广东",
|
||||
"广西",
|
||||
"海南",
|
||||
"四川",
|
||||
"贵州",
|
||||
"云南",
|
||||
"陕西",
|
||||
"甘肃",
|
||||
"青海",
|
||||
"宁夏",
|
||||
"新疆",
|
||||
"西藏",
|
||||
"台湾",
|
||||
"香港",
|
||||
"澳门",
|
||||
}
|
||||
|
||||
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def calculate(
|
||||
self,
|
||||
payload: TravelReimbursementCalculatorRequest,
|
||||
current_user: CurrentUserContext,
|
||||
) -> TravelReimbursementCalculatorResponse:
|
||||
days = max(1, int(payload.days))
|
||||
location = str(payload.location or "").strip()
|
||||
if not location:
|
||||
raise ValueError("请先填写出差地点。")
|
||||
|
||||
policy = self._load_travel_policy()
|
||||
grade = self._resolve_grade(payload.grade, current_user)
|
||||
if not grade:
|
||||
raise ValueError("未识别到当前员工职级,请在个人信息中维护职级后再计算。")
|
||||
|
||||
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
|
||||
if not grade_band:
|
||||
raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位。")
|
||||
|
||||
matched_city = self._resolve_city(location, policy)
|
||||
matched_other_region = "" if matched_city else self._resolve_other_region(location)
|
||||
if not matched_city and not matched_other_region:
|
||||
raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。")
|
||||
city_tier = policy.city_tiers.get(matched_city, "tier_3") if matched_city else "tier_3"
|
||||
hotel_rate = self._resolve_hotel_rate(policy, grade_band, matched_city, city_tier)
|
||||
allowance_region = self._resolve_allowance_region(location, matched_city or matched_other_region)
|
||||
meal_rate = self._resolve_allowance_rate(policy, "meal", allowance_region)
|
||||
basic_rate = self._resolve_allowance_rate(policy, "basic", allowance_region)
|
||||
total_allowance_rate = self._resolve_total_allowance_rate(policy, allowance_region, meal_rate, basic_rate)
|
||||
|
||||
hotel_amount = hotel_rate * Decimal(days)
|
||||
allowance_amount = total_allowance_rate * Decimal(days)
|
||||
total_amount = hotel_amount + allowance_amount
|
||||
band_label = policy.band_labels.get(grade_band, grade_band)
|
||||
rule_name = policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则"
|
||||
rule_version = policy.standard_rule_version or policy.rule_version or ""
|
||||
display_city = matched_city or self._format_other_region_display(matched_other_region)
|
||||
formula_text = (
|
||||
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
|
||||
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
|
||||
f"{self._format_money(total_amount)}"
|
||||
)
|
||||
summary_text = (
|
||||
f"按《{rule_name}》{f'({rule_version})' if rule_version else ''}测算:"
|
||||
f"当前职级 {grade} 对应 {band_label} 档,出差地点“{location}”匹配为“{display_city}”,"
|
||||
f"住宿标准 {self._format_money(hotel_rate)} 元/天,补贴区域为“{allowance_region}”,"
|
||||
f"补贴标准 {self._format_money(total_allowance_rate)} 元/天"
|
||||
f"(伙食 {self._format_money(meal_rate)} + 基本 {self._format_money(basic_rate)})。"
|
||||
f"按 {days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
|
||||
f"补贴合计 {self._format_money(allowance_amount)} 元,"
|
||||
f"参考可报销总金额为 {self._format_money(total_amount)} 元。"
|
||||
)
|
||||
|
||||
return TravelReimbursementCalculatorResponse(
|
||||
days=days,
|
||||
location=location,
|
||||
matched_city=display_city,
|
||||
city_tier=city_tier,
|
||||
grade=grade,
|
||||
grade_band=grade_band,
|
||||
grade_band_label=band_label,
|
||||
hotel_rate=hotel_rate,
|
||||
hotel_amount=hotel_amount,
|
||||
allowance_region=allowance_region,
|
||||
meal_allowance_rate=meal_rate,
|
||||
basic_allowance_rate=basic_rate,
|
||||
total_allowance_rate=total_allowance_rate,
|
||||
allowance_amount=allowance_amount,
|
||||
total_amount=total_amount,
|
||||
rule_name=rule_name,
|
||||
rule_version=rule_version,
|
||||
formula_text=formula_text,
|
||||
summary_text=summary_text,
|
||||
)
|
||||
|
||||
def _load_travel_policy(self) -> RuntimeTravelPolicy:
|
||||
AgentAssetService(self.db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
policy = ExpenseRuleRuntimeService(self.db).load_catalog().travel_policy
|
||||
if policy is None:
|
||||
raise ValueError("规则中心暂未配置差旅报销规则。")
|
||||
return policy
|
||||
|
||||
def _resolve_grade(
|
||||
self,
|
||||
grade: str | None,
|
||||
current_user: CurrentUserContext,
|
||||
) -> str:
|
||||
normalized_grade = str(grade or "").strip()
|
||||
if normalized_grade:
|
||||
return normalized_grade
|
||||
|
||||
employee = self._resolve_current_employee(current_user)
|
||||
if employee is not None and str(employee.grade or "").strip():
|
||||
return str(employee.grade).strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_other_region(location: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(location or "").strip())
|
||||
if not normalized:
|
||||
return ""
|
||||
if any(keyword in normalized for keyword in ("国外", "境外", "海外")):
|
||||
return "国外"
|
||||
for keyword in ("香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"):
|
||||
if keyword in normalized:
|
||||
return keyword
|
||||
city_matches = []
|
||||
province_matches = []
|
||||
for keyword in OTHER_REGION_LOCATION_KEYWORDS:
|
||||
if not keyword or keyword not in normalized:
|
||||
continue
|
||||
if keyword in OTHER_REGION_PROVINCE_KEYWORDS:
|
||||
province_matches.append(keyword)
|
||||
else:
|
||||
city_matches.append(keyword)
|
||||
candidates = city_matches or province_matches
|
||||
if candidates:
|
||||
return sorted(candidates, key=len, reverse=True)[0]
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_other_region_display(region: str) -> str:
|
||||
normalized = str(region or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if normalized in {"国外", "香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"}:
|
||||
return normalized
|
||||
return f"{normalized}(其他地区)"
|
||||
|
||||
def _resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None:
|
||||
candidates = [
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
]
|
||||
normalized_candidates = [
|
||||
item
|
||||
for item in dict.fromkeys(candidate for candidate in candidates if candidate)
|
||||
if item
|
||||
]
|
||||
if not normalized_candidates:
|
||||
return None
|
||||
|
||||
for candidate in normalized_candidates:
|
||||
employee = self.db.scalar(
|
||||
select(Employee)
|
||||
.where(
|
||||
or_(
|
||||
func.lower(Employee.email) == candidate.lower(),
|
||||
func.lower(Employee.employee_no) == candidate.lower(),
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if employee is not None:
|
||||
return employee
|
||||
|
||||
for candidate in normalized_candidates:
|
||||
matches = list(
|
||||
self.db.scalars(
|
||||
select(Employee)
|
||||
.where(Employee.name == candidate)
|
||||
.limit(2)
|
||||
).all()
|
||||
)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str:
|
||||
normalized = str(location or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
city_names = set(policy.city_tiers.keys())
|
||||
city_names.update(policy.hotel_city_limits.keys())
|
||||
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
|
||||
if city in AMBIGUOUS_PROVINCE_CITY_NAMES and normalized != city and f"{city}市" not in normalized:
|
||||
continue
|
||||
if city and city in normalized:
|
||||
return city
|
||||
compact = re.sub(r"(省|市|区|县|自治州|特别行政区)$", "", normalized)
|
||||
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
|
||||
if city in AMBIGUOUS_PROVINCE_CITY_NAMES and compact != city and f"{city}市" not in normalized:
|
||||
continue
|
||||
if city and city in compact:
|
||||
return city
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_hotel_rate(
|
||||
policy: RuntimeTravelPolicy,
|
||||
grade_band: str,
|
||||
matched_city: str,
|
||||
city_tier: str,
|
||||
) -> Decimal:
|
||||
city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {}
|
||||
if city_limits.get(grade_band) is not None:
|
||||
return Decimal(city_limits[grade_band])
|
||||
|
||||
band_limits = policy.hotel_limits.get(grade_band, {})
|
||||
if band_limits.get(city_tier) is not None:
|
||||
return Decimal(band_limits[city_tier])
|
||||
if band_limits.get("tier_3") is not None:
|
||||
return Decimal(band_limits["tier_3"])
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_allowance_region(location: str, matched_city: str) -> str:
|
||||
text = f"{location} {matched_city}".strip()
|
||||
if any(keyword in text for keyword in ("国外", "境外", "海外")):
|
||||
return "国外"
|
||||
if any(keyword in text for keyword in ("香港", "澳门", "台湾", "港澳台")):
|
||||
return "港澳台"
|
||||
if "乌鲁木齐" in text:
|
||||
return "新疆-乌鲁木齐"
|
||||
if "新疆" in text:
|
||||
return "新疆-其他"
|
||||
if "西藏" in text or "拉萨" in text:
|
||||
return "西藏"
|
||||
if any(keyword in text for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")):
|
||||
return "直辖市/特区"
|
||||
return "其他地区"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_allowance_rate(policy: RuntimeTravelPolicy, allowance_key: str, region: str) -> Decimal:
|
||||
limits = policy.allowance_limits.get(allowance_key, {})
|
||||
if limits.get(region) is not None:
|
||||
return Decimal(limits[region])
|
||||
if limits.get("其他地区") is not None:
|
||||
return Decimal(limits["其他地区"])
|
||||
return Decimal("0")
|
||||
|
||||
def _resolve_total_allowance_rate(
|
||||
self,
|
||||
policy: RuntimeTravelPolicy,
|
||||
region: str,
|
||||
meal_rate: Decimal,
|
||||
basic_rate: Decimal,
|
||||
) -> Decimal:
|
||||
total_limits = policy.allowance_limits.get("total", {})
|
||||
if total_limits.get(region) is not None:
|
||||
return Decimal(total_limits[region])
|
||||
if total_limits.get("其他地区") is not None:
|
||||
return Decimal(total_limits["其他地区"])
|
||||
return meal_rate + basic_rate
|
||||
|
||||
@staticmethod
|
||||
def _format_money(value: Decimal | int | float | str) -> str:
|
||||
return f"{Decimal(str(value)).quantize(Decimal('0.01'))}"
|
||||
@@ -34,6 +34,7 @@ from app.schemas.user_agent import (
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
|
||||
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
@@ -185,6 +186,7 @@ DOCUMENT_AMOUNT_PATTERN = re.compile(
|
||||
r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
||||
)
|
||||
DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)")
|
||||
TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)")
|
||||
|
||||
SOURCE_LABELS = {
|
||||
"user_text": "用户描述",
|
||||
@@ -197,6 +199,8 @@ SOURCE_LABELS = {
|
||||
"system": "系统判断",
|
||||
}
|
||||
|
||||
DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ("历史报销画像", "用户画像", "制度注意事项", "制度注意")
|
||||
|
||||
SCENE_REQUIRED_SLOT_KEYS = {
|
||||
"hotel": {"merchant_name"},
|
||||
"meeting": {"location"},
|
||||
@@ -2193,8 +2197,8 @@ class UserAgentService:
|
||||
for reason in self._resolve_submission_blocked_reasons(payload):
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="AI预审未通过",
|
||||
level="high",
|
||||
title="提交风险提示",
|
||||
level=self._resolve_submission_blocked_risk_level(reason),
|
||||
content=reason,
|
||||
detail=(
|
||||
"该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明,"
|
||||
@@ -2204,6 +2208,14 @@ class UserAgentService:
|
||||
)
|
||||
)
|
||||
|
||||
briefs.extend(
|
||||
self._build_travel_policy_precheck_briefs(
|
||||
payload,
|
||||
document_cards=document_cards,
|
||||
claim_groups=claim_groups,
|
||||
)
|
||||
)
|
||||
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
employee_name = (
|
||||
str(employee.name).strip()
|
||||
@@ -2211,7 +2223,10 @@ class UserAgentService:
|
||||
else self._collect_entity_values(payload).get("employee_name")
|
||||
or str(payload.context_json.get("name") or "").strip()
|
||||
)
|
||||
if employee_name:
|
||||
current_amount = self._resolve_amount_value(payload) or sum(
|
||||
self._extract_amount_from_card(card) for card in document_cards
|
||||
)
|
||||
if employee_name and current_amount > 0:
|
||||
since = datetime.now(UTC) - timedelta(days=90)
|
||||
claim_identity_conditions = [ExpenseClaim.employee_name == employee_name]
|
||||
if employee is not None:
|
||||
@@ -2228,57 +2243,27 @@ class UserAgentService:
|
||||
stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since)
|
||||
recent_claims = list(self.db.scalars(stmt).all())
|
||||
if recent_claims:
|
||||
risky_count = sum(1 for item in recent_claims if item.risk_flags_json)
|
||||
draft_count = sum(1 for item in recent_claims if item.status == "draft")
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="历史报销画像",
|
||||
level="info",
|
||||
content=(
|
||||
f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销,"
|
||||
f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。"
|
||||
),
|
||||
detail=(
|
||||
"该画像来自员工近 90 天报销记录,用于辅助判断是否存在频繁草稿、"
|
||||
"历史风险或异常重复报销倾向,不会单独阻断审批。"
|
||||
),
|
||||
suggestion="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。",
|
||||
)
|
||||
duplicate_count = sum(
|
||||
1
|
||||
for item in recent_claims
|
||||
if abs(float(item.amount) - current_amount) < 0.01
|
||||
)
|
||||
current_amount = self._resolve_amount_value(payload)
|
||||
if current_amount > 0:
|
||||
duplicate_count = sum(
|
||||
1
|
||||
for item in recent_claims
|
||||
if abs(float(item.amount) - current_amount) < 0.01
|
||||
)
|
||||
if duplicate_count:
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="金额重复预警",
|
||||
level="warning",
|
||||
content=(
|
||||
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
|
||||
"提交前建议核对是否为重复报销或拆分不当。"
|
||||
),
|
||||
detail=(
|
||||
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
|
||||
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
|
||||
),
|
||||
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
|
||||
)
|
||||
if duplicate_count:
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="金额重复预警",
|
||||
level="warning",
|
||||
content=(
|
||||
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
|
||||
"提交前建议核对是否为重复报销或拆分不当。"
|
||||
),
|
||||
detail=(
|
||||
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
|
||||
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
|
||||
),
|
||||
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
|
||||
)
|
||||
|
||||
if citations:
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="制度注意事项",
|
||||
level="info",
|
||||
content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。",
|
||||
detail=f"本条来自规则或知识库引用:{citations[0].title}。提交前应确认当前单据符合该条口径。",
|
||||
suggestion="如当前场景与制度口径存在差异,请补充审批说明或选择更准确的报销分类。",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
warning_count = sum(len(item.warnings) for item in document_cards)
|
||||
if warning_count:
|
||||
@@ -2296,14 +2281,635 @@ class UserAgentService:
|
||||
briefs.append(
|
||||
UserAgentReviewRiskBrief(
|
||||
title="建议拆单",
|
||||
level="high",
|
||||
level="warning",
|
||||
content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。",
|
||||
detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。",
|
||||
suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。",
|
||||
)
|
||||
)
|
||||
|
||||
return briefs[:4]
|
||||
return self._filter_deprecated_review_risk_briefs(briefs)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_submission_blocked_risk_level(reason: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(reason or ""))
|
||||
amount_keywords = ("金额", "超标", "费用", "价款", "票面金额", "单价", "合计")
|
||||
return "high" if any(keyword in normalized for keyword in amount_keywords) else "warning"
|
||||
|
||||
@staticmethod
|
||||
def _filter_deprecated_review_risk_briefs(
|
||||
briefs: list[UserAgentReviewRiskBrief],
|
||||
) -> list[UserAgentReviewRiskBrief]:
|
||||
filtered: list[UserAgentReviewRiskBrief] = []
|
||||
for brief in briefs:
|
||||
title = str(brief.title or "").strip()
|
||||
if any(keyword in title for keyword in DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS):
|
||||
continue
|
||||
filtered.append(brief)
|
||||
return filtered
|
||||
|
||||
def _build_travel_policy_precheck_briefs(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
document_cards: list[UserAgentReviewDocumentCard],
|
||||
claim_groups: list[UserAgentReviewClaimGroup],
|
||||
) -> list[UserAgentReviewRiskBrief]:
|
||||
if not document_cards or not self._is_travel_review_context(payload, document_cards, claim_groups):
|
||||
return []
|
||||
|
||||
rule_catalog = ExpenseRuleRuntimeService(self.db).load_catalog()
|
||||
policy = rule_catalog.travel_policy
|
||||
if policy is None:
|
||||
return []
|
||||
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
grade = self._resolve_review_employee_grade(payload, employee=employee)
|
||||
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
|
||||
band_label = policy.band_labels.get(grade_band or "", grade or "当前职级")
|
||||
declared_city = self._resolve_declared_travel_city(payload, policy)
|
||||
reason_corpus = self._build_review_reason_corpus(payload)
|
||||
has_exception_note = self._text_contains_any(reason_corpus, policy.standard_exception_keywords)
|
||||
standard_rule_name = str(getattr(policy, "standard_rule_name", "") or policy.rule_name)
|
||||
standard_rule_version = str(getattr(policy, "standard_rule_version", "") or policy.rule_version)
|
||||
|
||||
briefs: list[UserAgentReviewRiskBrief] = []
|
||||
amount_measurement_lines: list[str] = []
|
||||
seen_keys: set[str] = set()
|
||||
|
||||
def append_once(key: str, brief: UserAgentReviewRiskBrief) -> None:
|
||||
if key in seen_keys:
|
||||
return
|
||||
seen_keys.add(key)
|
||||
briefs.append(brief)
|
||||
|
||||
for card in document_cards:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
suggested_type = str(card.suggested_expense_type or "").strip().lower()
|
||||
card_text = self._build_review_document_card_text(card)
|
||||
document_type_label = resolve_document_type_label(document_type)
|
||||
amount = self._extract_amount_decimal_from_card(card)
|
||||
|
||||
if self._is_review_hotel_card(card):
|
||||
hotel_city = self._extract_policy_city_from_text(card_text, policy) or declared_city
|
||||
city_tier = policy.city_tiers.get(hotel_city, "tier_3")
|
||||
city_tier_label = self._format_travel_city_tier(city_tier)
|
||||
|
||||
if amount is None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法完成住宿差标测算。"
|
||||
)
|
||||
append_once(
|
||||
f"hotel-amount-missing-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="住宿金额待补充",
|
||||
level="warning",
|
||||
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算的住宿金额。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),住宿票据需要按员工职级、城市级别和每晚金额进行差标核算。"
|
||||
"当前票据缺少金额,系统无法判断是否超出差旅标准。"
|
||||
),
|
||||
suggestion="请在票据识别结果中补充或更正住宿金额,再继续核对报销单。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if grade_band is None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别住宿金额 {amount:.2f} 元,但缺少员工职级,无法匹配住宿标准。"
|
||||
)
|
||||
append_once(
|
||||
f"hotel-grade-missing-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="职级信息待确认",
|
||||
level="warning",
|
||||
content=f"{card.filename} 已识别住宿金额 {amount:.2f} 元,但当前员工职级缺失,无法匹配住宿标准。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),住宿标准按职级档位和城市级别配置。"
|
||||
"当前未能识别员工职级,因此无法完成创建前差标核算。"
|
||||
),
|
||||
suggestion="请确认员工档案或页面上下文中的职级信息,再重新进行差旅规则预检。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
cap = self._resolve_review_hotel_cap(
|
||||
policy,
|
||||
grade_band=grade_band,
|
||||
city=hotel_city,
|
||||
city_tier=city_tier,
|
||||
)
|
||||
if cap <= Decimal("0.00"):
|
||||
continue
|
||||
night_count = self._extract_review_hotel_night_count(card)
|
||||
nightly_amount = (amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01"))
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元,"
|
||||
f"按 {night_count} 晚折算 {nightly_amount:.2f} 元/晚;"
|
||||
f"适用标准为 {band_label}{city_tier_label} {cap:.2f} 元/晚,"
|
||||
f"{'超出标准' if nightly_amount > cap else '测算通过'}。"
|
||||
)
|
||||
if nightly_amount <= cap:
|
||||
continue
|
||||
|
||||
basis = (
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),{band_label} 在{city_tier_label}"
|
||||
f"住宿标准为 {cap:.2f} 元/晚;{card.filename} 识别为{document_type_label},"
|
||||
f"金额 {amount:.2f} 元,按 {night_count} 晚折算约 {nightly_amount:.2f} 元/晚。"
|
||||
)
|
||||
append_once(
|
||||
f"hotel-over-limit-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="住宿超标待说明" if not has_exception_note else "住宿超标提醒",
|
||||
level="high",
|
||||
content=(
|
||||
f"{card.filename} 住宿金额约 {nightly_amount:.2f} 元/晚,"
|
||||
f"超过 {band_label} {city_tier_label}标准 {cap:.2f} 元/晚。"
|
||||
),
|
||||
detail=(
|
||||
basis
|
||||
+ (
|
||||
"当前未识别到超标说明,创建单据前需要先补充原因。"
|
||||
if not has_exception_note
|
||||
else "当前已识别到例外说明,后续仍需审批人重点复核。"
|
||||
)
|
||||
),
|
||||
suggestion="补充超标说明、协议酒店满房/会议高峰等原因,或调整住宿金额后再继续。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if document_type == "meal_receipt":
|
||||
allowance = self._resolve_review_travel_allowance_standard(
|
||||
policy,
|
||||
declared_city=declared_city,
|
||||
card_text=card_text,
|
||||
)
|
||||
if allowance is not None:
|
||||
region_label, standard_amount = allowance
|
||||
if amount is None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法按{region_label}伙食补助标准测算。"
|
||||
)
|
||||
append_once(
|
||||
f"travel-meal-amount-missing-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅餐饮金额待补充",
|
||||
level="high",
|
||||
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算金额。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),差旅餐饮票据优先按出差补助标准中的伙食补助进行测算。"
|
||||
f"当前匹配区域为{region_label},但票据缺少金额,系统无法判断是否超出补助标准。"
|
||||
),
|
||||
suggestion="请在票据识别结果中补充或更正餐饮金额,再继续创建报销单。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
|
||||
f"适用《{standard_rule_name}》{region_label}伙食补助标准 {standard_amount:.2f} 元/天,"
|
||||
f"{'超出标准' if amount > standard_amount else '测算通过'}。"
|
||||
)
|
||||
if amount > standard_amount:
|
||||
append_once(
|
||||
f"travel-meal-allowance-over-limit-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅餐饮金额超出伙食补助标准",
|
||||
level="high",
|
||||
content=(
|
||||
f"{card.filename} 识别金额 {amount:.2f} 元,"
|
||||
f"超过{region_label}伙食补助标准 {standard_amount:.2f} 元/天。"
|
||||
),
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version})的出差补助标准,"
|
||||
f"{region_label}伙食补助为 {standard_amount:.2f} 元/天;"
|
||||
f"当前票据类型识别为{document_type_label},识别金额 {amount:.2f} 元。"
|
||||
"首轮上传阶段按单张票据先行测算,后续可结合出差天数和实际餐补口径复核。"
|
||||
),
|
||||
suggestion="如该票据属于差旅餐补,请调整金额或补充超标/拆分说明;如属于业务招待或普通餐费,请改为对应费用类型后再提交。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
scene_code = self._resolve_review_amount_scene_code(card, payload)
|
||||
scene_policy = rule_catalog.get_scene_policy(scene_code)
|
||||
scene_limit = self._resolve_review_scene_amount_limit(scene_policy)
|
||||
if scene_policy is not None and scene_limit is not None:
|
||||
metric_label = str(getattr(scene_limit, "metric_label", "") or scene_policy.label or "金额").strip()
|
||||
standard_amount = self._resolve_scene_standard_amount(scene_limit)
|
||||
if amount is None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法按{metric_label}测算。"
|
||||
)
|
||||
append_once(
|
||||
f"{scene_code}-amount-missing-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title=f"{scene_policy.label}金额待补充",
|
||||
level="warning",
|
||||
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算金额。",
|
||||
detail=(
|
||||
f"依据《{scene_policy.rule_name}》({scene_policy.rule_version}),"
|
||||
f"{scene_policy.label}需要按{metric_label}进行金额审核。当前票据缺少金额,系统无法判断是否合规。"
|
||||
),
|
||||
suggestion="请在票据识别结果中补充或更正金额,再继续核对报销单。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if standard_amount is not None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
|
||||
f"适用《{scene_policy.rule_name}》{metric_label}标准 {standard_amount:.2f} 元,"
|
||||
f"{'超出标准' if amount > standard_amount else '测算通过'}。"
|
||||
)
|
||||
|
||||
amount_risk = self._evaluate_review_scene_amount(
|
||||
amount=amount,
|
||||
limit_config=scene_limit,
|
||||
reason_text=reason_corpus,
|
||||
)
|
||||
if amount_risk is not None:
|
||||
severity, threshold = amount_risk
|
||||
append_once(
|
||||
f"{scene_code}-amount-over-limit-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title=f"{scene_policy.label}金额超标待说明",
|
||||
level="high" if severity == "high" else "warning",
|
||||
content=(
|
||||
f"{card.filename} 识别金额 {amount:.2f} 元,"
|
||||
f"超过{metric_label}标准 {threshold:.2f} 元。"
|
||||
),
|
||||
detail=(
|
||||
f"依据《{scene_policy.rule_name}》({scene_policy.rule_version}),"
|
||||
f"{scene_policy.label}按{metric_label}审核,当前票据类型识别为{document_type_label},"
|
||||
f"识别金额 {amount:.2f} 元,标准阈值 {threshold:.2f} 元。"
|
||||
),
|
||||
suggestion="请补充超标原因或拆分到更准确的费用类型;如属于例外场景,请在事由中写明业务背景。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
transport_class = self._detect_review_transport_class(card, policy)
|
||||
if transport_class and grade_band is not None:
|
||||
transport_kind, class_label, class_level = transport_class
|
||||
allowed_level = policy.transport_limits.get(grade_band, {}).get(transport_kind)
|
||||
if allowed_level is not None and class_level > allowed_level:
|
||||
append_once(
|
||||
f"transport-class-over-limit-{card.index}-{class_label}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="交通舱位超标待说明" if not has_exception_note else "交通舱位超标提醒",
|
||||
level="warning",
|
||||
content=f"{card.filename} 识别为 {class_label},{band_label} 当前默认不可报销该舱位/席别。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),{band_label} 的交通席别标准"
|
||||
f"未覆盖 {class_label};票据类型识别为{document_type_label}。"
|
||||
+ (
|
||||
"当前未识别到例外说明,创建单据前需要补充原因。"
|
||||
if not has_exception_note
|
||||
else "当前已识别到例外说明,后续仍需审批人重点复核。"
|
||||
)
|
||||
),
|
||||
suggestion="补充无直达、临时改签、行程变更等例外说明,或更换为符合标准的票据。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if document_type == "meal_receipt" and self._is_travel_review_context(payload, document_cards, claim_groups):
|
||||
if amount is not None:
|
||||
amount_measurement_lines.append(
|
||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;需确认按餐补、餐费或业务招待口径归口。"
|
||||
)
|
||||
append_once(
|
||||
f"travel-meal-card-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅餐饮票据待归口",
|
||||
level="warning",
|
||||
content=f"{card.filename} 已识别为餐饮票据,当前差旅报销单需要确认是否允许并入差旅费用。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version})的差旅票据预检口径,系统优先核算交通、住宿等差旅核心票据。"
|
||||
"餐饮票据可能需要按餐费或业务招待场景拆分,并补充同行人员或客户信息。"
|
||||
),
|
||||
suggestion="如属于差旅餐补,请补充制度允许口径;如属于招待或普通餐费,建议拆成对应费用类型单据。",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if suggested_type in {"travel", "hotel", "transport"} and document_type in {"other", "travel_ticket"}:
|
||||
append_once(
|
||||
f"travel-type-uncertain-{card.index}",
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅票据类型待确认",
|
||||
level="warning",
|
||||
content=f"{card.filename} 归入差旅场景,但票据类型仍需确认。",
|
||||
detail=(
|
||||
f"依据《{standard_rule_name}》({standard_rule_version}),差旅预检需要先明确票据是机票、火车票、住宿票据、打车票等,"
|
||||
"再匹配对应的金额或舱位规则。当前类型识别不够稳定。"
|
||||
),
|
||||
suggestion="请在附件识别结果中更正票据类型,或重新上传更清晰的附件后再继续。",
|
||||
),
|
||||
)
|
||||
|
||||
if amount_measurement_lines:
|
||||
briefs.insert(
|
||||
0,
|
||||
UserAgentReviewRiskBrief(
|
||||
title="附件金额测算结果",
|
||||
level="info",
|
||||
content="系统已根据首轮上传附件识别金额,并匹配当前可执行的报销标准进行测算。",
|
||||
detail=";".join(dict.fromkeys(amount_measurement_lines)),
|
||||
suggestion="如测算结果超标,请补充超标说明、调整金额或更正票据类型后再继续。",
|
||||
),
|
||||
)
|
||||
|
||||
return briefs
|
||||
|
||||
def _is_travel_review_context(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
document_cards: list[UserAgentReviewDocumentCard],
|
||||
claim_groups: list[UserAgentReviewClaimGroup],
|
||||
) -> bool:
|
||||
entity_expense_type = self._collect_entity_values(payload).get("expense_type_code", "")
|
||||
review_form_values = self._resolve_review_form_values(payload)
|
||||
form_expense_type = str(review_form_values.get("expense_type") or "").strip()
|
||||
message_context = " ".join(
|
||||
[
|
||||
str(payload.message or ""),
|
||||
str(payload.context_json.get("user_input_text") or ""),
|
||||
str(payload.context_json.get("expense_type") or ""),
|
||||
form_expense_type,
|
||||
]
|
||||
)
|
||||
if entity_expense_type in {"travel", "hotel", "transport"}:
|
||||
return True
|
||||
if any(group.group_code == "travel" or group.expense_type in {"travel", "hotel", "transport"} for group in claim_groups):
|
||||
return True
|
||||
if any(card.suggested_expense_type in {"travel", "hotel", "transport"} for card in document_cards):
|
||||
return True
|
||||
return any(keyword in message_context for keyword in ("差旅", "出差", "机票", "火车", "高铁", "酒店", "住宿"))
|
||||
|
||||
def _resolve_review_travel_allowance_standard(
|
||||
self,
|
||||
policy: RuntimeTravelPolicy,
|
||||
*,
|
||||
declared_city: str,
|
||||
card_text: str,
|
||||
) -> tuple[str, Decimal] | None:
|
||||
meal_limits = getattr(policy, "allowance_limits", {}).get("meal", {})
|
||||
if not meal_limits:
|
||||
return None
|
||||
|
||||
region_label = self._resolve_review_travel_allowance_region(
|
||||
" ".join([declared_city or "", card_text or ""])
|
||||
)
|
||||
amount = meal_limits.get(region_label)
|
||||
if amount is None and region_label != "其他地区":
|
||||
amount = meal_limits.get("其他地区")
|
||||
region_label = "其他地区"
|
||||
if amount is None:
|
||||
return None
|
||||
return region_label, Decimal(amount).quantize(Decimal("0.01"))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_review_travel_allowance_region(text: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(text or ""))
|
||||
if not normalized:
|
||||
return "其他地区"
|
||||
if any(keyword in normalized for keyword in ("境外", "国外", "海外")):
|
||||
return "国外"
|
||||
if any(keyword in normalized for keyword in ("香港", "澳门", "台湾", "港澳台")):
|
||||
return "港澳台"
|
||||
if "乌鲁木齐" in normalized:
|
||||
return "新疆-乌鲁木齐"
|
||||
if "新疆" in normalized:
|
||||
return "新疆-其他"
|
||||
if any(keyword in normalized for keyword in ("西藏", "拉萨")):
|
||||
return "西藏"
|
||||
if any(keyword in normalized for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")):
|
||||
return "直辖市/特区"
|
||||
return "其他地区"
|
||||
|
||||
def _resolve_review_amount_scene_code(
|
||||
self,
|
||||
card: UserAgentReviewDocumentCard,
|
||||
payload: UserAgentRequest,
|
||||
) -> str:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
suggested_type = str(card.suggested_expense_type or "").strip().lower()
|
||||
if document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"}:
|
||||
return "transport"
|
||||
if document_type == "meal_receipt":
|
||||
entity_values = self._collect_entity_values(payload)
|
||||
if suggested_type == "entertainment" or entity_values.get("expense_type_code") == "entertainment":
|
||||
return "entertainment"
|
||||
return "meal"
|
||||
if document_type == "hotel_invoice" or suggested_type == "hotel":
|
||||
return "hotel"
|
||||
if suggested_type in {
|
||||
"travel",
|
||||
"transport",
|
||||
"meal",
|
||||
"entertainment",
|
||||
"office",
|
||||
"meeting",
|
||||
"training",
|
||||
"communication",
|
||||
"welfare",
|
||||
"other",
|
||||
}:
|
||||
return suggested_type
|
||||
return self._collect_entity_values(payload).get("expense_type_code") or "other"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_review_scene_amount_limit(scene_policy: Any | None) -> Any | None:
|
||||
if scene_policy is None:
|
||||
return None
|
||||
return getattr(scene_policy, "item_amount_limit", None) or getattr(scene_policy, "claim_amount_limit", None)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_scene_standard_amount(limit_config: Any | None) -> Decimal | None:
|
||||
if limit_config is None:
|
||||
return None
|
||||
warn_amount = getattr(limit_config, "warn_amount", None)
|
||||
block_amount = getattr(limit_config, "block_amount", None)
|
||||
amount = warn_amount if warn_amount is not None else block_amount
|
||||
if amount is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(amount).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_review_scene_amount(
|
||||
*,
|
||||
amount: Decimal,
|
||||
limit_config: Any,
|
||||
reason_text: str,
|
||||
) -> tuple[str, Decimal] | None:
|
||||
block_amount = getattr(limit_config, "block_amount", None)
|
||||
warn_amount = getattr(limit_config, "warn_amount", None)
|
||||
exception_keywords = list(getattr(limit_config, "exception_keywords", []) or [])
|
||||
has_exception = UserAgentService._text_contains_any(reason_text, exception_keywords)
|
||||
|
||||
if block_amount is not None and amount > Decimal(block_amount):
|
||||
return ("high", Decimal(block_amount).quantize(Decimal("0.01")))
|
||||
if warn_amount is not None and amount > Decimal(warn_amount):
|
||||
return ("high", Decimal(warn_amount).quantize(Decimal("0.01")))
|
||||
return None
|
||||
|
||||
def _resolve_review_employee_grade(self, payload: UserAgentRequest, *, employee: Employee | None) -> str:
|
||||
if employee is not None and employee.grade:
|
||||
return str(employee.grade).strip()
|
||||
review_form_values = self._resolve_review_form_values(payload)
|
||||
for source in (
|
||||
review_form_values,
|
||||
payload.context_json,
|
||||
payload.tool_payload,
|
||||
):
|
||||
for key in ("employee_grade", "grade", "user_grade", "position_grade"):
|
||||
value = str(source.get(key) or "").strip() if isinstance(source, dict) else ""
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str:
|
||||
review_form_values = self._resolve_review_form_values(payload)
|
||||
parts = [
|
||||
str(payload.message or ""),
|
||||
str(payload.context_json.get("user_input_text") or ""),
|
||||
str(review_form_values.get("reason") or ""),
|
||||
str(review_form_values.get("business_reason") or ""),
|
||||
str(review_form_values.get("location") or ""),
|
||||
str(review_form_values.get("business_location") or ""),
|
||||
]
|
||||
return "\n".join(part.strip() for part in parts if part and part.strip())
|
||||
|
||||
def _resolve_declared_travel_city(self, payload: UserAgentRequest, policy: RuntimeTravelPolicy) -> str:
|
||||
review_form_values = self._resolve_review_form_values(payload)
|
||||
candidates = [
|
||||
str(review_form_values.get("business_location") or ""),
|
||||
str(review_form_values.get("location") or ""),
|
||||
self._resolve_location_value(payload),
|
||||
str(payload.message or ""),
|
||||
]
|
||||
for candidate in candidates:
|
||||
city = self._extract_policy_city_from_text(candidate, policy)
|
||||
if city:
|
||||
return city
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_review_document_card_text(card: UserAgentReviewDocumentCard) -> str:
|
||||
field_text = " ".join(f"{field.label}:{field.value}" for field in card.fields)
|
||||
return " ".join(
|
||||
[
|
||||
str(card.filename or ""),
|
||||
str(card.document_type or ""),
|
||||
str(card.scene_label or ""),
|
||||
str(card.summary or ""),
|
||||
field_text,
|
||||
]
|
||||
).strip()
|
||||
|
||||
@staticmethod
|
||||
def _is_review_hotel_card(card: UserAgentReviewDocumentCard) -> bool:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
suggested_type = str(card.suggested_expense_type or "").strip().lower()
|
||||
scene_label = str(card.scene_label or "").strip()
|
||||
return document_type == "hotel_invoice" or suggested_type == "hotel" or "住宿" in scene_label
|
||||
|
||||
@staticmethod
|
||||
def _extract_amount_decimal_from_card(card: UserAgentReviewDocumentCard) -> Decimal | None:
|
||||
for field in card.fields:
|
||||
if field.label != "金额":
|
||||
continue
|
||||
normalized = str(field.value or "").replace("元", "").replace("¥", "").replace("¥", "").replace(",", "").strip()
|
||||
try:
|
||||
amount = Decimal(normalized).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
if amount > Decimal("0.00"):
|
||||
return amount
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_review_hotel_night_count(card: UserAgentReviewDocumentCard) -> int:
|
||||
text = f"{card.summary or ''} {' '.join(f'{field.label}:{field.value}' for field in card.fields)}"
|
||||
match = TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN.search(text)
|
||||
if not match:
|
||||
return 1
|
||||
try:
|
||||
return max(1, int(match.group(1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def _extract_policy_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
|
||||
normalized = str(text or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
city_names = set(policy.city_tiers.keys())
|
||||
city_names.update(getattr(policy, "hotel_city_limits", {}).keys())
|
||||
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
|
||||
if city in normalized:
|
||||
return city
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_travel_city_tier(city_tier: str) -> str:
|
||||
return {
|
||||
"tier_1": "一线城市",
|
||||
"tier_2": "重点城市",
|
||||
"tier_3": "其他城市",
|
||||
}.get(str(city_tier or "").strip(), "当前城市")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_review_hotel_cap(
|
||||
policy: RuntimeTravelPolicy,
|
||||
*,
|
||||
grade_band: str,
|
||||
city: str,
|
||||
city_tier: str,
|
||||
) -> Decimal:
|
||||
normalized_city = str(city or "").strip()
|
||||
if normalized_city and getattr(policy, "hotel_city_limits", None):
|
||||
city_limits = policy.hotel_city_limits.get(normalized_city, {})
|
||||
city_cap = city_limits.get(grade_band)
|
||||
if city_cap is not None:
|
||||
return Decimal(city_cap).quantize(Decimal("0.01"))
|
||||
return Decimal(policy.hotel_limits.get(grade_band, {}).get(city_tier, Decimal("0.00"))).quantize(
|
||||
Decimal("0.01")
|
||||
)
|
||||
|
||||
def _detect_review_transport_class(
|
||||
self,
|
||||
card: UserAgentReviewDocumentCard,
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> tuple[str, str, int] | None:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
text = re.sub(r"\s+", "", self._build_review_document_card_text(card))
|
||||
if not text:
|
||||
return None
|
||||
|
||||
if document_type == "flight_itinerary" or any(keyword in text for keyword in ("机票", "航班", "登机牌")):
|
||||
for config in policy.flight_classes:
|
||||
label = str(config.keyword or "").strip()
|
||||
if label and label in text:
|
||||
return "flight", label, int(config.level)
|
||||
|
||||
if document_type == "train_ticket" or any(keyword in text for keyword in ("火车", "高铁", "动车", "铁路")):
|
||||
for config in policy.train_classes:
|
||||
label = str(config.keyword or "").strip()
|
||||
if label and label in text:
|
||||
return "train", label, int(config.level)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _text_contains_any(text: str, keywords: list[str] | tuple[str, ...]) -> bool:
|
||||
compact = re.sub(r"\s+", "", str(text or ""))
|
||||
return bool(compact) and any(str(keyword or "").strip() and str(keyword).strip() in compact for keyword in keywords)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]:
|
||||
@@ -2543,6 +3149,14 @@ class UserAgentService:
|
||||
"系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。"
|
||||
)
|
||||
|
||||
blocked_reasons = self._resolve_submission_blocked_reasons(payload)
|
||||
if blocked_reasons:
|
||||
reason_text = ";".join(dict.fromkeys(reason.strip("。;;") for reason in blocked_reasons if reason))
|
||||
return (
|
||||
f"AI预审未通过:{reason_text}。"
|
||||
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
|
||||
)
|
||||
|
||||
review_payload = UserAgentReviewPayload(
|
||||
intent_summary="",
|
||||
body_message="",
|
||||
@@ -3460,7 +4074,18 @@ class UserAgentService:
|
||||
evidence="来源于用户修改后的结构化表单。",
|
||||
)
|
||||
|
||||
merchant_value = self._extract_document_merchant_name(ocr_documents[0]) if ocr_documents else ""
|
||||
merchant_value = ""
|
||||
for document in ocr_documents:
|
||||
if str(document.get("document_type") or "").strip().lower() != "hotel_invoice":
|
||||
continue
|
||||
merchant_value = self._extract_document_merchant_name(document)
|
||||
if merchant_value:
|
||||
break
|
||||
if not merchant_value:
|
||||
for document in ocr_documents:
|
||||
merchant_value = self._extract_document_merchant_name(document)
|
||||
if merchant_value:
|
||||
break
|
||||
if merchant_value:
|
||||
return self._build_slot_value(
|
||||
value=merchant_value,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"file_name": "2月23_上海-武汉.pdf",
|
||||
"storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 24940,
|
||||
"uploaded_at": "2026-05-20T13:48:38.616319+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "2月23_上海-武汉.preview.png",
|
||||
"analysis": {
|
||||
"severity": "medium",
|
||||
"label": "中风险",
|
||||
"headline": "AI提示:附件存在明显待整改项",
|
||||
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
|
||||
"points": [
|
||||
"用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。"
|
||||
],
|
||||
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "train_ticket",
|
||||
"document_type_label": "火车/高铁票",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "354元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-05-18"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "中国铁路"
|
||||
},
|
||||
{
|
||||
"key": "invoice_number",
|
||||
"label": "票据号码",
|
||||
"value": "26319166100006175398"
|
||||
},
|
||||
{
|
||||
"key": "route",
|
||||
"label": "行程",
|
||||
"value": "上海-武汉"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "travel",
|
||||
"current_expense_type_label": "差旅费",
|
||||
"allowed_scene_labels": [
|
||||
"差旅"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"机票/航班行程单",
|
||||
"火车/高铁票"
|
||||
],
|
||||
"recognized_scene_code": "travel",
|
||||
"recognized_scene_label": "差旅票据",
|
||||
"recognized_document_type": "train_ticket",
|
||||
"recognized_document_type_label": "火车/高铁票",
|
||||
"mismatch_severity": "high",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
"ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
|
||||
"ocr_summary": "电子发票;(铁路电子客票);州",
|
||||
"ocr_avg_score": 0.9620026834309101,
|
||||
"ocr_line_count": 24,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"铁路电子客票",
|
||||
"电子客票",
|
||||
"铁路",
|
||||
"二等座"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"file_name": "2月20_武汉-上海.pdf",
|
||||
"storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 24995,
|
||||
"uploaded_at": "2026-05-20T13:48:21.652497+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "2月20_武汉-上海.preview.png",
|
||||
"analysis": {
|
||||
"severity": "medium",
|
||||
"label": "中风险",
|
||||
"headline": "AI提示:附件存在明显待整改项",
|
||||
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
|
||||
"points": [
|
||||
"用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。"
|
||||
],
|
||||
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "train_ticket",
|
||||
"document_type_label": "火车/高铁票",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "354元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-05-18"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "中国铁路"
|
||||
},
|
||||
{
|
||||
"key": "invoice_number",
|
||||
"label": "票据号码",
|
||||
"value": "26429165800002785705"
|
||||
},
|
||||
{
|
||||
"key": "route",
|
||||
"label": "行程",
|
||||
"value": "武汉-上海"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "travel",
|
||||
"current_expense_type_label": "差旅费",
|
||||
"allowed_scene_labels": [
|
||||
"差旅"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"机票/航班行程单",
|
||||
"火车/高铁票"
|
||||
],
|
||||
"recognized_scene_code": "travel",
|
||||
"recognized_scene_label": "差旅票据",
|
||||
"recognized_document_type": "train_ticket",
|
||||
"recognized_document_type_label": "火车/高铁票",
|
||||
"mismatch_severity": "high",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
"ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
|
||||
"ocr_summary": "电子发票;(铁路电子客票);州",
|
||||
"ocr_avg_score": 0.9580968717734019,
|
||||
"ocr_line_count": 24,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"铁路电子客票",
|
||||
"电子客票",
|
||||
"铁路",
|
||||
"二等座"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
BIN
server/storage/font-test-after-install.png
Normal file
BIN
server/storage/font-test-after-install.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -20,7 +20,7 @@
|
||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
||||
"ingest_agent_run_id": "run_8b0ead1e3c734a53"
|
||||
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||
},
|
||||
{
|
||||
"id": "a8f8465df08e455ebe133351721d49f8",
|
||||
@@ -36,12 +36,12 @@
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1,
|
||||
"ingest_status": 4,
|
||||
"ingest_status_updated_at": "2026-05-19T16:00:57.418443+00:00",
|
||||
"ingest_status_updated_at": "2026-05-20T16:00:02.515903+00:00",
|
||||
"ingest_completed_at": "",
|
||||
"ingest_document_name": "",
|
||||
"ingest_document_updated_at": "",
|
||||
"ingest_document_sha256": "",
|
||||
"ingest_agent_run_id": "run_57f2d8727aaa4374"
|
||||
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -24,11 +24,14 @@ from app.core.agent_enums import (
|
||||
)
|
||||
from app.core.config import SERVER_DIR
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetCreate,
|
||||
AgentAssetReviewCreate,
|
||||
AgentAssetVersionCreate,
|
||||
)
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
@@ -41,6 +44,7 @@ from app.services.agent_runs import AgentRunService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||
from app.services.settings import OnlyOfficeRuntimeConfig
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -618,6 +622,126 @@ def test_agent_asset_service_returns_travel_policy_rule_detail() -> None:
|
||||
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
|
||||
|
||||
|
||||
def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -> None:
|
||||
with build_session() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
travel_spreadsheet_rule = db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||
)
|
||||
assert travel_spreadsheet_rule is not None
|
||||
travel_spreadsheet_rule.status = AgentAssetStatus.REVIEW.value
|
||||
db.commit()
|
||||
|
||||
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||||
|
||||
assert catalog.travel_policy is not None
|
||||
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则"
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500
|
||||
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
|
||||
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
|
||||
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
|
||||
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1
|
||||
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9001",
|
||||
name="测试员工",
|
||||
email="traveler@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"),
|
||||
CurrentUserContext(
|
||||
username="traveler@example.com",
|
||||
name="测试员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.rule_name == "公司差旅费报销规则"
|
||||
assert result.grade == "P4"
|
||||
assert result.grade_band == "mid"
|
||||
assert result.matched_city == "北京"
|
||||
assert result.hotel_rate == 450
|
||||
assert result.hotel_amount == 1350
|
||||
assert result.allowance_region == "直辖市/特区"
|
||||
assert result.total_allowance_rate == 100
|
||||
assert result.allowance_amount == 300
|
||||
assert result.total_amount == 1650
|
||||
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text
|
||||
assert "参考可报销总金额为 1650.00 元" in result.summary_text
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9002",
|
||||
name="其他地区员工",
|
||||
email="other-region@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=2, location="吉林延边"),
|
||||
CurrentUserContext(
|
||||
username="other-region@example.com",
|
||||
name="其他地区员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.matched_city == "延边(其他地区)"
|
||||
assert result.city_tier == "tier_3"
|
||||
assert result.hotel_rate == 380
|
||||
assert result.hotel_amount == 760
|
||||
assert result.allowance_region == "其他地区"
|
||||
assert result.total_allowance_rate == 90
|
||||
assert result.allowance_amount == 180
|
||||
assert result.total_amount == 940
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9003",
|
||||
name="无效地点员工",
|
||||
email="invalid-location@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="未识别为有效出差地区"):
|
||||
TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=2, location="背景"),
|
||||
CurrentUserContext(
|
||||
username="invalid-location@example.com",
|
||||
name="无效地点员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
|
||||
@@ -51,6 +51,27 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
|
||||
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
|
||||
|
||||
|
||||
def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None:
|
||||
insight = build_document_insight(
|
||||
filename="铁路电子客票.pdf",
|
||||
summary="电子发票(铁路电子客票)",
|
||||
text=(
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26319166100006175398\n"
|
||||
"上海虹桥站\n"
|
||||
"武汉站\n"
|
||||
"G456\n"
|
||||
"二等座\n"
|
||||
"票价:¥354.00"
|
||||
),
|
||||
)
|
||||
|
||||
assert insight.document_type == "train_ticket"
|
||||
assert insight.document_type_label == "火车/高铁票"
|
||||
assert insight.scene_code == "travel"
|
||||
assert any(field.label == "金额" and field.value == "354元" for field in insight.fields)
|
||||
|
||||
|
||||
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.organization import OrganizationUnit
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.ocr import OcrService
|
||||
@@ -722,6 +723,82 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
|
||||
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
|
||||
|
||||
|
||||
def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
) -> OcrRecognizeBatchRead:
|
||||
return OcrRecognizeBatchRead(
|
||||
total_file_count=1,
|
||||
success_count=1,
|
||||
documents=[
|
||||
OcrRecognizeDocumentRead(
|
||||
filename="train-ticket.png",
|
||||
media_type="image/png",
|
||||
text="中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00",
|
||||
summary="铁路电子客票,票价 354 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅费",
|
||||
document_fields=[
|
||||
{"key": "fare", "label": "票价", "value": "¥354.00"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="北京")
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.items[0].item_amount = Decimal("0.00")
|
||||
claim.items[0].invoice_id = None
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
updated = service.upload_claim_item_attachment(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
filename="train-ticket.png",
|
||||
content=b"fake-image-bytes",
|
||||
media_type="image/png",
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated["item_amount"] == Decimal("354.00")
|
||||
assert updated["claim_amount"] == Decimal("354.00")
|
||||
db.refresh(claim)
|
||||
assert claim.items[0].item_amount == Decimal("354.00")
|
||||
assert claim.amount == Decimal("354.00")
|
||||
uploaded_meta = service.get_claim_item_attachment_meta(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
assert uploaded_meta is not None
|
||||
assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
|
||||
assert any(
|
||||
field["label"] == "票价" and field["value"] == "¥354.00"
|
||||
for field in uploaded_meta["document_info"]["fields"]
|
||||
)
|
||||
|
||||
|
||||
def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
@@ -1502,7 +1579,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
|
||||
|
||||
|
||||
def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
@@ -1545,10 +1622,46 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
deleted = service.delete_claim(claim_id, current_user)
|
||||
with pytest.raises(ValueError, match="只有高级管理人员可以删除"):
|
||||
service.delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_executive_can_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-delete@example.com",
|
||||
name="高管",
|
||||
role_codes=["executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-DEL-EXEC-101",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-RET-101"
|
||||
assert deleted.claim_no == "EXP-DEL-EXEC-101"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
@@ -1675,6 +1788,56 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
|
||||
)
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
name="财务复核",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-FIN-APP-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion="票据与明细一致,同意入账。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "归档入账"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "finance_approval"
|
||||
and flag.get("event_type") == "expense_claim_finance_approval"
|
||||
and flag.get("opinion") == "票据与明细一致,同意入账。"
|
||||
and flag.get("previous_approval_stage") == "财务审批"
|
||||
and flag.get("next_approval_stage") == "归档入账"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-returned@example.com",
|
||||
@@ -1836,6 +1999,16 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
claim.risk_flags_json = [return_flag]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
conversation = AgentConversationService(db).get_or_create_conversation(
|
||||
conversation_id=None,
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
context_json={
|
||||
"session_type": "expense",
|
||||
"draft_claim_id": claim.id,
|
||||
},
|
||||
)
|
||||
conversation_id = conversation.conversation_id
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
@@ -1848,6 +2021,7 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
and flag.get("return_event_id") == "return-event-submit"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
assert AgentConversationService(db).get_conversation(conversation_id) is None
|
||||
|
||||
|
||||
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
|
||||
@@ -2001,3 +2175,57 @@ def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_a
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-MGR-201"
|
||||
|
||||
|
||||
def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approval-list@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-LIST-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="transport",
|
||||
reason="直属领导待审",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-LIST-202",
|
||||
employee_name="李四",
|
||||
department_name="销售部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="meal",
|
||||
reason="财务待审",
|
||||
location="杭州",
|
||||
amount=Decimal("188.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
|
||||
|
||||
@@ -177,3 +177,80 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
assert any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields)
|
||||
assert recognized.lines[0].page_index == 0
|
||||
assert recognized.lines[1].page_index == 1
|
||||
|
||||
|
||||
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-page")
|
||||
return [page]
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
*,
|
||||
python_bin: str,
|
||||
worker_path: str,
|
||||
input_paths: list[Path],
|
||||
) -> dict:
|
||||
return {
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"documents": [
|
||||
{
|
||||
"input_path": str(input_paths[0]),
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"text": "□□□□□□\n□□□□:26319166100006175398\nG456\n□□:□354.00",
|
||||
"summary": "□□□□□□;□□□□:26319166100006175398",
|
||||
"avg_score": 0.88,
|
||||
"line_count": 4,
|
||||
"page_count": 1,
|
||||
"warnings": [],
|
||||
"lines": [
|
||||
{
|
||||
"text": "□□□□□□",
|
||||
"score": 0.88,
|
||||
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
|
||||
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
|
||||
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||
monkeypatch.setattr(
|
||||
OcrService,
|
||||
"_extract_pdf_text_layer",
|
||||
lambda self, pdf_path: (
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26319166100006175398\n"
|
||||
"上海虹桥站\n"
|
||||
"武汉站\n"
|
||||
"G456\n"
|
||||
"票价:¥354.00"
|
||||
),
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
result = OcrService().recognize_files(
|
||||
[
|
||||
("train-ticket.pdf", b"%PDF-1.4 fake", "application/pdf"),
|
||||
]
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
recognized = result.documents[0]
|
||||
assert "电子发票(铁路电子客票)" in recognized.text
|
||||
assert "上海虹桥站" in recognized.text
|
||||
assert "□□□□" not in recognized.summary
|
||||
assert recognized.document_type == "train_ticket"
|
||||
assert recognized.preview_kind == ""
|
||||
assert recognized.preview_data_url == ""
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
|
||||
|
||||
@@ -96,6 +97,8 @@ def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert claim.submitted_at is not None
|
||||
assert response.conversation_id
|
||||
assert AgentConversationService(db).get_conversation(response.conversation_id) is None
|
||||
|
||||
|
||||
def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
@@ -165,6 +168,8 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
|
||||
assert response.status == "succeeded"
|
||||
assert result["draft_payload"]["status"] == "draft"
|
||||
assert response.conversation_id
|
||||
assert AgentConversationService(db).get_conversation(response.conversation_id) is not None
|
||||
assert "AI预审暂未通过" in result["answer"]
|
||||
assert "所属部门未完善" in result["answer"]
|
||||
assert "next_step" not in actions
|
||||
|
||||
@@ -345,9 +345,17 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["opinion"] == "情况属实,同意报销。"
|
||||
and item["operator"] == "李经理"
|
||||
and item["next_approval_stage"] == "财务审批"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
approval_events = [
|
||||
item
|
||||
for item in payload["risk_flags_json"]
|
||||
if item["source"] == "manual_approval"
|
||||
]
|
||||
assert approval_events[0]["operator"] == "李经理"
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
|
||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.user_agent import UserAgentService
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
@@ -1096,11 +1101,11 @@ def test_user_agent_prefers_larger_decimal_amount_from_ocr_text_candidates() ->
|
||||
assert slot_map["amount"].value == "13.40元"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我上传了打车票据,帮我生成报销草稿",
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
@@ -1147,15 +1152,465 @@ def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.review_payload.document_cards[0].preview_kind == "image"
|
||||
assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
assert response.review_payload.document_cards[0].preview_kind == "image"
|
||||
assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prechecks_travel_receipts_against_policy_and_hides_old_briefs(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
employee = Employee(
|
||||
employee_no="E-TRAVEL-001",
|
||||
name="张三",
|
||||
email="pytest-travel@example.com",
|
||||
position="实施顾问",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-HISTORY-001",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_name="交付部",
|
||||
expense_type="travel",
|
||||
reason="历史差旅记录",
|
||||
location="北京",
|
||||
amount=Decimal("680.00"),
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC) - timedelta(days=7),
|
||||
status="draft",
|
||||
risk_flags_json=[{"label": "历史风险"}],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
query = "我去北京出差住酒店,上传了北京酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京中心酒店 住宿 1 晚 金额 680 元",
|
||||
"text": "北京中心酒店 住宿 1 晚 金额 680 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "680"},
|
||||
{"key": "merchant", "label": "酒店", "value": "北京中心酒店"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
service = UserAgentService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_build_citations",
|
||||
lambda payload: [
|
||||
UserAgentCitation(
|
||||
source_type="rule",
|
||||
code="rule.expense.travel_risk_control_standard",
|
||||
title="差旅报销风险管控制度",
|
||||
version="v1.1.0",
|
||||
excerpt="住宿费按职级和城市分级限额执行。",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
titles = [item.title for item in response.review_payload.risk_briefs]
|
||||
assert "历史报销画像" not in titles
|
||||
assert "制度注意事项" not in titles
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.title}\n{hotel_brief.content}\n{hotel_brief.detail}\n{hotel_brief.suggestion}"
|
||||
assert "北京酒店发票.png" in combined
|
||||
assert "P4-P5" in combined
|
||||
assert "680.00" in combined
|
||||
assert "450.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
assert "补充超标说明" in combined
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["merchant_name"].value == "北京中心酒店"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prefers_hotel_invoice_for_hotel_name() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,上传了火车票和酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京南站火车票.png", "北京中心酒店发票.png"],
|
||||
"attachment_count": 2,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京南站火车票.png",
|
||||
"document_type": "train_ticket",
|
||||
"summary": "广州南至北京南 高铁二等座 金额 560 元",
|
||||
"text": "广州南至北京南 高铁二等座 金额 560 元",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "560"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
{
|
||||
"filename": "北京中心酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"text": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "450"},
|
||||
{"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-hotel-name@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-hotel-name@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["merchant_name"].value == "北京中心酒店"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,上传了一张打车票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["北京打车票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京打车票.png",
|
||||
"document_type": "taxi_receipt",
|
||||
"summary": "北京网约车 打车票 支付金额 360 元",
|
||||
"text": "北京网约车 打车票 支付金额 360 元",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "支付金额", "value": "360"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
amount_brief = next(item for item in response.review_payload.risk_briefs if "交通费金额超标" in item.title)
|
||||
combined = f"{amount_brief.title}\n{amount_brief.content}\n{amount_brief.detail}\n{amount_brief.suggestion}"
|
||||
assert "北京打车票.png" in combined
|
||||
assert "360.00" in combined
|
||||
assert "300.00" in combined
|
||||
assert "单笔交通金额" in combined
|
||||
assert "报销场景提交与附件标准" in combined
|
||||
assert amount_brief.level == "high"
|
||||
assert any(item.title == "附件金额测算结果" for item in response.review_payload.risk_briefs)
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_finance_spreadsheet_hotel_amount_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
employee = Employee(
|
||||
employee_no="E-TRAVEL-XLSX-001",
|
||||
name="测算员工",
|
||||
email="pytest-travel-xlsx@example.com",
|
||||
position="基层经理",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
query = "测算员工去北京出差住宿,上传了北京酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "测算员工",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京酒店 住宿 1 晚 金额 480 元",
|
||||
"text": "北京酒店 住宿 1 晚 金额 480 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "480"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-xlsx@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-xlsx@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.content}\n{hotel_brief.detail}"
|
||||
assert "480.00" in combined
|
||||
assert "450.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_spreadsheet_city_hotel_standard_not_default_tier() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
|
||||
query = "我去张家口出差住宿,上传了张家口酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["张家口酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "张家口酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "张家口酒店 住宿 1 晚 金额 320 元",
|
||||
"text": "张家口酒店 住宿 1 晚 金额 320 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "320"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-city@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-city@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.content}\n{hotel_brief.detail}"
|
||||
assert "320.00" in combined
|
||||
assert "300.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_finance_spreadsheet_meal_allowance_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
|
||||
query = "我去北京出差,上传了一张餐饮发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["北京餐饮发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京餐饮发票.png",
|
||||
"document_type": "meal_receipt",
|
||||
"summary": "北京餐饮发票 金额 90 元",
|
||||
"text": "北京餐饮发票 金额 90 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "90"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-meal@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-meal@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
meal_brief = next(item for item in response.review_payload.risk_briefs if "伙食补助标准" in item.title)
|
||||
combined = f"{meal_brief.title}\n{meal_brief.content}\n{meal_brief.detail}\n{meal_brief.suggestion}"
|
||||
assert "北京餐饮发票.png" in combined
|
||||
assert "90.00" in combined
|
||||
assert "65.00" in combined
|
||||
assert "直辖市/特区" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
assert meal_brief.level == "high"
|
||||
measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算结果")
|
||||
assert "伙食补助标准 65.00" in measurement.detail
|
||||
|
||||
|
||||
def test_user_agent_filters_deprecated_review_risk_briefs() -> None:
|
||||
filtered = UserAgentService._filter_deprecated_review_risk_briefs(
|
||||
[
|
||||
UserAgentReviewRiskBrief(title="历史报销画像", level="info", content="旧画像"),
|
||||
UserAgentReviewRiskBrief(title="用户画像", level="info", content="旧画像"),
|
||||
UserAgentReviewRiskBrief(title="制度注意事项", level="info", content="旧制度提示"),
|
||||
UserAgentReviewRiskBrief(title="住宿超标待说明", level="high", content="保留"),
|
||||
]
|
||||
)
|
||||
|
||||
assert [item.title for item in filtered] == ["住宿超标待说明"]
|
||||
|
||||
|
||||
def test_user_agent_submission_blocked_risk_level_only_marks_amount_reasons_high() -> None:
|
||||
assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high"
|
||||
assert UserAgentService._resolve_submission_blocked_risk_level("缺少直属领导或参与人员信息") == "warning"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差住酒店,帮我生成差旅费报销草稿并进入下一步提交"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京酒店 住宿 1 晚 金额 680 元",
|
||||
"text": "北京酒店 住宿 1 晚 金额 680 元",
|
||||
"avg_score": 0.94,
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={
|
||||
"submission_blocked": True,
|
||||
"submission_blocked_reasons": ["住宿金额超出当前职级差标,且未补充超标说明。"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.answer == response.review_payload.body_message
|
||||
assert response.answer.startswith("AI预审未通过:住宿金额超出当前职级差标")
|
||||
assert "整改后再继续提交" in response.answer
|
||||
assert response.review_payload.can_proceed is False
|
||||
blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示")
|
||||
assert blocked_brief.level == "high"
|
||||
assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs)
|
||||
|
||||
|
||||
def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我上传了两张票据,帮我生成报销草稿",
|
||||
user_id="pytest",
|
||||
)
|
||||
|
||||
@@ -813,6 +813,24 @@
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.message-meta-chip.high {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.message-meta-chip.medium {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.message-meta-chip.low {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.risk-chip,
|
||||
.message-risk-chip {
|
||||
background: #fff1f2;
|
||||
@@ -1262,6 +1280,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.travel-calculator-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-btn.composer-side-btn.active {
|
||||
border-color: rgba(59, 130, 246, 0.42);
|
||||
background: rgba(239, 246, 255, 0.96);
|
||||
@@ -1286,6 +1308,84 @@
|
||||
0 4px 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.travel-calculator-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
width: min(300px, calc(100vw - 48px));
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.92);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow:
|
||||
0 18px 40px rgba(15, 23, 42, 0.16),
|
||||
0 4px 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.travel-calculator-mini-head {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.travel-calculator-mini-head strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.travel-calculator-mini-head span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.travel-calculator-form {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.travel-calculator-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.travel-calculator-field span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.travel-calculator-field input {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.92);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.travel-calculator-field input:focus {
|
||||
border-color: rgba(59, 130, 246, 0.46);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.travel-calculator-error {
|
||||
margin: 0;
|
||||
color: #dc2626;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.composer-date-mode-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -1984,6 +2084,11 @@
|
||||
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.review-side-metric-card.wide {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 104px;
|
||||
}
|
||||
|
||||
.review-side-metric-card.invalid {
|
||||
border-color: rgba(239, 68, 68, 0.34);
|
||||
background: rgba(254, 242, 242, 0.72);
|
||||
@@ -2038,6 +2143,14 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.review-inline-textarea {
|
||||
min-height: 82px;
|
||||
padding: 9px 10px;
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.review-inline-input.invalid {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #b91c1c;
|
||||
@@ -2225,16 +2338,6 @@
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.84) 0%, rgba(255, 249, 238, 0.8) 100%);
|
||||
}
|
||||
|
||||
.review-side-risk-score {
|
||||
color: #f97316;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.review-side-risk-score.empty {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.review-side-risk-summary {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
@@ -2281,7 +2384,7 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.review-side-risk-item.warning .review-side-risk-icon {
|
||||
.review-side-risk-item.medium .review-side-risk-icon {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
@@ -2291,6 +2394,11 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.review-side-risk-item.low .review-side-risk-icon {
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.review-side-risk-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -4201,93 +4309,6 @@
|
||||
flex: 1 1 168px;
|
||||
}
|
||||
|
||||
.review-risk-detail-modal {
|
||||
width: min(560px, calc(100vw - 40px));
|
||||
max-height: min(760px, calc(100vh - 48px));
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e7eef6;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(245, 158, 11, 0.10), transparent 28%),
|
||||
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(15, 23, 42, 0.22),
|
||||
0 2px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.review-risk-detail-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.review-risk-detail-head h3 {
|
||||
margin: 12px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 21px;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.review-risk-detail-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.review-risk-detail-level {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0284c7;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.review-risk-detail-level.warning {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.review-risk-detail-level.high {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.review-risk-detail-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.review-risk-detail-section strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.review-risk-detail-section p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.review-edit-modal {
|
||||
max-height: min(860px, calc(100vh - 48px));
|
||||
display: grid;
|
||||
@@ -4723,6 +4744,10 @@
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.travel-calculator-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dialog-toolbar {
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
])
|
||||
|
||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||
'保存草稿',
|
||||
'创建单据',
|
||||
'待提交',
|
||||
'AI预审',
|
||||
'直属领导审批',
|
||||
@@ -270,6 +270,21 @@ function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isEmailLike(value) {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
|
||||
}
|
||||
|
||||
function resolveDisplayName(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = normalizeText(value)
|
||||
if (normalized && !isEmailLike(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getRiskFlags(claim) {
|
||||
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
|
||||
}
|
||||
@@ -344,7 +359,7 @@ function buildCompletedStepMeta(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
const employeeName = normalizeText(claim?.employee_name) || '申请人'
|
||||
|
||||
if (stepLabel === '保存草稿') {
|
||||
if (stepLabel === '创建单据') {
|
||||
const createdAt = formatDateTime(claim?.created_at)
|
||||
return buildProgressStepMeta(`${employeeName}创建`, createdAt)
|
||||
}
|
||||
@@ -362,7 +377,12 @@ function buildCompletedStepMeta(claim, label) {
|
||||
if (stepLabel === '直属领导审批' || stepLabel === '财务审批') {
|
||||
const approvalEvent = findApprovalEventForStep(claim, stepLabel)
|
||||
if (approvalEvent) {
|
||||
const operator = normalizeText(approvalEvent.operator) || (stepLabel === '财务审批' ? '财务' : '审批人')
|
||||
const operator = resolveDisplayName(
|
||||
approvalEvent.operator,
|
||||
approvalEvent.operator_name,
|
||||
approvalEvent.operatorName,
|
||||
stepLabel === '直属领导审批' ? claim?.manager_name : ''
|
||||
) || (stepLabel === '财务审批' ? '财务' : '直属领导')
|
||||
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
|
||||
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
|
||||
}
|
||||
@@ -383,7 +403,7 @@ function buildCompletedStepMeta(claim, label) {
|
||||
|
||||
function resolveCurrentStepStartedAt(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
if (stepLabel === '保存草稿') {
|
||||
if (stepLabel === '创建单据') {
|
||||
return claim?.created_at
|
||||
}
|
||||
if (stepLabel === '待提交') {
|
||||
@@ -539,7 +559,7 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
employeeName: String(claim?.employee_name || '').trim() || '待补充',
|
||||
employeePosition: String(claim?.employee_position || '').trim(),
|
||||
employeeGrade: String(claim?.employee_grade || '').trim(),
|
||||
managerName: String(claim?.manager_name || '').trim(),
|
||||
managerName: resolveDisplayName(claim?.manager_name),
|
||||
roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [],
|
||||
entity: '',
|
||||
typeCode,
|
||||
|
||||
@@ -12,6 +12,13 @@ export function fetchExpenseClaimDetail(claimId) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
|
||||
}
|
||||
|
||||
export function calculateTravelReimbursement(payload = {}) {
|
||||
return apiRequest('/reimbursements/travel-calculator', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function createExpenseClaimItem(claimId, payload = {}) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -19,8 +19,9 @@ const VIEW_ROLE_RULES = {
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive'])
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
|
||||
function normalizedRoleCodes(user) {
|
||||
if (!user) {
|
||||
@@ -60,6 +61,14 @@ export function canReturnExpenseClaims(user) {
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canApproveLeaderExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import { canManageExpenseClaims } from './accessControl.js'
|
||||
import {
|
||||
canApproveLeaderExpenseClaims,
|
||||
canManageExpenseClaims,
|
||||
isFinanceUser
|
||||
} from './accessControl.js'
|
||||
|
||||
export function canProcessApprovalRequest(request, currentUser) {
|
||||
const node = String(request?.workflowNode || '').trim()
|
||||
@@ -14,12 +18,18 @@ export function canProcessApprovalRequest(request, currentUser) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
if (isFinanceUser(currentUser) && node.includes('财务')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isLeaderApprovalNode = (
|
||||
node.includes('直属领导')
|
||||
|| node.includes('领导审批')
|
||||
|| node.includes('部门负责人')
|
||||
|| node.includes('负责人审批')
|
||||
)
|
||||
|
||||
return canApproveLeaderExpenseClaims(currentUser) && isLeaderApprovalNode
|
||||
}
|
||||
|
||||
export function listPendingApprovalRequests(claimsPayload, currentUser) {
|
||||
|
||||
@@ -181,6 +181,21 @@ function normalizeRoleLabels(value) {
|
||||
return text ? [text] : []
|
||||
}
|
||||
|
||||
function isEmailLike(value) {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(value || '').trim())
|
||||
}
|
||||
|
||||
function resolveDisplayName(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized && !isEmailLike(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function normalizeRequestForUi(request) {
|
||||
if (!request) {
|
||||
return null
|
||||
@@ -255,7 +270,12 @@ export function normalizeRequestForUi(request) {
|
||||
String(request.profilePosition || request.employeePosition || request.employee_position || request.position || '').trim()
|
||||
|| '待补充',
|
||||
profileGrade: String(request.profileGrade || request.employeeGrade || request.employee_grade || request.grade || '').trim() || '待补充',
|
||||
profileManager: String(request.profileManager || request.managerName || request.manager_name || request.manager || '').trim() || '待补充',
|
||||
profileManager: resolveDisplayName(
|
||||
request.profileManager,
|
||||
request.managerName,
|
||||
request.manager_name,
|
||||
request.manager
|
||||
) || '待补充',
|
||||
roleLabels,
|
||||
profileAvatar:
|
||||
String(request.person || request.applicant || request.employeeName || '申').trim().slice(0, 1) || '申'
|
||||
|
||||
@@ -121,7 +121,14 @@
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
|
||||
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
|
||||
<span
|
||||
v-for="item in message.meta"
|
||||
:key="item"
|
||||
class="message-meta-chip"
|
||||
:class="message.metaTone"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
|
||||
@@ -548,6 +555,72 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="travel-calculator-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn composer-side-btn travel-calculator-trigger"
|
||||
:class="{ active: travelCalculatorOpen }"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
aria-label="差旅计算器"
|
||||
title="差旅计算器"
|
||||
:aria-expanded="travelCalculatorOpen"
|
||||
@click.stop="toggleTravelCalculator"
|
||||
>
|
||||
<i class="mdi mdi-calculator"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="travelCalculatorOpen"
|
||||
class="travel-calculator-popover"
|
||||
role="dialog"
|
||||
aria-label="差旅计算器"
|
||||
@click.stop
|
||||
>
|
||||
<div class="travel-calculator-mini-head">
|
||||
<strong>差旅计算器</strong>
|
||||
<span>按规则中心差旅表测算</span>
|
||||
</div>
|
||||
<div class="travel-calculator-form">
|
||||
<label class="travel-calculator-field">
|
||||
<span>实际天数</span>
|
||||
<input
|
||||
v-model="travelCalculatorForm.days"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
:disabled="travelCalculatorBusy"
|
||||
@keydown.enter.prevent="submitTravelCalculator"
|
||||
/>
|
||||
</label>
|
||||
<label class="travel-calculator-field">
|
||||
<span>出差地点</span>
|
||||
<input
|
||||
v-model="travelCalculatorForm.location"
|
||||
type="text"
|
||||
placeholder="例如:北京、成都"
|
||||
:disabled="travelCalculatorBusy"
|
||||
@keydown.enter.prevent="submitTravelCalculator"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="travelCalculatorError" class="travel-calculator-error">
|
||||
{{ travelCalculatorError }}
|
||||
</p>
|
||||
<div class="composer-date-popover-actions">
|
||||
<button type="button" class="composer-date-cancel-btn" :disabled="travelCalculatorBusy" @click="closeTravelCalculator">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="composer-date-apply-btn"
|
||||
:disabled="!travelCalculatorCanSubmit"
|
||||
@click="submitTravelCalculator"
|
||||
>
|
||||
{{ travelCalculatorBusy ? '计算中...' : 'AI计算' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-shell">
|
||||
@@ -783,7 +856,8 @@
|
||||
:class="{
|
||||
editable: item.editor,
|
||||
editing: reviewInlineEditorKey === item.key,
|
||||
invalid: Boolean(reviewInlineErrors[item.key])
|
||||
invalid: Boolean(reviewInlineErrors[item.key]),
|
||||
wide: item.wide
|
||||
}"
|
||||
@click="openInlineReviewEditor(item.key)"
|
||||
>
|
||||
@@ -831,6 +905,19 @@
|
||||
@keydown.enter.prevent="commitInlineReviewEditor"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'textarea'">
|
||||
<textarea
|
||||
v-model="reviewInlineForm[item.modelKey]"
|
||||
class="review-inline-input review-inline-textarea"
|
||||
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
|
||||
:placeholder="item.placeholder"
|
||||
rows="3"
|
||||
@click.stop
|
||||
@input="clearInlineReviewFieldError(item.key)"
|
||||
@blur="commitInlineReviewEditor"
|
||||
@keydown.enter.stop
|
||||
></textarea>
|
||||
</template>
|
||||
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'select'">
|
||||
<div class="review-inline-select-list" @click.stop>
|
||||
<button
|
||||
@@ -1091,12 +1178,9 @@
|
||||
<section class="review-side-card review-side-risk-card">
|
||||
<div class="review-side-head">
|
||||
<div class="review-side-head-copy">
|
||||
<strong>合规提醒 / 风险评分</strong>
|
||||
<p>结合本体附件要求和识别结果,集中查看当前票据风险。</p>
|
||||
<strong>差旅合规提示</strong>
|
||||
<p>结合票据识别结果与差旅规则,逐项查看需要处理的风险点。</p>
|
||||
</div>
|
||||
<span class="review-side-risk-score" :class="{ empty: reviewRiskScore === null }">
|
||||
{{ reviewRiskScore === null ? '无' : `${reviewRiskScore}/100` }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
|
||||
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
|
||||
@@ -1106,9 +1190,9 @@
|
||||
type="button"
|
||||
class="review-side-risk-item"
|
||||
:class="item.level"
|
||||
@click="openReviewRiskDetail(item)"
|
||||
@click="appendReviewRiskBriefToConversation(item)"
|
||||
>
|
||||
<span class="review-side-risk-icon">
|
||||
<span class="review-side-risk-icon" :title="item.levelLabel">
|
||||
<i :class="item.icon"></i>
|
||||
</span>
|
||||
<span class="review-side-risk-copy">
|
||||
@@ -1116,7 +1200,6 @@
|
||||
<p>{{ item.summary }}</p>
|
||||
</span>
|
||||
<span class="review-side-risk-meta">
|
||||
{{ item.levelLabel }}
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</span>
|
||||
</button>
|
||||
@@ -1125,8 +1208,8 @@
|
||||
<span class="review-side-empty-icon">
|
||||
<i class="mdi mdi-shield-check-outline"></i>
|
||||
</span>
|
||||
<strong>暂无风险评分</strong>
|
||||
<p>当前版本还没有返回结构化风险评分结果,这里先不展示虚拟分数。</p>
|
||||
<strong>暂无风险提示</strong>
|
||||
<p>当前没有需要额外处理的结构化风险点。</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1222,41 +1305,6 @@
|
||||
@confirm="confirmCancelReview"
|
||||
/>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="reviewRiskDetailDialog.open" class="assistant-overlay review-overlay">
|
||||
<section class="review-risk-detail-modal">
|
||||
<header class="review-risk-detail-head">
|
||||
<div>
|
||||
<span class="assistant-badge warning">{{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }}</span>
|
||||
<h3>{{ reviewRiskDetailDialog.item?.title || '风险提示' }}</h3>
|
||||
</div>
|
||||
<button class="close-btn" type="button" aria-label="关闭风险说明" @click="closeReviewRiskDetail">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="review-risk-detail-body">
|
||||
<div class="review-risk-detail-level" :class="reviewRiskDetailDialog.item?.level">
|
||||
<i :class="reviewRiskDetailDialog.item?.icon || 'mdi mdi-information-outline'"></i>
|
||||
<span>{{ reviewRiskDetailDialog.item?.levelLabel || '提示' }}</span>
|
||||
</div>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>提示情况</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.summary }}</p>
|
||||
</article>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>详细解释</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.detail }}</p>
|
||||
</article>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>处理建议</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.suggestion }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
|
||||
<section class="review-confirm-modal review-upload-decision-modal">
|
||||
|
||||
@@ -375,15 +375,15 @@
|
||||
</article>
|
||||
|
||||
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
|
||||
<h3>领导意见</h3>
|
||||
<h3>{{ approvalOpinionTitle }}</h3>
|
||||
<textarea
|
||||
v-model="leaderOpinion"
|
||||
maxlength="500"
|
||||
placeholder="请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。"
|
||||
aria-label="领导意见"
|
||||
:placeholder="approvalOpinionPlaceholder"
|
||||
:aria-label="approvalOpinionTitle"
|
||||
></textarea>
|
||||
<div class="leader-opinion-meta">
|
||||
<span>审批通过后将流转至财务审批。</span>
|
||||
<span>{{ approvalOpinionHint }}</span>
|
||||
<strong>{{ leaderOpinion.length }}/500</strong>
|
||||
</div>
|
||||
</article>
|
||||
@@ -620,10 +620,10 @@
|
||||
|
||||
<ConfirmDialog
|
||||
:open="approveConfirmDialogOpen"
|
||||
badge="领导审批"
|
||||
:badge="approvalConfirmBadge"
|
||||
badge-tone="info"
|
||||
:title="`确认通过 ${request.id} 吗?`"
|
||||
description="确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。"
|
||||
:description="approvalConfirmDescription"
|
||||
cancel-text="返回核对"
|
||||
confirm-text="确认通过"
|
||||
busy-text="通过中..."
|
||||
@@ -644,10 +644,10 @@
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>下一节点</span>
|
||||
<strong>财务审批</strong>
|
||||
<strong>{{ approvalNextStage }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>领导意见</span>
|
||||
<span>{{ approvalOpinionTitle }}</span>
|
||||
<strong>{{ leaderOpinion.trim() || '未填写' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
TRANSPORT_KEYWORD_PATTERN
|
||||
} from '../../utils/reimbursementTextInference.js'
|
||||
import {
|
||||
calculateTravelReimbursement,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
@@ -55,15 +56,15 @@ const REVIEW_RISK_LEVEL_META = {
|
||||
icon: 'mdi mdi-alert-octagon-outline',
|
||||
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
|
||||
},
|
||||
warning: {
|
||||
label: '需关注',
|
||||
medium: {
|
||||
label: '中风险',
|
||||
icon: 'mdi mdi-alert-circle-outline',
|
||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
||||
},
|
||||
info: {
|
||||
label: '提示',
|
||||
low: {
|
||||
label: '低风险',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +311,7 @@ const FLOW_MISSING_SLOT_LABELS = {
|
||||
participants: '参与人员',
|
||||
attachments: '票据附件'
|
||||
}
|
||||
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
|
||||
let messageSeed = 0
|
||||
|
||||
function nowTime() {
|
||||
@@ -1317,6 +1319,7 @@ function createEmptyInlineReviewState() {
|
||||
return {
|
||||
occurred_date: '',
|
||||
amount: '',
|
||||
transport_type: '',
|
||||
scene_label: '',
|
||||
reason_value: '',
|
||||
customer_name: '',
|
||||
@@ -1330,6 +1333,67 @@ function createEmptyInlineReviewState() {
|
||||
}
|
||||
}
|
||||
|
||||
function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
const expenseType = resolveExpenseTypeCode(
|
||||
inlineState?.expense_type ||
|
||||
buildReviewSlotMap(reviewPayload).expense_type?.normalized_value ||
|
||||
buildReviewSlotMap(reviewPayload).expense_type?.value ||
|
||||
''
|
||||
)
|
||||
if (['travel', 'hotel', 'transport'].includes(expenseType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => {
|
||||
const documentType = String(item?.document_type || '').trim().toLowerCase()
|
||||
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
|
||||
return (
|
||||
['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) ||
|
||||
['travel', 'hotel', 'transport'].includes(suggestedType)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
const labels = []
|
||||
|
||||
const appendLabel = (label) => {
|
||||
if (label && !labels.includes(label)) {
|
||||
labels.push(label)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of documents) {
|
||||
const documentType = String(item?.document_type || '').trim().toLowerCase()
|
||||
const text = [
|
||||
item?.filename,
|
||||
item?.summary,
|
||||
item?.scene_label,
|
||||
item?.suggested_expense_type,
|
||||
...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : [])
|
||||
].join(' ')
|
||||
const compact = text.replace(/\s+/g, '')
|
||||
|
||||
if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) {
|
||||
appendLabel('飞机')
|
||||
} else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) {
|
||||
appendLabel('火车/高铁')
|
||||
} else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) {
|
||||
appendLabel('打车/网约车')
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = String(fallbackText || '').replace(/\s+/g, '')
|
||||
if (!labels.length) {
|
||||
if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机')
|
||||
if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁')
|
||||
if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车')
|
||||
}
|
||||
|
||||
return labels.join('、')
|
||||
}
|
||||
|
||||
function buildClientTimeContext() {
|
||||
const now = new Date()
|
||||
const locale =
|
||||
@@ -1434,7 +1498,11 @@ function resolveReviewMissingSlotCards(reviewPayload) {
|
||||
}
|
||||
|
||||
function resolveReviewRiskBriefs(reviewPayload) {
|
||||
return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []
|
||||
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
|
||||
return reviewPayload.risk_briefs.filter((item) => {
|
||||
const title = String(item?.title || '').trim()
|
||||
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
function formatConfidenceLabel(value) {
|
||||
@@ -1792,7 +1860,7 @@ function buildReviewAlertChips(reviewPayload) {
|
||||
chips.push({
|
||||
key: item.key,
|
||||
label: buildReviewAlertLabel(item.key, expenseTypeLabel),
|
||||
tone: item.key === 'attachments' ? 'danger' : 'warning'
|
||||
tone: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1830,7 +1898,7 @@ function buildReviewTodoItems(reviewPayload) {
|
||||
title: config.title || item.label,
|
||||
hint: item.hint || config.hint || `请补充${item.label}`,
|
||||
status: config.status || '待补充',
|
||||
tone: item.key === 'attachments' ? 'danger' : 'warning'
|
||||
tone: 'warning'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2075,6 +2143,9 @@ function buildInlineReviewState(reviewPayload) {
|
||||
editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
|
||||
).trim()
|
||||
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
|
||||
const transportType = String(
|
||||
editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue)
|
||||
).trim()
|
||||
|
||||
return {
|
||||
occurred_date: String(
|
||||
@@ -2083,6 +2154,7 @@ function buildInlineReviewState(reviewPayload) {
|
||||
amount: normalizeAmountValue(
|
||||
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
|
||||
),
|
||||
transport_type: transportType,
|
||||
scene_label: sceneLabel,
|
||||
reason_value:
|
||||
sceneLabel === REVIEW_SCENE_OTHER_OPTION
|
||||
@@ -2129,6 +2201,56 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
|
||||
: totalAttachmentCount > 0
|
||||
? `已上传 ${totalAttachmentCount} 份`
|
||||
: buildReviewAttachmentStatus(reviewPayload)
|
||||
if (isTravelReviewPayload(reviewPayload, inlineState)) {
|
||||
return [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
label: '发生时间',
|
||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
editor: 'date',
|
||||
modelKey: 'occurred_date',
|
||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
||||
icon: 'mdi mdi-cash',
|
||||
editor: 'amount',
|
||||
modelKey: 'amount',
|
||||
placeholder: '例如 200.00'
|
||||
},
|
||||
{
|
||||
key: 'transport_type',
|
||||
label: '交通类型',
|
||||
value: String(inlineState.transport_type || '').trim() || '待确认',
|
||||
icon: 'mdi mdi-train-car',
|
||||
editor: 'text',
|
||||
modelKey: 'transport_type',
|
||||
placeholder: '例如 火车/高铁、飞机'
|
||||
},
|
||||
{
|
||||
key: 'hotel_name',
|
||||
label: '酒店名称',
|
||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-bed-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'merchant_name',
|
||||
placeholder: '请输入酒店名称'
|
||||
},
|
||||
{
|
||||
key: 'travel_purpose',
|
||||
label: '出差事宜',
|
||||
value: String(inlineState.reason_value || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-briefcase-edit-outline',
|
||||
editor: 'textarea',
|
||||
modelKey: 'reason_value',
|
||||
placeholder: '请填写本次出差的具体工作内容或业务意图',
|
||||
wide: true
|
||||
}
|
||||
]
|
||||
}
|
||||
const cards = [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
@@ -2319,14 +2441,6 @@ function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInli
|
||||
)
|
||||
}
|
||||
|
||||
function buildReviewRiskScore(reviewPayload) {
|
||||
const score = Number(reviewPayload?.risk_score)
|
||||
if (!Number.isFinite(score) || score <= 0) {
|
||||
return null
|
||||
}
|
||||
return Math.max(0, Math.min(100, Math.round(score)))
|
||||
}
|
||||
|
||||
function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
|
||||
if (slotKey === 'customer_name') {
|
||||
return expenseTypeLabel === '业务招待费'
|
||||
@@ -2353,17 +2467,30 @@ function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
|
||||
|
||||
function buildReviewRiskSummary(reviewPayload) {
|
||||
if (resolveReviewRiskBriefs(reviewPayload).length) {
|
||||
return '当前识别到了合规提醒,提交前建议逐项核对。'
|
||||
return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。'
|
||||
}
|
||||
return '当前版本暂未生成风险评分结果。'
|
||||
return '当前没有需要额外处理的结构化风险点。'
|
||||
}
|
||||
|
||||
function normalizeReviewRiskLevel(level) {
|
||||
const normalized = String(level || '').trim().toLowerCase()
|
||||
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
|
||||
if (normalized === 'warn' || normalized === 'medium') return 'warning'
|
||||
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
|
||||
return 'info'
|
||||
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
|
||||
if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low'
|
||||
if (normalized === 'high') return normalized
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function normalizeReviewRiskTitle(title, fallbackTitle) {
|
||||
const normalized = String(title || '').trim()
|
||||
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
|
||||
if (!normalized) return fallback
|
||||
const cleaned = normalized
|
||||
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
|
||||
.replace(/(高风险|中风险|低风险)/g, '')
|
||||
.replace(/^[::\-—\s]+|[::\-—\s]+$/g, '')
|
||||
.trim()
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
function buildReviewRiskItems(reviewPayload) {
|
||||
@@ -2374,9 +2501,9 @@ function buildReviewRiskItems(reviewPayload) {
|
||||
const detail = String(brief?.detail || '').trim()
|
||||
const suggestion = String(brief?.suggestion || '').trim()
|
||||
const level = normalizeReviewRiskLevel(brief?.level)
|
||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
|
||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
|
||||
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
|
||||
const normalizedTitle = title || fallbackTitle
|
||||
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
|
||||
const summary = content || normalizedTitle
|
||||
|
||||
if (!normalizedTitle && !summary) return null
|
||||
@@ -2389,12 +2516,30 @@ function buildReviewRiskItems(reviewPayload) {
|
||||
level,
|
||||
levelLabel: meta.label,
|
||||
icon: meta.icon,
|
||||
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
|
||||
sourceLabel: meta.label,
|
||||
suggestion: suggestion || meta.suggestion
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
}
|
||||
|
||||
function buildReviewRiskConversationText(item) {
|
||||
const title = String(item?.title || '风险提示').trim()
|
||||
const summary = String(item?.summary || '').trim()
|
||||
const detail = String(item?.detail || '').trim()
|
||||
const suggestion = String(item?.suggestion || '').trim()
|
||||
const lines = [`${title}`]
|
||||
|
||||
if (summary) {
|
||||
lines.push('', `风险点:${summary}`)
|
||||
}
|
||||
if (detail && detail !== summary) {
|
||||
lines.push('', `规则依据:${detail}`)
|
||||
}
|
||||
if (suggestion) {
|
||||
lines.push('', `修改建议:${suggestion}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
|
||||
@@ -2489,6 +2634,7 @@ function normalizeInlineReviewComparableState(state) {
|
||||
return {
|
||||
occurred_date: String(source.occurred_date || '').trim(),
|
||||
amount: String(source.amount || '').trim(),
|
||||
transport_type: String(source.transport_type || '').trim(),
|
||||
scene_label: String(source.scene_label || '').trim(),
|
||||
reason_value: String(source.reason_value || '').trim(),
|
||||
customer_name: String(source.customer_name || '').trim(),
|
||||
@@ -2512,6 +2658,9 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
|
||||
if (base.amount !== next.amount) {
|
||||
lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`)
|
||||
}
|
||||
if (base.transport_type !== next.transport_type) {
|
||||
lines.push(`交通类型 ${next.transport_type || '待确认'}`)
|
||||
}
|
||||
if (base.scene_label !== next.scene_label) {
|
||||
lines.push(`场景 ${next.scene_label || '待补充'}`)
|
||||
}
|
||||
@@ -2543,6 +2692,7 @@ function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = [])
|
||||
const fieldConfigs = [
|
||||
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
|
||||
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
|
||||
{ key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' },
|
||||
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
|
||||
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
|
||||
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
|
||||
@@ -2611,6 +2761,7 @@ function mergeInlineReviewFields(baseFields, inlineState) {
|
||||
const merged = cloneReviewEditFields(baseFields)
|
||||
const updateMap = {
|
||||
expense_type: inlineState.expense_type,
|
||||
transport_type: inlineState.transport_type,
|
||||
occurred_date: inlineState.occurred_date,
|
||||
amount: inlineState.amount,
|
||||
customer_name: inlineState.customer_name,
|
||||
@@ -2699,7 +2850,7 @@ function buildReviewRiskHint(reviewPayload) {
|
||||
if (!riskBriefs.length) {
|
||||
return ''
|
||||
}
|
||||
return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。'
|
||||
return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。'
|
||||
}
|
||||
|
||||
function buildReviewActionHint(reviewPayload) {
|
||||
@@ -2839,6 +2990,14 @@ export default {
|
||||
const composerRangeEndDate = ref(formatDateInputValue())
|
||||
const composerBusinessTimeTags = ref([])
|
||||
const composerBusinessTimeDraftTouched = ref(false)
|
||||
const travelCalculatorOpen = ref(false)
|
||||
const travelCalculatorBusy = ref(false)
|
||||
const travelCalculatorError = ref('')
|
||||
const travelCalculatorResult = ref(null)
|
||||
const travelCalculatorForm = ref({
|
||||
days: '1',
|
||||
location: ''
|
||||
})
|
||||
const attachedFiles = ref([])
|
||||
const composerFilesExpanded = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -2882,10 +3041,6 @@ export default {
|
||||
const activeReviewDocumentIndex = ref(0)
|
||||
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const reviewRiskDetailDialog = ref({
|
||||
open: false,
|
||||
item: null
|
||||
})
|
||||
const documentPreviewDialog = ref({
|
||||
open: false,
|
||||
filename: '',
|
||||
@@ -2921,6 +3076,11 @@ export default {
|
||||
&& composerRangeStartDate.value <= composerRangeEndDate.value
|
||||
)
|
||||
})
|
||||
const travelCalculatorCanSubmit = computed(() =>
|
||||
!travelCalculatorBusy.value
|
||||
&& Number(travelCalculatorForm.value.days) >= 1
|
||||
&& Boolean(String(travelCalculatorForm.value.location || '').trim())
|
||||
)
|
||||
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
||||
const completedFlowStepCount = computed(
|
||||
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
|
||||
@@ -3040,10 +3200,9 @@ export default {
|
||||
).length > 0
|
||||
)
|
||||
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value))
|
||||
const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value))
|
||||
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
|
||||
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
|
||||
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
|
||||
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
|
||||
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
|
||||
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
|
||||
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
|
||||
@@ -3301,7 +3460,9 @@ export default {
|
||||
activeReviewDocumentIndex.value = nextDocumentDrafts.length
|
||||
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
|
||||
: 0
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
|
||||
? REVIEW_DRAWER_MODE_RISK
|
||||
: REVIEW_DRAWER_MODE_REVIEW
|
||||
reviewInlinePendingFiles.value = []
|
||||
reviewInlineEditorKey.value = ''
|
||||
reviewInlineErrors.value = {}
|
||||
@@ -3975,6 +4136,9 @@ export default {
|
||||
|
||||
function toggleComposerDatePicker() {
|
||||
composerDatePickerOpen.value = !composerDatePickerOpen.value
|
||||
if (composerDatePickerOpen.value) {
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeComposerDatePicker() {
|
||||
@@ -3998,13 +4162,21 @@ export default {
|
||||
}
|
||||
|
||||
function handleComposerDatePickerOutside(event) {
|
||||
if (!composerDatePickerOpen.value) {
|
||||
if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
|
||||
return
|
||||
}
|
||||
composerDatePickerOpen.value = false
|
||||
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
|
||||
return
|
||||
}
|
||||
if (composerDatePickerOpen.value) {
|
||||
composerDatePickerOpen.value = false
|
||||
}
|
||||
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyComposerDateSelection() {
|
||||
@@ -4026,6 +4198,142 @@ export default {
|
||||
composerTextareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function resolveTravelCalculatorInitialDays() {
|
||||
const businessTimeContext = buildComposerBusinessTimeContext()
|
||||
if (!businessTimeContext) {
|
||||
return 1
|
||||
}
|
||||
const startDate = businessTimeContext.start_date
|
||||
const endDate = businessTimeContext.end_date || startDate
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return 1
|
||||
}
|
||||
const startAt = Date.parse(`${startDate}T00:00:00Z`)
|
||||
const endAt = Date.parse(`${endDate}T00:00:00Z`)
|
||||
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
|
||||
return 1
|
||||
}
|
||||
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
|
||||
}
|
||||
|
||||
function resolveTravelCalculatorInitialLocation() {
|
||||
const slotMap = buildReviewSlotMap(activeReviewPayload.value)
|
||||
const candidates = [
|
||||
reviewInlineForm.value.location,
|
||||
slotMap.business_location?.normalized_value,
|
||||
slotMap.business_location?.value,
|
||||
slotMap.location?.normalized_value,
|
||||
slotMap.location?.value,
|
||||
currentUser.value?.location
|
||||
]
|
||||
return String(candidates.find((item) => String(item || '').trim()) || '').trim()
|
||||
}
|
||||
|
||||
function openTravelCalculator() {
|
||||
closeComposerDatePicker()
|
||||
travelCalculatorError.value = ''
|
||||
travelCalculatorResult.value = null
|
||||
travelCalculatorForm.value = {
|
||||
days: String(resolveTravelCalculatorInitialDays()),
|
||||
location: resolveTravelCalculatorInitialLocation()
|
||||
}
|
||||
travelCalculatorOpen.value = true
|
||||
}
|
||||
|
||||
function toggleTravelCalculator() {
|
||||
if (travelCalculatorOpen.value) {
|
||||
closeTravelCalculator()
|
||||
return
|
||||
}
|
||||
openTravelCalculator()
|
||||
}
|
||||
|
||||
function closeTravelCalculator() {
|
||||
if (travelCalculatorBusy.value) {
|
||||
return
|
||||
}
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
|
||||
function formatTravelCalculatorMoney(value) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) {
|
||||
return String(value || '0')
|
||||
}
|
||||
return amount.toFixed(2)
|
||||
}
|
||||
|
||||
function buildTravelCalculatorResultText(result) {
|
||||
const days = Number(result?.days) || 1
|
||||
const location = String(result?.location || '').trim() || '未填写地点'
|
||||
const matchedCity = String(result?.matched_city || location).trim()
|
||||
const grade = String(result?.grade || '').trim() || '当前职级'
|
||||
const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位'
|
||||
const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域'
|
||||
const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则'
|
||||
const ruleVersion = String(result?.rule_version || '').trim()
|
||||
const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate)
|
||||
const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount)
|
||||
const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate)
|
||||
const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate)
|
||||
const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate)
|
||||
const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount)
|
||||
const totalAmount = formatTravelCalculatorMoney(result?.total_amount)
|
||||
const ruleVersionText = ruleVersion ? `(${ruleVersion})` : ''
|
||||
const user = currentUser.value || {}
|
||||
const displayName = String(user.name || user.display_name || user.username || '').trim()
|
||||
const greeting = displayName ? `您好,${displayName},` : '您好,'
|
||||
|
||||
return [
|
||||
`${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`,
|
||||
'',
|
||||
`**参考可报销合计:${totalAmount} 元**`,
|
||||
'',
|
||||
'| 项目 | 标准口径 | 天数 | 小计 |',
|
||||
'| --- | --- | ---: | ---: |',
|
||||
`| 住宿费 | ${matchedCity} / ${grade}(${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`,
|
||||
`| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`,
|
||||
'',
|
||||
'**计算过程**',
|
||||
`1. 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元`,
|
||||
`2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount} 元`,
|
||||
`3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount} 元`,
|
||||
'',
|
||||
`**规则依据**:${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`,
|
||||
'',
|
||||
'这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function submitTravelCalculator() {
|
||||
if (!travelCalculatorCanSubmit.value) {
|
||||
travelCalculatorError.value = '请填写出差天数和地点后再计算。'
|
||||
return
|
||||
}
|
||||
|
||||
travelCalculatorBusy.value = true
|
||||
travelCalculatorError.value = ''
|
||||
try {
|
||||
const user = currentUser.value || {}
|
||||
const payload = await calculateTravelReimbursement({
|
||||
days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1),
|
||||
location: String(travelCalculatorForm.value.location || '').trim(),
|
||||
grade: String(user.grade || '').trim()
|
||||
})
|
||||
travelCalculatorResult.value = payload
|
||||
messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], {
|
||||
meta: ['差旅计算器'],
|
||||
metaTone: 'low'
|
||||
}))
|
||||
travelCalculatorOpen.value = false
|
||||
nextTick(scrollToBottom)
|
||||
} catch (error) {
|
||||
travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。'
|
||||
} finally {
|
||||
travelCalculatorBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function rememberFilePreviews(filePreviews) {
|
||||
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
|
||||
}
|
||||
@@ -4378,6 +4686,7 @@ export default {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
|
||||
amount: String(reviewInlineForm.value.amount || '').trim(),
|
||||
transport_type: String(reviewInlineForm.value.transport_type || '').trim(),
|
||||
customer_name: String(reviewInlineForm.value.customer_name || '').trim(),
|
||||
location: String(reviewInlineForm.value.location || '').trim(),
|
||||
merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(),
|
||||
@@ -4473,19 +4782,13 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
function openReviewRiskDetail(item) {
|
||||
function appendReviewRiskBriefToConversation(item) {
|
||||
if (!item) return
|
||||
reviewRiskDetailDialog.value = {
|
||||
open: true,
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
function closeReviewRiskDetail() {
|
||||
reviewRiskDetailDialog.value = {
|
||||
...reviewRiskDetailDialog.value,
|
||||
open: false
|
||||
}
|
||||
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
|
||||
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
|
||||
metaTone: item.level || 'low'
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function goReviewDocument(direction) {
|
||||
@@ -5267,11 +5570,9 @@ export default {
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
workbenchVisible,
|
||||
reviewPanelConfidence,
|
||||
reviewRiskScore,
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewRiskDetailDialog,
|
||||
recognizedNarratives,
|
||||
reviewRecognitionNotes,
|
||||
reviewDocumentSummaries,
|
||||
@@ -5281,6 +5582,12 @@ export default {
|
||||
reviewCancelDialogOpen,
|
||||
reviewEditDialogOpen,
|
||||
uploadDecisionDialogOpen,
|
||||
travelCalculatorOpen,
|
||||
travelCalculatorBusy,
|
||||
travelCalculatorError,
|
||||
travelCalculatorResult,
|
||||
travelCalculatorForm,
|
||||
travelCalculatorCanSubmit,
|
||||
deleteSessionDialogOpen,
|
||||
reviewActionBusy,
|
||||
deleteSessionBusy,
|
||||
@@ -5331,6 +5638,10 @@ export default {
|
||||
resolveFlowStepStatusLabel,
|
||||
resolveFlowStepDetail,
|
||||
toggleInsightPanel,
|
||||
openTravelCalculator,
|
||||
toggleTravelCalculator,
|
||||
closeTravelCalculator,
|
||||
submitTravelCalculator,
|
||||
switchToReviewOverviewDrawer,
|
||||
toggleReviewDocumentDrawer,
|
||||
toggleReviewRiskDrawer,
|
||||
@@ -5357,8 +5668,7 @@ export default {
|
||||
selectReviewCategory,
|
||||
selectReviewOtherCategory,
|
||||
queryDraftByClaimNo,
|
||||
openReviewRiskDetail,
|
||||
closeReviewRiskDetail,
|
||||
appendReviewRiskBriefToConversation,
|
||||
goReviewDocument,
|
||||
openActiveReviewDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
|
||||
import {
|
||||
canApproveLeaderExpenseClaims,
|
||||
canManageExpenseClaims,
|
||||
canReturnExpenseClaims,
|
||||
isFinanceUser
|
||||
} from '../../utils/accessControl.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
@@ -82,7 +87,7 @@ function resolveLocationDisplay(value, expenseType) {
|
||||
|
||||
function buildFallbackProgressSteps() {
|
||||
return [
|
||||
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
@@ -486,20 +491,51 @@ export default {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '直属领导审批'
|
||||
})
|
||||
const showLeaderApprovalPanel = computed(() =>
|
||||
Boolean(props.approvalMode)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& isDirectManagerApprovalStage.value
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const isFinanceApprovalStage = computed(() => {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '财务审批'
|
||||
})
|
||||
const canReturnRequest = computed(() =>
|
||||
canReturnExpenseClaims(currentUser.value)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const canApproveRequest = computed(() =>
|
||||
showLeaderApprovalPanel.value
|
||||
&& canReturnExpenseClaims(currentUser.value)
|
||||
Boolean(props.approvalMode)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
&& (
|
||||
(
|
||||
isDirectManagerApprovalStage.value
|
||||
&& canApproveLeaderExpenseClaims(currentUser.value)
|
||||
)
|
||||
|| (
|
||||
isFinanceApprovalStage.value
|
||||
&& isFinanceUser(currentUser.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
|
||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
||||
const approvalOpinionPlaceholder = computed(() =>
|
||||
isFinanceApprovalStage.value
|
||||
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
||||
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
||||
)
|
||||
const approvalOpinionHint = computed(() =>
|
||||
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
|
||||
)
|
||||
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
||||
const approvalConfirmDescription = computed(() =>
|
||||
isFinanceApprovalStage.value
|
||||
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
||||
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||||
)
|
||||
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
|
||||
const approvalSuccessToast = computed(() =>
|
||||
isFinanceApprovalStage.value
|
||||
? `${request.value.id} 已完成财务终审,进入归档入账。`
|
||||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||||
)
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
@@ -564,7 +600,7 @@ export default {
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '日期',
|
||||
label: '单据申请日期',
|
||||
value: request.value.applyTime || request.value.occurredDisplay,
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
valueClass: ''
|
||||
@@ -1011,12 +1047,23 @@ export default {
|
||||
try {
|
||||
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
||||
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||||
applyLocalExpenseItemPatch(item.id, {
|
||||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||||
const itemPatch = {
|
||||
invoiceId: String(payload?.invoice_id || '').trim(),
|
||||
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
|
||||
}
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
itemPatch.itemAmount = recognizedItemAmount
|
||||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||||
}
|
||||
applyLocalExpenseItemPatch(item.id, {
|
||||
...itemPatch
|
||||
})
|
||||
if (editingExpenseId.value === item.id) {
|
||||
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
expenseEditor.itemAmount = String(recognizedItemAmount)
|
||||
}
|
||||
}
|
||||
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
@@ -1322,7 +1369,7 @@ export default {
|
||||
}
|
||||
|
||||
if (!canApproveRequest.value) {
|
||||
toast('当前节点不支持领导审批通过。')
|
||||
toast('当前节点暂不支持审批通过。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1345,7 +1392,7 @@ export default {
|
||||
}
|
||||
|
||||
if (!canApproveRequest.value) {
|
||||
toast('当前节点不支持领导审批通过。')
|
||||
toast('当前节点暂不支持审批通过。')
|
||||
approveConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
@@ -1357,7 +1404,7 @@ export default {
|
||||
})
|
||||
approveConfirmDialogOpen.value = false
|
||||
leaderOpinion.value = ''
|
||||
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
|
||||
toast(approvalSuccessToast.value)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '审批通过失败,请稍后重试。')
|
||||
@@ -1396,6 +1443,12 @@ export default {
|
||||
attachmentPreviewUrl,
|
||||
approveBusy,
|
||||
approveConfirmDialogOpen,
|
||||
approvalConfirmBadge,
|
||||
approvalConfirmDescription,
|
||||
approvalNextStage,
|
||||
approvalOpinionHint,
|
||||
approvalOpinionPlaceholder,
|
||||
approvalOpinionTitle,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { canManageExpenseClaims, canReturnExpenseClaims } from '../src/utils/accessControl.js'
|
||||
import {
|
||||
canApproveLeaderExpenseClaims,
|
||||
canManageExpenseClaims,
|
||||
canReturnExpenseClaims
|
||||
} from '../src/utils/accessControl.js'
|
||||
import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js'
|
||||
|
||||
test('direct approvers can return claims without receiving delete permissions', () => {
|
||||
const managerUser = { roleCodes: ['manager'] }
|
||||
@@ -9,13 +14,42 @@ test('direct approvers can return claims without receiving delete permissions',
|
||||
|
||||
assert.equal(canReturnExpenseClaims(managerUser), true)
|
||||
assert.equal(canReturnExpenseClaims(approverUser), true)
|
||||
assert.equal(canApproveLeaderExpenseClaims(managerUser), true)
|
||||
assert.equal(canApproveLeaderExpenseClaims(approverUser), true)
|
||||
assert.equal(canManageExpenseClaims(managerUser), false)
|
||||
assert.equal(canManageExpenseClaims(approverUser), false)
|
||||
})
|
||||
|
||||
test('finance and executives can return and manage claims', () => {
|
||||
test('finance can return and final approve, but only executives can manage delete permissions', () => {
|
||||
assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true)
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), true)
|
||||
assert.equal(canApproveLeaderExpenseClaims({ roleCodes: ['finance'] }), false)
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), false)
|
||||
assert.equal(canReturnExpenseClaims({ roleCodes: ['executive'] }), true)
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true)
|
||||
})
|
||||
|
||||
test('finance approval inbox only processes finance-stage requests', () => {
|
||||
const financeUser = { roleCodes: ['finance'], name: '财务' }
|
||||
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, financeUser),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeUser),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('users with both finance and manager roles can process both relevant stages', () => {
|
||||
const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' }
|
||||
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, financeManagerUser),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeManagerUser),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
@@ -39,7 +39,9 @@ test('progress steps show approval operator time and current stay duration', ()
|
||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
||||
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
||||
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
|
||||
const firstStep = request.progressSteps[0]
|
||||
|
||||
assert.equal(firstStep.label, '创建单据')
|
||||
assert.equal(leaderStep.time, '李经理通过')
|
||||
assert.match(leaderStep.detail, /2026-05-20/)
|
||||
assert.match(leaderStep.title, /李经理审批通过/)
|
||||
@@ -52,6 +54,96 @@ test('progress steps show approval operator time and current stay duration', ()
|
||||
}
|
||||
})
|
||||
|
||||
test('progress steps do not expose approver email when manager name is available', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
||||
|
||||
try {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-email-operator',
|
||||
claim_no: 'EXP-202605-003',
|
||||
employee_name: '张三',
|
||||
department_name: '市场部',
|
||||
manager_name: '李经理',
|
||||
expense_type: 'transport',
|
||||
reason: '交通报销',
|
||||
location: '上海',
|
||||
amount: 88,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-20T01:00:00.000Z',
|
||||
submitted_at: '2026-05-20T02:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T03:30:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: '财务审批',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
operator: 'manager@example.com',
|
||||
operator_username: 'manager@example.com',
|
||||
previous_approval_stage: '直属领导审批',
|
||||
next_approval_stage: '财务审批',
|
||||
created_at: '2026-05-20T03:30:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
||||
|
||||
assert.equal(leaderStep.time, '李经理通过')
|
||||
assert.ok(!leaderStep.title.includes('manager@example.com'))
|
||||
} finally {
|
||||
Date.now = originalNow
|
||||
}
|
||||
})
|
||||
|
||||
test('completed finance approval marks finance and archive progress steps', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-finance-completed',
|
||||
claim_no: 'EXP-202605-004',
|
||||
employee_name: '张三',
|
||||
department_name: '市场部',
|
||||
expense_type: 'transport',
|
||||
reason: '交通报销',
|
||||
location: '上海',
|
||||
amount: 88,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-20T01:00:00.000Z',
|
||||
submitted_at: '2026-05-20T02:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T04:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: '归档入账',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
operator: '李经理',
|
||||
previous_approval_stage: '直属领导审批',
|
||||
next_approval_stage: '财务审批',
|
||||
created_at: '2026-05-20T03:00:00.000Z'
|
||||
},
|
||||
{
|
||||
source: 'finance_approval',
|
||||
operator: '财务复核',
|
||||
previous_approval_stage: '财务审批',
|
||||
next_approval_stage: '归档入账',
|
||||
created_at: '2026-05-20T04:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
||||
const archiveStep = request.progressSteps.find((step) => step.label === '归档入账')
|
||||
|
||||
assert.equal(request.workflowNode, '归档入账')
|
||||
assert.equal(financeStep.time, '财务复核通过')
|
||||
assert.match(financeStep.detail, /2026-05-20/)
|
||||
assert.equal(archiveStep.time, '归档入账')
|
||||
assert.equal(archiveStep.done, true)
|
||||
})
|
||||
|
||||
test('current direct manager step shows how long the claim has stayed there', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime()
|
||||
|
||||
@@ -31,3 +31,17 @@ test('normalizes returned backend claims as editable pending submission', () =>
|
||||
assert.equal(request.approvalStatus, '待提交')
|
||||
assert.equal(request.node, '待提交')
|
||||
})
|
||||
|
||||
test('does not show manager email as direct supervisor name', () => {
|
||||
const request = normalizeRequestForUi({
|
||||
id: 'EXP-202605-003',
|
||||
claim_id: 'claim-3',
|
||||
status: 'submitted',
|
||||
approval_stage: '直属领导审批',
|
||||
expense_type: 'transport',
|
||||
amount: 66,
|
||||
manager_name: 'manager@example.com'
|
||||
})
|
||||
|
||||
assert.equal(request.profileManager, '待补充')
|
||||
})
|
||||
|
||||
@@ -11,6 +11,10 @@ const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||||
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||
@@ -35,3 +39,74 @@ test('review drawer tool buttons switch modes instead of toggling the active mod
|
||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
})
|
||||
|
||||
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
|
||||
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
|
||||
assert.ok(riskItemsBlock, 'risk item builder should be present')
|
||||
|
||||
assert.doesNotMatch(createViewTemplate, /review-side-risk-score/)
|
||||
assert.doesNotMatch(createViewTemplate, /风险评分/)
|
||||
assert.doesNotMatch(createViewTemplate, /暂无风险评分/)
|
||||
assert.doesNotMatch(createViewScript, /function buildReviewRiskScore/)
|
||||
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
|
||||
assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
|
||||
assert.match(createViewScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
|
||||
)
|
||||
|
||||
assert.match(
|
||||
createViewTemplate,
|
||||
/class="review-side-risk-item"[\s\S]*@click="appendReviewRiskBriefToConversation\(item\)"/
|
||||
)
|
||||
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
||||
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
||||
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
||||
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
||||
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
||||
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
||||
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
||||
assert.match(createViewScript, /sourceLabel:\s*meta\.label/)
|
||||
assert.doesNotMatch(createViewScript, /normalizedTitle\.includes\('AI预审'\)/)
|
||||
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
|
||||
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
|
||||
assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/)
|
||||
assert.doesNotMatch(createViewScript, /reviewRiskDetailDialog/)
|
||||
assert.doesNotMatch(createViewScript, /function openReviewRiskDetail/)
|
||||
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
||||
)
|
||||
})
|
||||
|
||||
test('review payload with risks opens risk drawer and travel overview uses travel-specific fields', () => {
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/reviewDrawerMode\.value = resolveReviewRiskBriefs\(payload\)\.length[\s\S]*\? REVIEW_DRAWER_MODE_RISK[\s\S]*: REVIEW_DRAWER_MODE_REVIEW/
|
||||
)
|
||||
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
|
||||
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
||||
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
||||
assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
|
||||
assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
|
||||
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
|
||||
assert.match(createViewTemplate, /wide: item\.wide/)
|
||||
})
|
||||
|
||||
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
|
||||
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
|
||||
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
|
||||
assert.doesNotMatch(createViewTemplate, /travel-calculator-modal/)
|
||||
assert.doesNotMatch(createViewTemplate, /travelCalculatorResult\.total_amount/)
|
||||
assert.match(createViewScript, /calculateTravelReimbursement/)
|
||||
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
|
||||
assert.match(createViewScript, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
|
||||
assert.match(createViewScript, /根据您输入的地点和天数/)
|
||||
assert.match(createViewScript, /匹配到您要出差的地区为/)
|
||||
assert.match(createViewScript, /参考可报销合计/)
|
||||
assert.match(createViewScript, /住宿费:\$\{hotelRate\} × \$\{days\} = \$\{hotelAmount\} 元/)
|
||||
assert.match(createViewScript, /messages\.value\.push\(createMessage\('assistant', buildTravelCalculatorResultText\(payload\)/)
|
||||
assert.match(reimbursementService, /export function calculateTravelReimbursement\(payload = \{\}\) \{[\s\S]*\/reimbursements\/travel-calculator/)
|
||||
})
|
||||
|
||||
@@ -44,14 +44,24 @@ test('approval-mode detail collects leader opinion and confirms approval before
|
||||
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
|
||||
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailScript, /const canApproveRequest = computed/)
|
||||
assert.match(detailScript, /canApproveLeaderExpenseClaims/)
|
||||
assert.match(detailScript, /isFinanceApprovalStage/)
|
||||
assert.match(detailScript, /approvalOpinionTitle/)
|
||||
assert.match(detailScript, /approvalConfirmDescription/)
|
||||
assert.match(detailScript, /approvalNextStage/)
|
||||
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
|
||||
assert.match(detailScript, /toast\(approvalSuccessToast\.value\)/)
|
||||
|
||||
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
|
||||
assert.match(detailTemplate, /领导意见/)
|
||||
assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
|
||||
assert.match(detailTemplate, /v-model="leaderOpinion"/)
|
||||
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
|
||||
assert.match(detailTemplate, /@click="handleApproveRequest"/)
|
||||
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
|
||||
assert.match(detailTemplate, /:badge="approvalConfirmBadge"/)
|
||||
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
|
||||
assert.match(detailTemplate, /confirm-text="确认通过"/)
|
||||
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
|
||||
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
|
||||
|
||||
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
|
||||
|
||||
@@ -172,6 +172,13 @@ test('expense item upload remains limited to one receipt per detail row', () =>
|
||||
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
|
||||
})
|
||||
|
||||
test('expense item upload patches OCR amount into the visible detail row', () => {
|
||||
assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
|
||||
assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/)
|
||||
assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
|
||||
assert.match(detailViewScript, /expenseEditor\.itemAmount = String\(recognizedItemAmount\)/)
|
||||
})
|
||||
|
||||
test('return reason dialog is wired into approval and detail return actions', () => {
|
||||
assert.match(returnReasonDialog, /missing_attachment/)
|
||||
assert.match(returnReasonDialog, /invoice_mismatch/)
|
||||
|
||||
@@ -52,3 +52,9 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
assert.doesNotMatch(handleSubmit, /submitExpenseClaim/)
|
||||
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
|
||||
})
|
||||
|
||||
test('detail header and fallback progress use reimbursement wording', () => {
|
||||
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
|
||||
assert.match(detailViewScript, /label:\s*'创建单据'/)
|
||||
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user