diff --git a/docker-compose.yml b/docker-compose.yml
index 7a0f87a..608dec7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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'
+ ''
+ ''
+ ''
+ ' SimSunNoto Serif CJK SC'
+ ' NSimSunNoto Serif CJK SC'
+ ' KaiTiNoto Serif CJK SC'
+ ' FangSongNoto Serif CJK SC'
+ ' SimHeiNoto Sans CJK SC'
+ ' DengXianNoto Sans CJK SC'
+ ' Microsoft YaHeiNoto Sans CJK SC'
+ ''
+ > /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 &&
diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py
index 42af4f0..727f5d2 100644
--- a/server/src/app/api/v1/endpoints/reimbursements.py
+++ b/server/src/app/api/v1/endpoints/reimbursements.py
@@ -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,
diff --git a/server/src/app/models/financial_record.py b/server/src/app/models/financial_record.py
index 5607b26..19468df 100644
--- a/server/src/app/models/financial_record.py
+++ b/server/src/app/models/financial_record.py
@@ -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"
diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py
index 06f9ec8..97e206c 100644
--- a/server/src/app/schemas/reimbursement.py
+++ b/server/src/app/schemas/reimbursement.py
@@ -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
diff --git a/server/src/app/services/agent_conversations.py b/server/src/app/services/agent_conversations.py
index 45843ed..9bc93ad 100644
--- a/server/src/app/services/agent_conversations.py
+++ b/server/src/app/services/agent_conversations.py
@@ -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,
diff --git a/server/src/app/services/document_intelligence.py b/server/src/app/services/document_intelligence.py
index 4f116a8..e36e42e 100644
--- a/server/src/app/services/document_intelligence.py
+++ b/server/src/app/services/document_intelligence.py
@@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
scene_code="travel",
scene_label="差旅票据",
expense_type="travel",
- keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"),
+ keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"),
score_bias=0.32,
),
DocumentRule(
diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py
index ca917c8..9e66b7a 100644
--- a/server/src/app/services/expense_claims.py
+++ b/server/src/app/services/expense_claims.py
@@ -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:
diff --git a/server/src/app/services/expense_rule_runtime.py b/server/src/app/services/expense_rule_runtime.py
index a96a249..7bc8609 100644
--- a/server/src/app/services/expense_rule_runtime.py
+++ b/server/src/app/services/expense_rule_runtime.py
@@ -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)
diff --git a/server/src/app/services/ocr.py b/server/src/app/services/ocr.py
index c2e38cf..e975c72 100644
--- a/server/src/app/services/ocr.py
+++ b/server/src/app/services/ocr.py
@@ -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 {"□", "�"})
+ return placeholder_count / len(chars)
+
@staticmethod
def _cleanup_temp_paths(paths: list[Path]) -> None:
for path in reversed(paths):
diff --git a/server/src/app/services/travel_reimbursement_calculator.py b/server/src/app/services/travel_reimbursement_calculator.py
new file mode 100644
index 0000000..951f350
--- /dev/null
+++ b/server/src/app/services/travel_reimbursement_calculator.py
@@ -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'))}"
diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py
index c110368..ff7fe8a 100644
--- a/server/src/app/services/user_agent.py
+++ b/server/src/app/services/user_agent.py
@@ -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,
diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf
new file mode 100644
index 0000000..d516ecb
Binary files /dev/null and b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf differ
diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf.meta.json b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf.meta.json
new file mode 100644
index 0000000..b2e8892
--- /dev/null
+++ b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf.meta.json
@@ -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": []
+}
\ No newline at end of file
diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png
new file mode 100644
index 0000000..099413e
Binary files /dev/null and b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png differ
diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf
new file mode 100644
index 0000000..b2207b8
Binary files /dev/null and b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf differ
diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf.meta.json b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf.meta.json
new file mode 100644
index 0000000..496127b
--- /dev/null
+++ b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf.meta.json
@@ -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": []
+}
\ No newline at end of file
diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png
new file mode 100644
index 0000000..0bdfb91
Binary files /dev/null and b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png differ
diff --git a/server/storage/font-test-after-install.png b/server/storage/font-test-after-install.png
new file mode 100644
index 0000000..099413e
Binary files /dev/null and b/server/storage/font-test-after-install.png differ
diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json
index 9e38834..4f7185f 100644
--- a/server/storage/knowledge/.index.json
+++ b/server/storage/knowledge/.index.json
@@ -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"
}
]
}
\ No newline at end of file
diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py
index caf0ff4..a5b75fc 100644
--- a/server/tests/test_agent_asset_service.py
+++ b/server/tests/test_agent_asset_service.py
@@ -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)
diff --git a/server/tests/test_document_intelligence.py b/server/tests/test_document_intelligence.py
index 69c4b26..514c644 100644
--- a/server/tests/test_document_intelligence.py
+++ b/server/tests/test_document_intelligence.py
@@ -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:",
diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py
index beedf1d..2f25967 100644
--- a/server/tests/test_expense_claim_service.py
+++ b/server/tests/test_expense_claim_service.py
@@ -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"]
diff --git a/server/tests/test_ocr_service.py b/server/tests/test_ocr_service.py
index 8141050..0717b8d 100644
--- a/server/tests/test_ocr_service.py
+++ b/server/tests/test_ocr_service.py
@@ -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 == ""
diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py
index fdbc00f..3969803 100644
--- a/server/tests/test_orchestrator_review_flow.py
+++ b/server/tests/test_orchestrator_review_flow.py
@@ -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
diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py
index 5693478..b5138db 100644
--- a/server/tests/test_reimbursement_endpoints.py
+++ b/server/tests/test_reimbursement_endpoints.py
@@ -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:
diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py
index 70c3afd..d407c9a 100644
--- a/server/tests/test_user_agent_service.py
+++ b/server/tests/test_user_agent_service.py
@@ -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",
)
diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css
index 5850952..5788ccb 100644
--- a/web/src/assets/styles/views/travel-reimbursement-create-view.css
+++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css
@@ -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;
}
diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js
index b748943..841523b 100644
--- a/web/src/composables/useRequests.js
+++ b/web/src/composables/useRequests.js
@@ -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,
diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js
index 9c6ef09..a4773d3 100644
--- a/web/src/services/reimbursements.js
+++ b/web/src/services/reimbursements.js
@@ -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',
diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js
index 7e34978..5d3325e 100644
--- a/web/src/utils/accessControl.js
+++ b/web/src/utils/accessControl.js
@@ -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
diff --git a/web/src/utils/approvalInbox.js b/web/src/utils/approvalInbox.js
index 102019b..961ad78 100644
--- a/web/src/utils/approvalInbox.js
+++ b/web/src/utils/approvalInbox.js
@@ -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) {
diff --git a/web/src/utils/requestViewModel.js b/web/src/utils/requestViewModel.js
index ceae2af..ebb5199 100644
--- a/web/src/utils/requestViewModel.js
+++ b/web/src/utils/requestViewModel.js
@@ -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) || '申'
diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue
index 694b3a9..c2924c3 100644
--- a/web/src/views/TravelReimbursementCreateView.vue
+++ b/web/src/views/TravelReimbursementCreateView.vue
@@ -121,7 +121,14 @@
- {{ item }}
+
+ {{ item }}
+
@@ -548,6 +555,72 @@
+
+
+
+
+ 差旅计算器
+ 按规则中心差旅表测算
+
+
+
+
+
+
+ {{ travelCalculatorError }}
+
+
+
+
+
+
+
@@ -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"
/>
+
+
+