feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View File

@@ -12,6 +12,7 @@ from app.schemas.reimbursement import (
ExpenseClaimAttachmentActionResponse,
ExpenseClaimActionResponse,
ExpenseClaimAttachmentRead,
ExpenseClaimApprovalPayload,
ExpenseClaimItemCreate,
ExpenseClaimItemActionResponse,
ExpenseClaimItemUpdate,
@@ -59,6 +60,16 @@ def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[Expens
return ExpenseClaimService(db).list_claims(current_user)
@router.get(
"/claims/approvals",
response_model=list[ExpenseClaimRead],
summary="查询当前用户审批待办报销单列表",
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
)
def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
return ExpenseClaimService(db).list_approval_claims(current_user)
@router.get(
"/claims/{claim_id}",
response_model=ExpenseClaimRead,
@@ -420,7 +431,7 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
"/claims/{claim_id}/return",
response_model=ExpenseClaimRead,
summary="退回报销单",
description="财务人员高级管理人员可将可见报销单退回到待提交状态。",
description="财务人员高级管理人员或当前审批人可将可见报销单退回到待提交状态。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
@@ -440,7 +451,40 @@ def return_expense_claim(
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.return_claim(claim_id, current_user, reason=payload.reason)
claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/approve",
response_model=ExpenseClaimRead,
summary="直属领导审批通过报销单",
description="当前审批人确认报销信息无误后,将报销单从直属领导审批流转到财务审批。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许审批通过。",
},
},
)
def approve_expense_claim(
claim_id: str,
payload: ExpenseClaimApprovalPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error

View File

@@ -150,6 +150,11 @@ class ExpenseClaimActionResponse(BaseModel):
class ExpenseClaimReturnPayload(BaseModel):
reason: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10)
class ExpenseClaimApprovalPayload(BaseModel):
opinion: str | None = Field(default=None, max_length=500)
class ExpenseClaimAttachmentActionResponse(BaseModel):

View File

@@ -104,7 +104,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
scene_code="transport",
scene_label="交通票据",
expense_type="transport",
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "乘车", "用车", "叫车", "车费", "车资", "的士", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
score_bias=0.38,
),
DocumentRule(

View File

@@ -6,6 +6,7 @@ import json
import mimetypes
import re
import shutil
import uuid
from collections import defaultdict
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal, InvalidOperation
@@ -57,6 +58,7 @@ EXPENSE_TYPE_LABELS = {
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
LOCATION_REQUIRED_EXPENSE_TYPES = {
"travel",
"meeting",
@@ -79,6 +81,12 @@ EXPENSE_SCENE_KEYWORDS = {
"网约车",
"滴滴",
"出行",
"乘车",
"用车",
"叫车",
"车费",
"车资",
"的士",
"高铁",
"动车",
"火车",
@@ -127,6 +135,14 @@ DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = {
"link_to_existing_draft",
"create_new_claim_from_documents",
}
RETURN_REASON_OPTIONS = {
"missing_attachment": "附件缺失或不清晰",
"invoice_mismatch": "票据类型/金额与明细不一致",
"over_policy": "超出制度标准或缺少超标说明",
"business_explanation": "业务事由/地点/人员信息不完整",
"duplicate_or_abnormal": "疑似重复或异常票据",
"approval_question": "审批人需要补充说明",
}
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
DOCUMENT_AMOUNT_PATTERNS = (
re.compile(
@@ -148,6 +164,16 @@ SYSTEM_GENERATED_REASON_PREFIXES = (
"查看报销草稿",
"请解释一下当前这笔报销的合规风险和待补充项",
)
LEADING_REASON_TIME_PATTERNS = (
re.compile(
r"^\s*(?:识别事项(?:有)?[:]\s*)?"
r"(?:业务发生(?:时间|日期)|费用发生(?:时间|日期)|发生(?:时间|日期)|报销(?:时间|日期)|时间)[:]?\s*"
r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]?\s*"
),
re.compile(
r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]\s*"
),
)
AI_REVIEW_LOOKBACK_DAYS = 90
AI_REVIEW_REPEAT_RISK_WARNING_COUNT = 1
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT = 2
@@ -291,6 +317,19 @@ class ExpenseClaimService:
stmt = self._apply_claim_scope(stmt, current_user)
return list(self.db.scalars(stmt).all())
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = (
select(ExpenseClaim)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
)
stmt = self._apply_approval_claim_scope(stmt, current_user)
return list(self.db.scalars(stmt).all())
def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
stmt = (
select(ExpenseClaim)
@@ -301,7 +340,7 @@ class ExpenseClaimService:
)
.where(ExpenseClaim.id == claim_id)
)
stmt = self._apply_claim_scope(stmt, current_user)
stmt = self._apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self.db.scalar(stmt)
def update_claim_item(
@@ -846,34 +885,81 @@ class ExpenseClaimService:
current_user: CurrentUserContext,
*,
reason: str | None = None,
reason_codes: list[str] | None = None,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
if not self._has_privileged_claim_access(current_user):
raise ValueError("只有财务人员高级管理人员可以退回报销单。")
if not self._can_return_claim(current_user, claim):
raise ValueError("只有财务人员高级管理人员或当前审批人可以退回报销单。")
normalized_status = str(claim.status or "").strip().lower()
if normalized_status == "draft":
raise ValueError("草稿状态无需退回。")
if normalized_status == "returned":
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。")
before_json = self._serialize_claim(claim)
operator = current_user.name or current_user.username
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)
return_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_reason_codes = reason_code_payload["reason_codes"]
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
return_count = len(existing_return_flags) + 1
stage_return_count = (
sum(
1
for flag in existing_return_flags
if (
str(flag.get("return_stage_key") or "").strip()
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
)
== previous_stage_key
)
+ 1
)
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
return_flag = {
"source": "manual_return",
"event_type": "expense_claim_return",
"return_event_id": str(uuid.uuid4()),
"severity": "medium",
"label": "人工退回",
"message": return_reason or f"{operator} 已退回该报销单,请申请人调整后重新提交。",
"message": message,
"reason": return_reason,
"reason_codes": normalized_reason_codes,
"risk_points": risk_points,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"return_stage": previous_stage,
"return_stage_key": previous_stage_key,
"next_status": "returned",
"next_approval_stage": "待提交",
"return_count": return_count,
"stage_return_count": stage_return_count,
"created_at": datetime.now(UTC).isoformat(),
}
if unknown_reason_codes:
return_flag["unknown_reason_codes"] = unknown_reason_codes
claim.status = "returned"
claim.approval_stage = "待提交"
claim.submitted_at = None
claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag]
self.db.commit()
@@ -890,6 +976,74 @@ class ExpenseClaimService:
return claim
def approve_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
*,
opinion: str | None = None,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
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("当前节点不是直属领导审批,不能执行领导审批通过。")
before_json = self._serialize_claim(claim)
operator = current_user.name or current_user.username
leader_opinion = str(opinion or "").strip()
next_stage = "财务审批"
approval_flag = {
"source": "manual_approval",
"event_type": "expense_claim_approval",
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "领导审批通过",
"message": leader_opinion or f"{operator} 已审批通过,流转至{next_stage}",
"opinion": leader_opinion,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": "submitted",
"next_approval_stage": next_stage,
"created_at": datetime.now(UTC).isoformat(),
}
claim.status = "submitted"
claim.approval_stage = next_stage
if claim.submitted_at is None:
claim.submitted_at = datetime.now(UTC)
claim.risk_flags_json = [*list(claim.risk_flags_json or []), approval_flag]
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=operator,
action="expense_claim.approve",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
def upsert_draft_from_ontology(
self,
*,
@@ -994,8 +1148,9 @@ class ExpenseClaimService:
final_attachment_count = (
attachment_count if attachment_count > 0 else int(claim.invoice_count or 0) if claim is not None else 0
)
final_risk_flags = list(ontology.risk_flags) or (
list(claim.risk_flags_json or []) if claim is not None else []
final_risk_flags = self._merge_persistent_claim_risk_flags(
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
next_flags=list(ontology.risk_flags),
)
try:
@@ -1124,7 +1279,7 @@ class ExpenseClaimService:
return {
"message": (
f"{'创建' if is_new_claim else '更新'}报销草稿 {claim.claim_no},当前状态为 draft。"
"你可以继续补充费用明细、客户单位和票据附件"
"请核对识别结果,确认无误后继续提交"
),
"draft_only": True,
"claim_id": claim.id,
@@ -1150,7 +1305,7 @@ class ExpenseClaimService:
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
if draft_claim_id:
claim = self.db.get(ExpenseClaim, draft_claim_id)
if claim is not None and str(claim.status or "").strip() == "draft":
if claim is not None and self._is_editable_claim_status(claim.status):
return claim
return None
@@ -1165,7 +1320,7 @@ class ExpenseClaimService:
stmt = (
select(ExpenseClaim)
.where(ExpenseClaim.claim_no.in_(claim_codes))
.where(ExpenseClaim.status == "draft")
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
.limit(1)
)
return self.db.scalar(stmt)
@@ -1181,7 +1336,7 @@ class ExpenseClaimService:
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
if draft_claim_id:
claim = self.db.get(ExpenseClaim, draft_claim_id)
if claim is not None and str(claim.status or "").strip() == "draft":
if claim is not None and self._is_editable_claim_status(claim.status):
return claim
owner_filters = self._build_draft_owner_filters(
@@ -1203,7 +1358,7 @@ class ExpenseClaimService:
stmt = (
select(ExpenseClaim)
.where(ExpenseClaim.status == "draft")
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
.where(or_(*owner_filters))
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.created_at.desc())
.limit(1)
@@ -1384,7 +1539,7 @@ class ExpenseClaimService:
item.item_reason = spec["item_reason"]
item.item_location = spec["item_location"]
item.item_amount = spec["item_amount"]
item.invoice_id = spec["invoice_id"]
item.invoice_id = self._merge_attachment_reference(item.invoice_id, spec["invoice_id"])
for stale_item in existing_items[len(item_specs) :]:
claim.items.remove(stale_item)
@@ -1401,9 +1556,15 @@ class ExpenseClaimService:
for item in claim.items
if str(item.invoice_id or "").strip()
}
existing_invoice_names = {
self._resolve_attachment_display_name(item.invoice_id)
for item in claim.items
if str(item.invoice_id or "").strip()
}
for spec in item_specs:
invoice_id = str(spec.get("invoice_id") or "").strip()
if invoice_id and invoice_id in existing_invoice_ids:
invoice_name = self._resolve_attachment_display_name(invoice_id)
if invoice_id and (invoice_id in existing_invoice_ids or invoice_name in existing_invoice_names):
continue
claim.items.append(
ExpenseClaimItem(
@@ -1419,6 +1580,7 @@ class ExpenseClaimService:
self.db.add(claim.items[-1])
if invoice_id:
existing_invoice_ids.add(invoice_id)
existing_invoice_names.add(invoice_name)
def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
scene_code = str(document.get("scene_code") or "").strip()
@@ -1573,7 +1735,11 @@ class ExpenseClaimService:
item.item_reason = reason
item.item_location = location
item.item_amount = amount
item.invoice_id = attachment_names[0] if attachment_names else item.invoice_id
item.invoice_id = (
self._merge_attachment_reference(item.invoice_id, attachment_names[0])
if attachment_names
else item.invoice_id
)
def _generate_claim_no(self, occurred_at: datetime) -> str:
month_code = occurred_at.strftime("%Y%m")
@@ -1776,7 +1942,7 @@ class ExpenseClaimService:
return "travel"
if any(word in compact for word in ("住宿", "酒店", "宾馆")):
return "hotel"
if any(word in compact for word in ("交通", "打车", "网约车", "出租车", "停车", "车费")):
if any(word in compact for word in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")):
return "transport"
if any(word in compact for word in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")):
return "meal"
@@ -1809,13 +1975,13 @@ class ExpenseClaimService:
for key in ("reason", "business_reason"):
value = str(review_form_values.get(key) or "").strip()
if value:
return value
return ExpenseClaimService._strip_leading_time_from_reason(value)
explicit_text = context_json.get("user_input_text")
if isinstance(explicit_text, str):
normalized_explicit_text = explicit_text.strip()
if normalized_explicit_text:
return normalized_explicit_text[:500]
return ExpenseClaimService._strip_leading_time_from_reason(normalized_explicit_text)[:500] or None
return None
request_context = context_json.get("request_context")
@@ -1834,7 +2000,16 @@ class ExpenseClaimService:
compact_message = re.sub(r"\s+", "", normalized_message)
if compact_message.startswith(SYSTEM_GENERATED_REASON_PREFIXES):
return None
return normalized_message[:500] or None
return ExpenseClaimService._strip_leading_time_from_reason(normalized_message)[:500] or None
@staticmethod
def _strip_leading_time_from_reason(value: str) -> str:
reason = str(value or "").strip()
for pattern in LEADING_REASON_TIME_PATTERNS:
next_reason = pattern.sub("", reason).strip()
if next_reason != reason:
return next_reason
return reason
@staticmethod
def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None:
@@ -1973,12 +2148,31 @@ class ExpenseClaimService:
raise FileNotFoundError("Attachment path is invalid") from exc
return path
def _resolve_item_attachment_path(self, item: ExpenseClaimItem) -> Path | None:
if not str(item.invoice_id or "").strip():
return None
file_path = self._resolve_attachment_path(item.invoice_id)
if file_path is not None and file_path.exists():
return file_path
filename = self._normalize_attachment_filename(item.invoice_id)
if not filename:
return file_path
fallback_path = (self._build_item_attachment_dir(item.claim_id, item.id) / filename).resolve()
try:
fallback_path.relative_to(self._get_attachment_storage_root())
except ValueError as exc:
raise FileNotFoundError("Attachment path is invalid") from exc
return fallback_path
def _to_attachment_storage_key(self, file_path: Path) -> str:
root = self._get_attachment_storage_root()
return file_path.resolve().relative_to(root).as_posix()
def _resolve_item_attachment_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
file_path = self._resolve_attachment_path(item.invoice_id)
file_path = self._resolve_item_attachment_path(item)
if file_path is None or not file_path.exists():
raise FileNotFoundError("Attachment not found")
@@ -1991,7 +2185,7 @@ class ExpenseClaimService:
return file_path, media_type, filename
def _delete_item_attachment_files(self, item: ExpenseClaimItem) -> None:
file_path = self._resolve_attachment_path(item.invoice_id)
file_path = self._resolve_item_attachment_path(item)
if file_path is None:
return
@@ -2192,6 +2386,22 @@ class ExpenseClaimService:
def _resolve_attachment_display_name(storage_key: str | None) -> str:
return Path(str(storage_key or "").strip()).name
@classmethod
def _merge_attachment_reference(cls, current_invoice_id: str | None, next_invoice_id: str | None) -> str | None:
normalized_next = str(next_invoice_id or "").strip()
if not normalized_next:
return None
normalized_current = str(current_invoice_id or "").strip()
if (
normalized_current
and cls._resolve_attachment_display_name(normalized_current)
== cls._resolve_attachment_display_name(normalized_next)
):
return normalized_current
return normalized_next
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
insight = build_document_insight(
filename=str(getattr(document, "filename", "") or ""),
@@ -2607,6 +2817,93 @@ class ExpenseClaimService:
"risk_flags_json": list(claim.risk_flags_json or []),
}
@staticmethod
def _collect_return_flags(risk_flags: Any) -> list[dict[str, Any]]:
if not isinstance(risk_flags, list):
return []
return [
flag
for flag in risk_flags
if isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return"
]
@staticmethod
def _normalize_return_reason_codes(reason_codes: list[str] | None) -> list[str]:
return ExpenseClaimService._normalize_return_reason_code_payload(reason_codes)["reason_codes"]
@staticmethod
def _normalize_return_reason_code_payload(reason_codes: list[str] | None) -> dict[str, list[str]]:
normalized_codes: list[str] = []
unknown_codes: list[str] = []
for item in reason_codes or []:
code = str(item or "").strip()
if not code:
continue
if code in RETURN_REASON_OPTIONS and code not in normalized_codes:
normalized_codes.append(code)
elif code not in RETURN_REASON_OPTIONS and code not in unknown_codes:
unknown_codes.append(code)
return {
"reason_codes": normalized_codes,
"unknown_reason_codes": unknown_codes,
}
@staticmethod
def _merge_persistent_claim_risk_flags(*, existing_flags: list[Any], next_flags: list[Any]) -> list[Any]:
if not next_flags:
return list(existing_flags or [])
merged_flags = list(next_flags or [])
next_return_markers = {
ExpenseClaimService._build_return_flag_marker(flag)
for flag in merged_flags
if isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return"
}
for flag in list(existing_flags or []):
if not (isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return"):
continue
marker = ExpenseClaimService._build_return_flag_marker(flag)
if marker in next_return_markers:
continue
merged_flags.append(flag)
next_return_markers.add(marker)
return merged_flags
@staticmethod
def _build_return_flag_marker(flag: dict[str, Any]) -> tuple[str, str, str]:
event_id = str(flag.get("return_event_id") or "").strip()
if event_id:
return ("event_id", event_id, "")
return (
str(flag.get("return_count") or "").strip(),
str(flag.get("created_at") or "").strip(),
str(flag.get("message") or flag.get("reason") or "").strip(),
)
@staticmethod
def _build_default_return_message(*, operator: str, risk_points: list[str]) -> str:
if risk_points:
return f"{operator} 退回该报销单:{''.join(risk_points)}。请申请人调整后重新提交。"
return f"{operator} 已退回该报销单,请申请人调整后重新提交。"
@staticmethod
def _normalize_return_stage_key(stage: str | None) -> str:
normalized = str(stage or "").strip()
if "直属" in normalized or "领导" in normalized or "负责人" in normalized:
return "direct_manager"
if "财务" in normalized:
return "finance"
if "AI" in normalized or "预审" in normalized:
return "ai_review"
if "归档" in normalized or "入账" in normalized:
return "archive"
return "unknown"
@staticmethod
def _is_editable_claim_status(status: str | None) -> bool:
return str(status or "").strip().lower() in EDITABLE_CLAIM_STATUSES
@staticmethod
def _normalize_optional_text(value: str | None, *, fallback: str = "", allow_empty: bool = False) -> str | None:
normalized = str(value or "").strip()
@@ -2633,8 +2930,7 @@ class ExpenseClaimService:
return compact in {"待补充", "暂无", "", "未知", "处理中"}
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
normalized_status = str(claim.status or "").strip().lower()
if normalized_status not in {"draft", "supplement", "returned"}:
if not self._is_editable_claim_status(claim.status):
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
@@ -4308,6 +4604,40 @@ class ExpenseClaimService:
}
return bool(role_codes & PRIVILEGED_CLAIM_ROLE_CODES)
def _can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
if self._has_privileged_claim_access(current_user):
return True
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
def _can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
return self._can_return_claim(current_user, claim)
@staticmethod
def _normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
return {
@@ -4459,10 +4789,7 @@ class ExpenseClaimService:
)
return same_name_count == 1
def _apply_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
if self._has_privileged_claim_access(current_user):
return stmt
def _build_personal_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
conditions = []
username = str(current_user.username or "").strip()
employee = self._resolve_current_employee(current_user)
@@ -4485,27 +4812,71 @@ class ExpenseClaimService:
add_condition("employee_id", username)
add_condition("employee_name", username)
if not conditions:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
return conditions
def _build_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
role_codes = self._normalize_role_codes(current_user)
if role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES:
pending_leader_approval = and_(
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == "直属领导审批",
)
if employee is not None:
subordinate_ids = select(Employee.id).where(Employee.manager_id == employee.id)
conditions.append(and_(pending_leader_approval, ExpenseClaim.employee_id.in_(subordinate_ids)))
if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES):
return []
employee = self._resolve_current_employee(current_user)
manager_name = str(
employee.name if employee is not None and employee.name else current_user.name or ""
).strip()
pending_leader_approval_parts = [
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == "直属领导审批",
]
if employee is not None:
pending_leader_approval_parts.append(
or_(ExpenseClaim.employee_id.is_(None), ExpenseClaim.employee_id != employee.id)
)
if manager_name:
pending_leader_approval_parts.append(ExpenseClaim.employee_name != manager_name)
pending_leader_approval = and_(*pending_leader_approval_parts)
conditions = []
if employee is not None:
subordinate_ids = select(Employee.id).where(Employee.manager_id == employee.id)
conditions.append(and_(pending_leader_approval, ExpenseClaim.employee_id.in_(subordinate_ids)))
if manager_name:
managed_department_ids = select(OrganizationUnit.id).where(OrganizationUnit.manager_name == manager_name)
managed_department_names = select(OrganizationUnit.name).where(OrganizationUnit.manager_name == manager_name)
conditions.append(and_(pending_leader_approval, ExpenseClaim.department_id.in_(managed_department_ids)))
conditions.append(and_(pending_leader_approval, ExpenseClaim.department_name.in_(managed_department_names)))
return conditions
def _apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
if self._has_privileged_claim_access(current_user):
return stmt.where(ExpenseClaim.status == "submitted")
conditions = self._build_approval_claim_conditions(current_user)
if not conditions:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
return stmt.where(or_(*conditions))
def _apply_claim_scope(
self,
stmt: Any,
current_user: CurrentUserContext,
*,
include_approval_scope: bool = False,
) -> Any:
if self._has_privileged_claim_access(current_user):
return stmt
conditions = self._build_personal_claim_conditions(current_user)
if not conditions:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
if include_approval_scope:
conditions.extend(self._build_approval_claim_conditions(current_user))
return stmt.where(or_(*conditions))
def _ensure_ready(self) -> None:

View File

@@ -171,6 +171,11 @@ EXPENSE_TYPE_KEYWORDS = {
"打车": "transport",
"网约车": "transport",
"出租车": "transport",
"乘车": "transport",
"乘车费": "transport",
"用车": "transport",
"叫车": "transport",
"车资": "transport",
"停车费": "transport",
"餐费": "meal",
"用餐": "meal",
@@ -204,6 +209,11 @@ EXPENSE_NARRATIVE_KEYWORDS = (
"垫付",
"打车",
"车费",
"乘车",
"乘车费",
"用车",
"叫车",
"车资",
"餐费",
"吃饭",
"用餐",
@@ -1190,7 +1200,10 @@ class SemanticOntologyService:
)
)
if any(keyword in query for keyword in ("打车", "网约车", "出租车", "车费", "停车费", "过路费")):
if any(
keyword in query
for keyword in ("打车", "网约车", "出租车", "车费", "乘车", "用车", "叫车", "车资", "停车费", "过路费")
):
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")):

View File

@@ -226,6 +226,16 @@ SYSTEM_GENERATED_REASON_PREFIXES = (
"查看报销草稿",
"请解释一下当前这笔报销的合规风险和待补充项",
)
LEADING_REASON_TIME_PATTERNS = (
re.compile(
r"^\s*(?:识别事项(?:有)?[:]\s*)?"
r"(?:业务发生(?:时间|日期)|费用发生(?:时间|日期)|发生(?:时间|日期)|报销(?:时间|日期)|时间)[:]?\s*"
r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]?\s*"
),
re.compile(
r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]\s*"
),
)
AMOUNT_UNIT_ALIASES = {
"": "",
"": "",
@@ -2298,8 +2308,11 @@ class UserAgentService:
@staticmethod
def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]:
raw_reasons = payload.tool_payload.get("submission_blocked_reasons")
if raw_reasons is None:
submission_blocked = bool(payload.tool_payload.get("submission_blocked"))
if raw_reasons is None and submission_blocked:
raw_reasons = payload.tool_payload.get("missing_fields")
if raw_reasons is None and not submission_blocked:
return []
reasons: list[str] = []
if isinstance(raw_reasons, list):
@@ -2311,11 +2324,18 @@ class UserAgentService:
if item.strip()
)
if not reasons:
if not reasons and submission_blocked:
message = str(payload.tool_payload.get("message") or "").strip()
prefix = "提交前请先补全信息:"
for prefix in (
"提交前请先补全信息:",
"AI预审暂未通过原因如下",
"AI预审未通过原因如下",
"AI预审暂未通过",
"AI预审未通过",
):
if message.startswith(prefix):
message = message[len(prefix):].strip()
break
if message:
reasons.extend(
item.strip()
@@ -2769,7 +2789,7 @@ class UserAgentService:
@classmethod
def _resolve_reason_text(cls, message: str) -> str:
reason = cls._extract_message_reason(message)
reason = cls._strip_leading_time_from_reason(cls._extract_message_reason(message))
if not reason:
return ""
@@ -2799,6 +2819,15 @@ class UserAgentService:
return reason
@staticmethod
def _strip_leading_time_from_reason(value: str) -> str:
reason = str(value or "").strip()
for pattern in LEADING_REASON_TIME_PATTERNS:
next_reason = pattern.sub("", reason).strip()
if next_reason != reason:
return next_reason
return reason
@staticmethod
def _should_skip_model_answer(
payload: UserAgentRequest,
@@ -3490,7 +3519,7 @@ class UserAgentService:
return "travel", "差旅费"
if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")):
return "hotel", "住宿费"
if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "车费", "停车")):
if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")):
return "transport", "交通费"
if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")):
return "meal", "餐费"
@@ -3698,7 +3727,7 @@ class UserAgentService:
"group_code": "travel",
"scene_label": "住宿票据",
}
if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "过路费", "停车")):
if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "乘车", "用车", "叫车", "车费", "车资", "的士", "过路费", "停车")):
return {
"document_type": "transport_receipt",
"expense_type": "transport",

View File

@@ -1,23 +1,25 @@
{
"file_name": "发票_3_京S98876.pdf",
"storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf",
"storage_key": "193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf",
"media_type": "application/pdf",
"size_bytes": 61170,
"uploaded_at": "2026-05-20T02:21:35.637474+00:00",
"uploaded_at": "2026-05-20T12:25:49.243144+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png",
"preview_storage_key": "193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "发票_3_京S98876.preview.png",
"analysis": {
"severity": "medium",
"label": "中风险",
"headline": "AI提示附件存在明显待整改项",
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"用途字段:当前费用项目为其他,但附件内容更像住宿、交通相关票据。"
"票据类型:已识别为增值税发票。",
"附件类型要求:当前费用项目为交通费,已识别为增值税发票,符合当前交通费场景的附件要求。",
"金额字段:已识别到与当前明细接近的金额 121.54 元。"
],
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "vat_invoice",
@@ -49,23 +51,25 @@
},
"requirement_check": {
"matches": true,
"current_expense_type": "other",
"current_expense_type_label": "其他费用",
"current_expense_type": "transport",
"current_expense_type_label": "交通费",
"allowed_scene_labels": [
"其他票据"
"交通"
],
"allowed_document_type_labels": [
"停车/通行费票据",
"一般收据/凭证",
"出租车/网约车票据",
"增值税发票"
],
"recognized_scene_code": "other",
"recognized_scene_label": "通用发票",
"recognized_document_type": "vat_invoice",
"recognized_document_type_label": "增值税发票",
"mismatch_severity": "medium",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为其他费用,已识别为增值税发票,符合当前其他费用场景的附件要求。"
"message": "当前费用项目为交通费,已识别为增值税发票,符合当前交通费场景的附件要求。"
},
"ocr_status": "recognized",
"ocr_error": "",

View File

@@ -14,8 +14,8 @@
"updated_at": "2026-05-17T09:28:28.999515+00:00",
"uploaded_by": "admin",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-20T06:29:01.123795+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_document_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
@@ -110,6 +111,19 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
assert expense_type == "office"
def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
expense_type = ExpenseClaimService._resolve_expense_type(
[],
context_json={
"review_form_values": {
"expense_type": "乘车费用"
}
},
)
assert expense_type == "transport"
def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None:
user_id = "zhangsan@example.com"
@@ -238,6 +252,48 @@ def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upl
assert claim.reason == "待补充"
def test_upsert_draft_from_ontology_strips_recognized_business_time_from_reason() -> None:
user_id = "transport-time@example.com"
message = "业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用"
with build_session() as db:
employee = Employee(
employee_no="E5004",
name="赵六",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "赵六",
"user_input_text": message,
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.occurred_at.date() == date(2026, 3, 4)
assert claim.reason == "送客户去林萃小区办事,请报销乘车费用"
assert len(claim.items) == 1
assert claim.items[0].item_date == date(2026, 3, 4)
assert claim.items[0].item_reason == "送客户去林萃小区办事,请报销乘车费用"
assert "客户单位" not in result["message"]
assert "票据附件" not in result["message"]
assert "费用明细" not in result["message"]
def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None:
user_id = "lisi@example.com"
@@ -348,6 +404,100 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
assert float(new_claim.amount) == 50.5
def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
user_id = "returned-owner@example.com"
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-1",
"message": "第一次退回:附件缺失。",
"reason": "附件缺失。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
"risk_points": ["附件缺失或不清晰"],
}
with build_session() as db:
employee = Employee(
employee_no="E5004",
name="赵六",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-012",
employee_id=employee.id,
employee_name="赵六",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="上海",
amount=Decimal("20.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
status="returned",
approval_stage="待提交",
risk_flags_json=[return_flag],
)
existing_claim.items = [
ExpenseClaimItem(
claim_id=existing_claim.id,
item_date=date(2026, 5, 13),
item_type="transport",
item_reason="原有交通报销",
item_location="上海",
item_amount=Decimal("20.00"),
invoice_id="old-trip.png",
)
]
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我补充了交通票据,更新这张退回单据",
user_id=user_id,
)
)
ontology.risk_flags = ["系统识别:票据金额待人工核对。"]
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="我补充了交通票据,更新这张退回单据",
ontology=ontology,
context_json={
"name": "赵六",
"draft_claim_id": existing_claim.id,
"attachment_names": ["new-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "new-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
}
],
},
)
db.refresh(existing_claim)
assert result["claim_id"] == existing_claim.id
assert existing_claim.status == "draft"
assert "系统识别:票据金额待人工核对。" in existing_claim.risk_flags_json
manual_returns = [
flag
for flag in list(existing_claim.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert manual_returns == [return_flag]
def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
with build_session() as db:
db.add_all(
@@ -642,6 +792,44 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
assert not attachment_root.exists()
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="transport", location="上海")
claim.items[0].invoice_id = "legacy-ticket.pdf"
db.add(claim)
db.commit()
attachment_dir = tmp_path / claim.id / claim.items[0].id
attachment_dir.mkdir(parents=True)
file_path = attachment_dir / "legacy-ticket.pdf"
file_path.write_bytes(b"legacy-pdf-bytes")
(attachment_dir / "legacy-ticket.pdf.meta.json").write_text(
'{"file_name":"legacy-ticket.pdf","media_type":"application/pdf","previewable":true}',
encoding="utf-8",
)
payload = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert payload is not None
resolved_path, media_type, filename = payload
assert resolved_path == file_path
assert media_type == "application/pdf"
assert filename == "legacy-ticket.pdf"
def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
@@ -677,6 +865,43 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.submitted_at is not None
def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7100",
name="李经理",
email="manager-returned@example.com",
)
employee = Employee(
employee_no="E7101",
name="张三",
email="emp-submit@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.status = "returned"
claim.approval_stage = "待补充"
claim.items[0].invoice_id = "taxi-ticket.png"
db.add_all([manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_submit_claim_backfills_department_from_current_employee() -> None:
current_user = CurrentUserContext(
username="emp-dept@example.com",
@@ -1327,7 +1552,377 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None
def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
current_user = CurrentUserContext(
username="manager-return@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8100",
name="李经理",
email="manager-return@example.com",
)
employee = Employee(
employee_no="E8101",
name="张三",
email="zhangsan-return@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-RET-201",
employee_id=employee.id,
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
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
assert returned is not None
assert returned.status == "returned"
assert returned.approval_stage == "待提交"
assert returned.submitted_at is None
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"
and flag.get("message") == "请补充行程说明"
for flag in returned.risk_flags_json
)
def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> None:
current_user = CurrentUserContext(
username="manager-approve@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8110",
name="李经理",
email="manager-approve@example.com",
)
employee = Employee(
employee_no="E8111",
name="张三",
email="zhangsan-approve@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-APP-201",
employee_id=employee.id,
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 == "submitted"
assert approved.approval_stage == "财务审批"
assert approved.submitted_at is not None
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_claim_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",
name="财务",
role_codes=["finance"],
is_admin=False,
)
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-existing",
"message": "请补充附件。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
}
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-RET-202",
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=None,
status="returned",
approval_stage="待提交",
risk_flags_json=[return_flag],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="无需重复退回"):
ExpenseClaimService(db).return_claim(claim_id, current_user, reason="重复退回")
db.refresh(claim)
manual_returns = [
flag
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert manual_returns == [return_flag]
def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
current_user = CurrentUserContext(
username="finance-return@example.com",
name="财务复核",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-RET-301",
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
service = ExpenseClaimService(db)
first_returned = service.return_claim(
claim_id,
current_user,
reason="发票金额与明细金额不一致,请重新核对。",
reason_codes=["invoice_mismatch", "business_explanation"],
)
assert first_returned is not None
first_returned.status = "submitted"
first_returned.approval_stage = "财务审批"
first_returned.submitted_at = datetime(2026, 5, 12, 11, 0, tzinfo=UTC)
db.commit()
second_returned = service.return_claim(
claim_id,
current_user,
reason="超标说明仍不完整,请补充制度例外依据。",
reason_codes=["over_policy"],
)
assert second_returned is not None
return_events = [
flag
for flag in list(second_returned.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert len(return_events) == 2
assert return_events[0]["return_count"] == 1
assert return_events[0]["stage_return_count"] == 1
assert return_events[0]["return_stage"] == "直属领导审批"
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
assert return_events[0]["operator_role_codes"] == ["finance"]
assert return_events[1]["return_count"] == 2
assert return_events[1]["stage_return_count"] == 1
assert return_events[1]["return_stage"] == "财务审批"
assert return_events[1]["risk_points"] == ["超出制度标准或缺少超标说明"]
def test_submit_returned_claim_preserves_manual_return_events() -> None:
current_user = CurrentUserContext(
username="emp-submit-returned@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-submit",
"message": "第一次退回:业务说明不完整。",
"reason": "业务说明不完整。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
"risk_points": ["业务事由/地点/人员信息不完整"],
}
with build_session() as db:
manager = Employee(
employee_no="E8200",
name="李经理",
email="manager-submit-returned@example.com",
)
employee = Employee(
employee_no="E8201",
name="张三",
email="emp-submit-returned@example.com",
manager=manager,
)
claim = build_claim(expense_type="office", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.employee_name = "张三"
claim.department_name = "市场部"
claim.status = "returned"
claim.approval_stage = "待提交"
claim.risk_flags_json = [return_flag]
db.add_all([manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"
and flag.get("return_event_id") == "return-event-submit"
for flag in list(submitted.risk_flags_json or [])
)
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
current_user = CurrentUserContext(
username="manager-personal@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8300",
name="李经理",
email="manager-personal@example.com",
)
employee = Employee(
employee_no="E8301",
name="张三",
email="zhangsan-personal@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="EXP-MGR-OWN",
employee_id=manager.id,
employee_name="李经理",
department_name="市场部",
project_code="PRJ-MGR",
expense_type="office",
reason="本人报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-MGR-SUB",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-MGR",
expense_type="transport",
reason="下属待审批报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 11, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
),
]
)
db.commit()
service = ExpenseClaimService(db)
personal_claims = service.list_claims(current_user)
approval_claims = service.list_approval_claims(current_user)
assert [claim.claim_no for claim in personal_claims] == ["EXP-MGR-OWN"]
assert [claim.claim_no for claim in approval_claims] == ["EXP-MGR-SUB"]
def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
current_user = CurrentUserContext(
username="manager@example.com",
name="李经理",
@@ -1402,7 +1997,7 @@ def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval()
)
db.commit()
claims = ExpenseClaimService(db).list_claims(current_user)
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-MGR-201"

View File

@@ -451,6 +451,24 @@ def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type()
)
def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用",
user_id="pytest",
)
)
assert result.scenario == "expense"
assert result.intent == "draft"
assert any(
item.type == "expense_type" and item.normalized_value == "transport"
for item in result.entities
)
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:

View File

@@ -289,6 +289,67 @@ def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monke
assert any("附件内容" in point for point in analysis["points"])
def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() -> None:
client, session_factory = build_client()
with session_factory() as db:
manager = Employee(
id="mgr-approve-1",
employee_no="E21001",
name="李经理",
email="manager-approve-api@example.com",
)
employee = Employee(
id="emp-approve-1",
employee_no="E11001",
name="张三",
email="zhangsan-approve-api@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-approve-1",
claim_no="EXP-APP-API-001",
employee_id=employee.id,
employee_name="张三",
department_id="dept-1",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
submitted_at=datetime(2026, 5, 13, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add_all([manager, employee, claim])
db.commit()
response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={"opinion": "情况属实,同意报销。"},
headers={
"X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "submitted"
assert payload["approval_stage"] == "财务审批"
assert any(
item["source"] == "manual_approval"
and item["opinion"] == "情况属实,同意报销。"
and item["next_approval_stage"] == "财务审批"
for item in payload["risk_flags_json"]
)
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
preview_bytes = b"fake-preview-png"
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"

View File

@@ -576,6 +576,97 @@ def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
assert "时间为 2026-05-11" in response.review_payload.intent_summary
def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
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["expense_type"].value == "交通费"
assert slot_map["expense_type"].normalized_value == "transport"
assert slot_map["time_range"].value == "2026-03-04"
assert slot_map["reason"].value == "送客户去林萃小区办事,请报销乘车费用"
assert "业务发生时间" not in slot_map["reason"].raw_value
assert "“交通费”" in response.review_payload.intent_summary
def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-03-04送客户去林萃小区办事打车花了32元请报销乘车费用"
context_json = {
"name": "赵六",
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
"document_fields": [
{"key": "amount", "label": "支付金额", "value": "32.00"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={
"draft_only": True,
"claim_id": "claim-1",
"claim_no": "EXP-202603-001",
"status": "draft",
"message": (
"已创建报销草稿 EXP-202603-001当前状态为 draft。"
"你可以继续补充费用明细、客户单位和票据附件。"
),
},
)
)
assert response.review_payload is not None
assert response.review_payload.can_proceed is True
assert "客户名称" not in response.review_payload.missing_slots
assert "参与人员" not in response.review_payload.missing_slots
assert "票据附件" not in response.review_payload.missing_slots
risk_text = "\n".join(
f"{item.title}\n{item.content}" for item in response.review_payload.risk_briefs
)
assert "AI预审未通过" not in risk_text
assert "已创建报销草稿" not in risk_text
def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None:
session_factory = build_session_factory()
with session_factory() as db:

View File

@@ -1094,10 +1094,10 @@ tbody tr:last-child td {
}
.history-row {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) 128px 112px;
align-items: center;
justify-content: space-between;
gap: 12px;
column-gap: 16px;
padding: 12px 0;
border-top: 1px solid #edf2f7;
}
@@ -1108,42 +1108,40 @@ tbody tr:last-child td {
}
.history-row strong {
flex: 1 1 auto;
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 800;
line-height: 1.45;
}
.history-row-meta {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 20px;
flex-shrink: 0;
margin-left: 16px;
padding-left: 16px;
border-left: 1px solid #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-row-owner,
.history-row-time {
display: inline-block;
min-width: 0;
margin-top: 0;
color: #64748b;
font-size: 12px;
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-row-owner {
padding-left: 16px;
border-left: 1px solid #e2e8f0;
color: #475569;
font-weight: 700;
}
.history-row-time {
color: #64748b;
font-variant-numeric: tabular-nums;
text-align: right;
}
td.cell-updated {
@@ -1298,4 +1296,18 @@ td.cell-updated {
.role-grid {
grid-template-columns: 1fr;
}
.history-row {
grid-template-columns: minmax(0, 1fr);
row-gap: 6px;
}
.history-row-owner {
padding-left: 0;
border-left: 0;
}
.history-row-time {
text-align: left;
}
}

View File

@@ -119,13 +119,28 @@
font-weight: 800;
}
.applicant-meta-line {
.applicant-profile-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 0;
align-items: flex-start;
gap: 12px 28px;
}
.applicant-meta-line span {
.applicant-profile-meta__org {
display: grid;
gap: 6px;
min-width: 0;
}
.applicant-profile-meta__role {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 0;
min-width: 0;
}
.applicant-meta-item {
min-width: 0;
position: relative;
display: inline-flex;
@@ -136,11 +151,11 @@
line-height: 1.5;
}
.applicant-meta-line span + span {
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item {
margin-left: 16px;
}
.applicant-meta-line span + span::before {
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item::before {
content: "•";
position: absolute;
left: -10px;
@@ -148,12 +163,17 @@
font-size: 12px;
}
.applicant-meta-line em {
font-style: normal;
color: #64748b;
.applicant-meta-item--sub strong {
font-weight: 750;
}
.applicant-meta-line strong {
.applicant-meta-item em {
font-style: normal;
color: #64748b;
flex-shrink: 0;
}
.applicant-meta-item strong {
color: #0f172a;
font-weight: 800;
}
@@ -245,14 +265,21 @@
.progress-line {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(var(--progress-columns, 5), minmax(0, 1fr));
grid-template-columns: repeat(var(--progress-columns, 5), minmax(118px, 1fr));
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 4px 2px 2px;
}
.progress-step {
position: relative;
display: grid;
grid-template-rows: 26px minmax(62px, auto);
justify-items: center;
gap: 5px;
align-items: start;
gap: 10px;
min-width: 0;
padding: 0 6px;
color: #94a3b8;
}
@@ -297,8 +324,8 @@
.progress-step span {
position: relative;
z-index: 1;
width: 26px;
height: 26px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 999px;
@@ -326,7 +353,7 @@
background: #10b981 !important;
color: #fff !important;
box-shadow: 0 0 0 4px rgba(16, 185, 129, .15) !important;
animation: breathe-dot 3s ease-in-out infinite !important;
animation: breathe-dot 3.2s ease-in-out infinite !important;
transform-origin: center !important;
}
@@ -344,19 +371,81 @@
.progress-step strong {
color: #334155;
font-size: 12px;
line-height: 1.35;
text-align: center;
}
.progress-step.current strong { color: #059669; }
.progress-step small {
.progress-step-copy {
width: 100%;
min-width: 0;
display: grid;
justify-items: center;
align-content: start;
gap: 6px;
}
.progress-step-status {
max-width: 100%;
min-height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 9px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
color: #64748b;
font-size: 11px;
font-weight: 850;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-step.done .progress-step-status {
border-color: rgba(16, 185, 129, .2);
background: #ecfdf5;
color: #047857;
}
.progress-step.current .progress-step-status {
border-color: rgba(5, 150, 105, .22);
background: #059669;
color: #fff;
box-shadow: 0 8px 18px rgba(5, 150, 105, .14);
}
.progress-step:not(.done):not(.current) .progress-step-status {
background: #f8fafc;
color: #94a3b8;
}
.progress-step.current small {
color: #059669;
}
.progress-step-meta {
display: block;
width: 100%;
min-height: 16px;
color: #64748b;
font-size: 11px;
font-style: normal;
line-height: 1.35;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-step.current .progress-step-meta {
color: #475569;
}
.detail-grid {
display: block;
min-width: 0;
@@ -472,6 +561,40 @@
white-space: pre-wrap;
}
.leader-approval-card {
border-color: rgba(5, 150, 105, .18);
background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%);
}
.leader-approval-card textarea {
min-height: 96px;
background: #fff;
color: #0f172a;
}
.leader-approval-card textarea:focus {
outline: 0;
border-color: rgba(5, 150, 105, .5);
box-shadow: 0 0 0 3px rgba(5, 150, 105, .1);
}
.leader-opinion-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.leader-opinion-meta strong {
flex: 0 0 auto;
color: #047857;
font-weight: 850;
}
.detail-expense-table {
min-width: 0;
overflow-x: auto;
@@ -510,13 +633,13 @@
background: #fbfefd;
}
.detail-expense-table .col-time { width: 13%; }
.detail-expense-table .col-type { width: 15%; }
.detail-expense-table .col-desc { width: 23%; }
.detail-expense-table .col-amount { width: 12%; }
.detail-expense-table .col-attachment { width: 19%; }
.detail-expense-table .col-risk { width: 18%; }
.detail-expense-table .col-action { width: 10%; }
.detail-expense-table .col-time { width: 11%; }
.detail-expense-table .col-filled-at { width: 15%; }
.detail-expense-table .col-type { width: 13%; }
.detail-expense-table .col-desc { width: 19%; }
.detail-expense-table .col-amount { width: 11%; }
.detail-expense-table .col-attachment { width: 22%; }
.detail-expense-table .col-action { width: 9%; }
.cell-editor {
display: grid;
@@ -574,6 +697,7 @@
}
.expense-time strong,
.expense-filled-at strong,
.expense-type strong,
.expense-desc strong,
.expense-amount strong {
@@ -586,6 +710,7 @@
}
.expense-time span,
.expense-filled-at span,
.expense-type span,
.expense-desc span {
display: block;
@@ -599,6 +724,11 @@
white-space: nowrap;
}
.expense-filled-at strong {
font-size: 12px;
white-space: nowrap;
}
.expense-desc,
.detail-expense-table .col-desc {
text-align: left;
@@ -853,12 +983,6 @@
font-weight: 700;
}
.total-row td {
color: #0f172a;
font-weight: 900;
background: #f8fafc;
}
.empty-row-cell {
padding: 22px 16px;
color: #64748b;
@@ -868,31 +992,6 @@
background: #fcfdfd;
}
.expense-total-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px 20px;
flex-wrap: wrap;
}
.expense-total-bar strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.expense-total-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px 16px;
flex-wrap: wrap;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.expense-upload-input {
display: none;
}
@@ -910,7 +1009,7 @@
}
.attachment-preview-card {
width: min(920px, calc(100vw - 48px));
width: min(1160px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -931,6 +1030,39 @@
gap: 16px;
}
.attachment-preview-toolbar {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.attachment-preview-nav,
.attachment-preview-close {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #d7e0ea;
border-radius: 999px;
background: rgba(255, 255, 255, .9);
color: #475569;
}
.attachment-preview-nav:disabled {
cursor: not-allowed;
opacity: .5;
}
.attachment-preview-count {
min-width: 48px;
color: #64748b;
font-size: 12px;
font-weight: 800;
text-align: center;
}
.attachment-preview-badge {
display: inline-flex;
align-items: center;
@@ -951,27 +1083,30 @@
font-weight: 800;
}
.attachment-preview-close {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #d7e0ea;
border-radius: 999px;
background: rgba(255, 255, 255, .9);
color: #475569;
}
.attachment-preview-body {
min-height: 0;
display: grid;
place-items: center;
grid-template-columns: minmax(0, 1.25fr) minmax(320px, .75fr);
align-items: stretch;
gap: 16px;
overflow: hidden;
background: transparent;
}
.attachment-source-pane,
.attachment-insight-pane {
min-height: 0;
border: 1px solid #e2e8f0;
border-radius: 20px;
overflow: hidden;
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
}
.attachment-source-pane {
display: grid;
place-items: center;
}
.attachment-preview-image,
.attachment-preview-frame {
width: 100%;
@@ -982,6 +1117,96 @@
background: #fff;
}
.attachment-insight-pane {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 18px;
overflow-y: auto;
background: #fff;
}
.attachment-insight-head {
display: grid;
gap: 6px;
padding-bottom: 14px;
border-bottom: 1px solid #e2e8f0;
}
.attachment-insight-head span,
.attachment-insight-section span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.attachment-insight-head strong {
color: #0f172a;
font-size: 18px;
line-height: 1.35;
}
.attachment-insight-content {
display: grid;
align-content: start;
gap: 14px;
padding-top: 14px;
}
.attachment-insight-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.attachment-insight-section {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.attachment-insight-section ul {
display: grid;
gap: 6px;
margin: 0;
padding-left: 16px;
color: #334155;
font-size: 12px;
line-height: 1.55;
}
.attachment-risk-card {
display: grid;
gap: 6px;
padding: 10px;
border: 1px solid #fee2e2;
border-radius: 12px;
background: #fff7f7;
}
.attachment-risk-card.medium {
border-color: #fed7aa;
background: #fffaf2;
}
.attachment-risk-card strong {
color: #991b1b;
font-size: 12px;
line-height: 1.45;
}
.attachment-risk-card.medium strong {
color: #9a3412;
}
.attachment-risk-card p {
margin: 0;
color: #475569;
font-size: 12px;
line-height: 1.55;
}
.attachment-preview-state {
min-height: 320px;
display: grid;
@@ -993,6 +1218,11 @@
text-align: center;
}
.attachment-preview-state.compact {
min-height: 180px;
padding: 20px;
}
.attachment-preview-state i {
font-size: 24px;
}
@@ -1074,6 +1304,33 @@
line-height: 1.6;
}
.submit-confirm-summary {
display: grid;
gap: 8px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.submit-confirm-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
color: #64748b;
font-size: 13px;
line-height: 1.55;
}
.submit-confirm-row strong {
min-width: 0;
color: #0f172a;
font-weight: 780;
text-align: right;
word-break: break-word;
}
.validation-card {
border: 1px solid #e6f0eb;
background: linear-gradient(180deg, #fcfffd 0%, #f7fbf9 100%);
@@ -1140,6 +1397,109 @@
line-height: 1.55;
}
.risk-advice-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.risk-advice-card {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid #fee2e2;
border-radius: 8px;
background: #fffafa;
}
.risk-advice-card.medium {
border-color: #fed7aa;
background: #fffaf2;
}
.risk-advice-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.risk-advice-card-head span {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 999px;
background: #fee2e2;
color: #b91c1c;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.risk-advice-card.medium .risk-advice-card-head span {
background: #ffedd5;
color: #c2410c;
}
.risk-advice-card-head strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
line-height: 1.45;
text-align: right;
}
.risk-advice-point {
margin: 0;
color: #7f1d1d;
font-size: 14px;
font-weight: 800;
line-height: 1.5;
}
.risk-advice-card.medium .risk-advice-point {
color: #9a3412;
}
.risk-advice-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
}
.risk-advice-meta > div {
min-width: 0;
display: grid;
gap: 6px;
padding: 10px;
border-radius: 8px;
background: rgba(255, 255, 255, .72);
}
.risk-advice-meta span {
color: #64748b;
font-size: 11px;
font-weight: 850;
}
.risk-advice-meta ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 16px;
color: #334155;
font-size: 12px;
line-height: 1.55;
}
.risk-advice-meta p {
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.55;
}
.detail-overlay {
position: fixed;
inset: 0;
@@ -1630,7 +1990,7 @@
}
.detail-expense-table table {
min-width: 980px;
min-width: 1080px;
}
.ai-entry-grid {
@@ -1665,16 +2025,21 @@
font-size: 16px;
}
.applicant-meta-line {
.applicant-profile-meta {
display: grid;
gap: 10px;
}
.applicant-profile-meta__role {
display: grid;
gap: 6px;
}
.applicant-meta-line span + span {
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item {
margin-left: 0;
}
.applicant-meta-line span + span::before {
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item::before {
content: none;
}
@@ -1726,12 +2091,7 @@
.smart-entry-btn { align-self: flex-start; }
.detail-expense-table table {
min-width: 980px;
}
.expense-total-bar,
.expense-total-meta {
justify-content: flex-start;
min-width: 1080px;
}
.detail-actions {
@@ -1764,12 +2124,34 @@
}
.attachment-preview-card {
width: min(100vw - 28px, 920px);
width: min(calc(100vw - 28px), 920px);
max-height: calc(100vh - 28px);
padding: 18px;
border-radius: 20px;
}
.attachment-preview-head {
flex-wrap: wrap;
}
.attachment-preview-toolbar {
order: 2;
width: 100%;
justify-content: flex-start;
}
.attachment-preview-body {
grid-template-columns: minmax(0, 1fr);
}
.attachment-insight-pane {
max-height: 320px;
}
.risk-advice-meta {
grid-template-columns: minmax(0, 1fr);
}
.attachment-preview-image,
.attachment-preview-frame {
min-height: 360px;

View File

@@ -0,0 +1,5 @@
# Workbench Icons
Icons in this folder are sourced from [Heroicons](https://heroicons.com) (MIT License).
Used on the Personal Workbench todo and progress lists.

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z"/>
</svg>

After

Width:  |  Height:  |  Size: 806 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"/>
</svg>

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 10.5V6a3.75 3.75 0 1 0-7.5 0v4.5m11.356-1.993 1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 0 1-1.12-1.243l1.264-12A1.125 1.125 0 0 1 5.513 7.5h12.974c.576 0 1.059.435 1.119 1.007ZM8.625 10.5a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm7.5 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 0 1-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 0 0-3.213-9.193 2.056 2.056 0 0 0-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 0 0-10.026 0 1.106 1.106 0 0 0-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12"/>
</svg>

After

Width:  |  Height:  |  Size: 636 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M7.5 6v.75H5.513c-.96 0-1.764.724-1.865 1.679l-1.263 12A1.875 1.875 0 0 0 4.25 22.5h15.5a1.875 1.875 0 0 0 1.865-2.071l-1.263-12a1.875 1.875 0 0 0-1.865-1.679H16.5V6a4.5 4.5 0 1 0-9 0ZM12 3a3 3 0 0 0-3 3v.75h6V6a3 3 0 0 0-3-3Zm-3 8.25a3 3 0 1 0 6 0v-.75a.75.75 0 0 1 1.5 0v.75a4.5 4.5 0 1 1-9 0v-.75a.75.75 0 0 1 1.5 0v.75Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-slot="icon">
<path d="M3.375 4.5C2.339 4.5 1.5 5.34 1.5 6.375V13.5h12V6.375c0-1.036-.84-1.875-1.875-1.875h-8.25ZM13.5 15h-12v2.625c0 1.035.84 1.875 1.875 1.875h.375a3 3 0 1 1 6 0h3a.75.75 0 0 0 .75-.75V15Z"/>
<path d="M8.25 19.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0ZM15.75 6.75a.75.75 0 0 0-.75.75v11.25c0 .087.015.17.042.248a3 3 0 0 1 5.958.464c.853-.175 1.522-.935 1.464-1.883a18.659 18.659 0 0 0-3.732-10.104 1.837 1.837 0 0 0-1.47-.725H15.75Z"/>
<path d="M19.5 19.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -14,8 +14,7 @@
</div>
<div class="assistant-copy">
<span class="assistant-tag">AI 报销助手</span>
<h3>描述费用或上传票据AI 直接帮你判断怎么报</h3>
<h3>{{ assistantGreetingName }}描述费用或上传票据AI 直接帮你判断怎么报</h3>
<p>自动识别报销类别核对附件完整性并生成可继续提交的报销草稿</p>
<div class="assistant-input">
@@ -71,9 +70,11 @@
<div class="list-body">
<div v-for="item in todoItems" :key="item.title" class="todo-row">
<div class="todo-icon" :style="{ '--icon-color': item.color }">
<i :class="item.icon"></i>
</div>
<WorkbenchListIcon
:icon-key="item.iconKey"
:color="item.color"
:accent="item.accent"
/>
<div class="todo-copy">
<strong>{{ item.title }}</strong>
@@ -99,9 +100,11 @@
<div class="list-body">
<div v-for="item in progressItems" :key="item.id" class="progress-row">
<div class="todo-icon" :style="{ '--icon-color': item.color }">
<i :class="item.icon"></i>
</div>
<WorkbenchListIcon
:icon-key="item.iconKey"
:color="item.color"
:accent="item.accent"
/>
<div class="todo-copy progress-copy">
<strong>{{ item.title }}</strong>
@@ -142,6 +145,7 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import robotAssistant from '../../assets/robot-helper.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -167,6 +171,10 @@ const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const hasExpenseConversation = computed(() => Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId))
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const expenseActionIcon = computed(() => (hasExpenseConversation.value ? 'mdi mdi-history' : 'mdi mdi-magnify-scan'))
const assistantGreetingName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
@@ -318,24 +326,27 @@ const todoItems = [
tipLabel: 'AI 建议',
suggestion: '补充客户单位、客户人数、我方陪同人员',
action: '去补充',
icon: 'mdi mdi-account-group-outline',
color: '#10b981'
iconKey: 'hospitality',
color: '#0d9668',
accent: '#6ee7b7'
},
{
title: '差旅报销单待提交',
tipLabel: 'AI 建议',
suggestion: '补齐出发交通,可直接生成报销单',
action: '继续填写',
icon: 'mdi mdi-briefcase-outline',
color: '#16a34a'
iconKey: 'travelDraft',
color: '#15803d',
accent: '#86efac'
},
{
title: '有 5 张票据未关联报销单',
tipLabel: 'AI 建议',
suggestion: '其中 3 张疑似交通费,可合并生成交通报销',
action: '去整理',
icon: 'mdi mdi-receipt-text-outline',
color: '#3b82f6'
iconKey: 'receipts',
color: '#2563eb',
accent: '#93c5fd'
}
]
@@ -349,8 +360,9 @@ const progressItems = [
date: '2026-05-03',
status: '主管审批中',
tone: 'success',
icon: 'mdi mdi-airplane',
color: '#10b981'
iconKey: 'flight',
color: '#0d9668',
accent: '#6ee7b7'
},
{
id: 'transport',
@@ -359,8 +371,9 @@ const progressItems = [
date: '2026-05-02',
status: '财务复核中',
tone: 'info',
icon: 'mdi mdi-car-outline',
color: '#3b82f6'
iconKey: 'transport',
color: '#2563eb',
accent: '#93c5fd'
},
{
id: 'office',
@@ -369,8 +382,9 @@ const progressItems = [
date: '2026-05-01',
status: '已到账',
tone: 'mint',
icon: 'mdi mdi-cart-outline',
color: '#16a34a'
iconKey: 'procurement',
color: '#059669',
accent: '#a7f3d0'
}
]
@@ -522,19 +536,6 @@ watch(
align-content: center;
}
.assistant-tag {
display: inline-flex;
width: fit-content;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(59, 130, 246, 0.12));
color: #0f766e;
font-size: 12px;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.assistant-copy h3 {
color: #0f172a;
font-size: 26px;
@@ -798,7 +799,7 @@ watch(
.todo-row,
.progress-row {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) auto;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 14px 0;
@@ -811,17 +812,6 @@ watch(
border-top: 0;
}
.todo-icon {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 14px;
background: color-mix(in srgb, var(--icon-color) 12%, white);
color: var(--icon-color);
font-size: 24px;
}
.todo-copy {
min-width: 0;
}
@@ -877,7 +867,7 @@ watch(
}
.progress-row {
grid-template-columns: 48px minmax(0, 1fr) minmax(84px, auto) minmax(104px, auto);
grid-template-columns: 56px minmax(0, 1fr) minmax(84px, auto) minmax(104px, auto);
gap: 14px 16px;
}
@@ -1107,7 +1097,7 @@ watch(
.todo-row,
.progress-row {
grid-template-columns: 48px minmax(0, 1fr);
grid-template-columns: 56px minmax(0, 1fr);
}
.progress-amount {

View File

@@ -46,7 +46,9 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
const props = defineProps({
navItems: { type: Array, required: true },
@@ -67,6 +69,13 @@ const props = defineProps({
const emit = defineEmits(['navigate', 'openChat', 'logout'])
const {
badgeLabel: approvalBadgeLabel,
refreshApprovalInbox,
startApprovalInboxPolling,
stopApprovalInboxPolling
} = useApprovalInbox()
const sidebarMeta = {
overview: { label: '总览' },
workbench: { label: '个人工作台' },
@@ -83,10 +92,19 @@ const decoratedNavItems = computed(() =>
props.navItems.map((item) => ({
...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
badge: sidebarMeta[item.id]?.badge
badge: item.id === 'approval' ? approvalBadgeLabel.value : sidebarMeta[item.id]?.badge
}))
)
onMounted(() => {
void refreshApprovalInbox()
startApprovalInboxPolling()
})
onBeforeUnmount(() => {
stopApprovalInboxPolling()
})
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
role: props.currentUser?.role || '管理员',

View File

@@ -97,6 +97,18 @@
</div>
</template>
<template v-else-if="isWorkbench">
<div class="kpi-chips">
<div v-for="kpi in workbenchKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">
{{ kpi.value }}<small v-if="kpi.unit">{{ kpi.unit }}</small>
</span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
<template v-else-if="isRequests">
<div class="kpi-chips">
<div v-for="kpi in requestKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
@@ -180,6 +192,10 @@ const props = defineProps({
type: Object,
default: () => null
},
workbenchSummary: {
type: Object,
default: () => null
},
detailMode: {
type: Boolean,
default: false
@@ -209,6 +225,7 @@ const emit = defineEmits([
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => props.activeView === 'requests' && props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)
@@ -216,6 +233,49 @@ const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const workbenchKpis = computed(() => {
const summary = props.workbenchSummary ?? {}
const monthlyCount = Number(summary.monthlyCount ?? 0)
const returnCount = Number(summary.returnCount ?? 0)
const highRiskCount = Number(summary.highRiskCount ?? 0)
const monthlyAmountLabel = String(summary.monthlyAmountLabel || '¥0')
return [
{
label: '本月报销笔数',
value: monthlyCount,
unit: '笔',
meta: '本月累计',
trend: monthlyCount > 0 ? 'up' : 'down',
color: '#10b981'
},
{
label: '本月报销总金额',
value: monthlyAmountLabel,
unit: '',
meta: '本月累计',
trend: monthlyCount > 0 ? 'up' : 'down',
color: '#3b82f6'
},
{
label: '退单次数',
value: returnCount,
unit: '次',
meta: '累计退回',
trend: returnCount > 0 ? 'down' : 'up',
color: '#f59e0b'
},
{
label: '高危风险次数',
value: highRiskCount,
unit: '次',
meta: highRiskCount > 0 ? '本月需关注' : '本月无高危',
trend: highRiskCount > 0 ? 'down' : 'up',
color: '#ef4444'
}
]
})
const requestKpis = computed(() => {
const summary = props.requestSummary ?? {}
const total = Number(summary.total ?? 0)

View File

@@ -0,0 +1,222 @@
<template>
<ConfirmDialog
:open="open"
badge="退回单据"
badge-tone="warning"
:title="title"
:description="description"
cancel-text="取消"
confirm-text="确认退回"
busy-text="退回中..."
confirm-tone="primary"
confirm-icon="mdi mdi-undo"
:busy="busy"
:close-on-mask="false"
@close="handleClose"
@confirm="handleConfirm"
>
<div class="return-reason-dialog">
<div class="return-reason-section">
<span>默认风险点</span>
<div class="return-reason-options" role="group" aria-label="默认退回风险点">
<label
v-for="option in options"
:key="option.code"
:class="['return-reason-option', { active: selectedCodes.includes(option.code) }]"
>
<input
v-model="selectedCodes"
type="checkbox"
:value="option.code"
:disabled="busy"
/>
<i :class="option.icon"></i>
<strong>{{ option.label }}</strong>
</label>
</div>
</div>
<label class="return-reason-section">
<span>退单理由</span>
<textarea
v-model="reasonText"
rows="4"
:disabled="busy"
placeholder="请写清楚需要申请人补充或修改的内容,例如:发票金额与明细金额不一致,请重新上传正确票据。"
@input="touched = true"
></textarea>
<small :class="{ error: reasonError }">
{{ reasonError || '会同步记录到退单埋点,并展示给申请人。' }}
</small>
</label>
</div>
</ConfirmDialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import ConfirmDialog from './ConfirmDialog.vue'
const RETURN_REASON_OPTIONS = [
{ code: 'missing_attachment', label: '附件缺失或不清晰', icon: 'mdi mdi-paperclip-alert' },
{ code: 'invoice_mismatch', label: '票据类型/金额与明细不一致', icon: 'mdi mdi-file-compare' },
{ code: 'over_policy', label: '超出制度标准或缺少超标说明', icon: 'mdi mdi-scale-unbalanced' },
{ code: 'business_explanation', label: '业务事由/地点/人员信息不完整', icon: 'mdi mdi-text-box-alert-outline' },
{ code: 'duplicate_or_abnormal', label: '疑似重复或异常票据', icon: 'mdi mdi-alert-octagon-outline' },
{ code: 'approval_question', label: '审批人需要补充说明', icon: 'mdi mdi-comment-question-outline' }
]
const props = defineProps({
open: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
claimNo: { type: String, default: '' },
title: { type: String, default: '确认退回该单据吗?' },
description: {
type: String,
default: '退回后单据会回到待提交状态,申请人可按退回原因修改后重新提交。'
}
})
const emit = defineEmits(['close', 'confirm'])
const selectedCodes = ref([])
const reasonText = ref('')
const touched = ref(false)
const options = computed(() => RETURN_REASON_OPTIONS)
const trimmedReason = computed(() => reasonText.value.trim())
const reasonError = computed(() => {
if (!touched.value || trimmedReason.value.length >= 6) {
return ''
}
return '请至少填写 6 个字的明确退单理由。'
})
watch(
() => props.open,
(open) => {
if (open) {
selectedCodes.value = []
reasonText.value = ''
touched.value = false
}
}
)
function handleClose() {
if (!props.busy) {
emit('close')
}
}
function handleConfirm() {
touched.value = true
if (trimmedReason.value.length < 6 || props.busy) {
return
}
emit('confirm', {
reason: trimmedReason.value,
reason_codes: [...selectedCodes.value]
})
}
</script>
<style scoped>
.return-reason-dialog {
display: grid;
gap: 14px;
}
.return-reason-section {
display: grid;
gap: 8px;
}
.return-reason-section > span {
color: #334155;
font-size: 12px;
font-weight: 850;
}
.return-reason-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.return-reason-option {
min-width: 0;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #334155;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease, color 160ms ease;
}
.return-reason-option.active {
border-color: rgba(234, 88, 12, .34);
background: #fff7ed;
color: #c2410c;
}
.return-reason-option input {
width: 16px;
height: 16px;
accent-color: #ea580c;
}
.return-reason-option i {
grid-column: 2;
color: inherit;
}
.return-reason-option strong {
min-width: 0;
grid-column: 3;
font-size: 12px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.return-reason-section textarea {
width: 100%;
resize: vertical;
min-height: 104px;
padding: 12px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #0f172a;
font-size: 13px;
line-height: 1.6;
outline: none;
}
.return-reason-section textarea:focus {
border-color: rgba(234, 88, 12, .5);
box-shadow: 0 0 0 3px rgba(234, 88, 12, .1);
}
.return-reason-section small {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.return-reason-section small.error {
color: #dc2626;
}
@media (max-width: 640px) {
.return-reason-options {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div
class="workbench-list-icon"
:class="[`workbench-list-icon--${iconKey}`, `workbench-list-icon--${iconStyle}`]"
:style="{ '--icon-color': color, '--icon-accent': accent || color }"
>
<span class="workbench-list-icon__halo" aria-hidden="true"></span>
<span class="workbench-list-icon__panel" aria-hidden="true">
<span class="workbench-list-icon__shine" aria-hidden="true"></span>
<span class="workbench-list-icon__art" aria-hidden="true" v-html="iconMarkup"></span>
</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { workbenchIconMap } from '../../utils/workbenchIconAssets.js'
const props = defineProps({
iconKey: { type: String, required: true },
color: { type: String, default: '#10b981' },
accent: { type: String, default: '' }
})
const iconMeta = computed(() => workbenchIconMap[props.iconKey] || workbenchIconMap.hospitality)
const iconMarkup = computed(() => iconMeta.value.markup)
const iconStyle = computed(() => iconMeta.value.style)
</script>
<style scoped>
.workbench-list-icon {
position: relative;
width: 56px;
height: 56px;
flex-shrink: 0;
color: var(--icon-color);
}
.workbench-list-icon__halo {
position: absolute;
inset: -3px;
border-radius: 20px;
background: radial-gradient(
circle at 50% 40%,
color-mix(in srgb, var(--icon-accent, var(--icon-color)) 42%, transparent) 0%,
transparent 72%
);
opacity: 0.9;
}
.workbench-list-icon__panel {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--icon-color) 20%, #e2e8f0);
background:
radial-gradient(circle at 24% 16%, rgba(255, 255, 255, 0.98), transparent 46%),
linear-gradient(
160deg,
color-mix(in srgb, var(--icon-accent, var(--icon-color)) 24%, #fff) 0%,
#fff 42%,
color-mix(in srgb, var(--icon-color) 6%, #f8fafc) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.98),
0 1px 2px rgba(15, 23, 42, 0.04),
0 12px 24px color-mix(in srgb, var(--icon-color) 14%, transparent);
}
.workbench-list-icon__shine {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), transparent 38%);
pointer-events: none;
}
.workbench-list-icon__art {
position: relative;
z-index: 1;
display: grid;
place-items: center;
width: 30px;
height: 30px;
}
.workbench-list-icon__art :deep(.workbench-heroicon) {
width: 30px;
height: 30px;
display: block;
color: var(--icon-color);
filter: drop-shadow(0 2px 6px color-mix(in srgb, var(--icon-color) 22%, transparent));
}
.workbench-list-icon--outline .workbench-list-icon__art :deep(.workbench-heroicon) {
stroke-width: 1.65;
}
.workbench-list-icon--solid .workbench-list-icon__art :deep(.workbench-heroicon path) {
opacity: 0.96;
}
</style>

View File

@@ -1,12 +1,14 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApprovalInbox } from './useApprovalInbox.js'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
@@ -107,6 +109,7 @@ export function useAppShell() {
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
const { refreshApprovalInbox } = useApprovalInbox()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
@@ -128,6 +131,7 @@ export function useAppShell() {
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
const workbenchActive = computed(() => activeView.value === 'workbench')
watch(requestsListActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
@@ -135,6 +139,16 @@ export function useAppShell() {
}
})
watch(workbenchActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
})
const workbenchSummary = computed(() =>
buildWorkbenchSummary(requests.value, currentUser.value)
)
const topBarView = computed(() => {
if (detailMode.value) {
return {
@@ -250,6 +264,7 @@ export function useAppShell() {
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
smartEntryOpen.value = false
await reloadRequests()
void refreshApprovalInbox()
if (status === 'submitted') {
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else {
@@ -271,10 +286,12 @@ export function useAppShell() {
async function handleRequestUpdated() {
await reloadRequests()
void refreshApprovalInbox()
}
async function handleRequestDeleted() {
await reloadRequests()
void refreshApprovalInbox()
router.push({ name: 'app-requests' })
}
@@ -301,6 +318,7 @@ export function useAppShell() {
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,

View File

@@ -0,0 +1,152 @@
import { computed, ref, watch } from 'vue'
import { fetchApprovalExpenseClaims } from '../services/reimbursements.js'
import { canAccessAppView } from '../utils/accessControl.js'
import { resolvePendingClaimIds } from '../utils/approvalInbox.js'
import { useSystemState } from './useSystemState.js'
const pendingClaimIds = ref([])
const viewedClaimIds = ref([])
let refreshTimer = null
function buildStorageKey(userId) {
return `x-financial.approval-viewed:${userId}`
}
function loadViewedClaimIds(userId) {
if (!userId) {
return []
}
try {
const raw = localStorage.getItem(buildStorageKey(userId))
const parsed = raw ? JSON.parse(raw) : []
return Array.isArray(parsed)
? parsed.map((item) => String(item || '').trim()).filter(Boolean)
: []
} catch {
return []
}
}
function saveViewedClaimIds(userId, claimIds) {
if (!userId) {
return
}
localStorage.setItem(buildStorageKey(userId), JSON.stringify(claimIds))
}
function pruneViewedClaimIds(viewedIds, pendingIds) {
const pendingSet = new Set(pendingIds)
return viewedIds.filter((claimId) => pendingSet.has(claimId))
}
function syncPendingState(pendingIds, userId) {
pendingClaimIds.value = pendingIds
const pruned = pruneViewedClaimIds(viewedClaimIds.value, pendingIds)
if (pruned.length !== viewedClaimIds.value.length) {
viewedClaimIds.value = pruned
saveViewedClaimIds(userId, pruned)
}
}
export function useApprovalInbox() {
const { currentUser } = useSystemState()
const userKey = computed(() => {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
})
const unreadCount = computed(() => {
const viewedSet = new Set(viewedClaimIds.value)
return pendingClaimIds.value.filter((claimId) => !viewedSet.has(claimId)).length
})
const badgeLabel = computed(() => {
const count = unreadCount.value
if (count <= 0) {
return ''
}
return count > 99 ? '99+' : String(count)
})
function markClaimViewed(claimId) {
const normalizedId = String(claimId || '').trim()
if (!normalizedId || !pendingClaimIds.value.includes(normalizedId)) {
return
}
if (viewedClaimIds.value.includes(normalizedId)) {
return
}
const nextViewed = [...viewedClaimIds.value, normalizedId]
viewedClaimIds.value = nextViewed
saveViewedClaimIds(userKey.value, nextViewed)
}
function syncPendingClaimIds(claimIds) {
if (!canAccessAppView(currentUser.value, 'approval')) {
pendingClaimIds.value = []
return
}
const pendingIds = Array.isArray(claimIds)
? claimIds.map((item) => String(item || '').trim()).filter(Boolean)
: []
syncPendingState(pendingIds, userKey.value)
}
async function refreshApprovalInbox() {
const user = currentUser.value
if (!user || !canAccessAppView(user, 'approval')) {
pendingClaimIds.value = []
return
}
try {
const payload = await fetchApprovalExpenseClaims()
syncPendingClaimIds(resolvePendingClaimIds(payload, user))
} catch {
pendingClaimIds.value = []
}
}
function startApprovalInboxPolling(intervalMs = 45000) {
stopApprovalInboxPolling()
refreshTimer = window.setInterval(() => {
void refreshApprovalInbox()
}, intervalMs)
}
function stopApprovalInboxPolling() {
if (refreshTimer) {
window.clearInterval(refreshTimer)
refreshTimer = null
}
}
watch(
userKey,
(nextUserKey) => {
viewedClaimIds.value = loadViewedClaimIds(nextUserKey)
void refreshApprovalInbox()
},
{ immediate: true }
)
return {
pendingClaimIds,
unreadCount,
badgeLabel,
markClaimViewed,
syncPendingClaimIds,
refreshApprovalInbox,
startApprovalInboxPolling,
stopApprovalInboxPolling
}
}

View File

@@ -66,6 +66,33 @@ function formatDateTime(value) {
return `${formatDate(nextDate)} ${hours}:${minutes}`
}
function formatDurationFrom(value, now = Date.now()) {
const startAt = toDate(value)
if (!startAt) {
return ''
}
const diffMs = Math.max(0, Number(now) - startAt.getTime())
const totalMinutes = Math.floor(diffMs / (60 * 1000))
if (totalMinutes < 1) {
return '刚刚'
}
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
const minutes = totalMinutes % 60
if (days > 0) {
return hours > 0 ? `${days}${hours}小时` : `${days}`
}
if (hours > 0) {
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
}
return `${minutes}分钟`
}
function formatAmount(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
@@ -239,7 +266,147 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
return 2
}
function buildProgressSteps(approvalMeta, workflowNode) {
function normalizeText(value) {
return String(value || '').trim()
}
function getRiskFlags(claim) {
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
}
function getLatestEvent(events) {
const sortedEvents = events
.filter((item) => item && typeof item === 'object')
.map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
.filter((item) => item.eventDate)
.sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime())
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
}
function findApprovalEventForStep(claim, label) {
const stepLabel = normalizeText(label)
const events = getRiskFlags(claim).filter((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const source = normalizeText(flag.source)
if (!['manual_approval', 'finance_approval'].includes(source)) {
return false
}
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
if (stepLabel === '直属领导审批') {
return (
previousStage.includes('直属领导')
|| previousStage.includes('领导审批')
|| nextStage.includes('财务')
)
}
if (stepLabel === '财务审批') {
return (
previousStage.includes('财务')
|| nextStage.includes('归档')
|| nextStage.includes('入账')
|| nextStage.includes('完成')
)
}
return false
})
return getLatestEvent(events)
}
function findLatestReturnEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => (
flag
&& typeof flag === 'object'
&& normalizeText(flag.source) === 'manual_return'
))
)
}
function buildProgressStepMeta(time, detail = '', title = '') {
return {
time,
detail,
title: title || [time, detail].filter(Boolean).join(' ')
}
}
function buildCompletedStepMeta(claim, label) {
const stepLabel = normalizeText(label)
const employeeName = normalizeText(claim?.employee_name) || '申请人'
if (stepLabel === '保存草稿') {
const createdAt = formatDateTime(claim?.created_at)
return buildProgressStepMeta(`${employeeName}创建`, createdAt)
}
if (stepLabel === '待提交') {
const submittedAt = formatDateTime(claim?.submitted_at)
return buildProgressStepMeta(`${employeeName}提交`, submittedAt)
}
if (stepLabel === 'AI预审') {
const reviewedAt = formatDateTime(claim?.submitted_at || claim?.updated_at)
return buildProgressStepMeta('AI预审通过', reviewedAt)
}
if (stepLabel === '直属领导审批' || stepLabel === '财务审批') {
const approvalEvent = findApprovalEventForStep(claim, stepLabel)
if (approvalEvent) {
const operator = normalizeText(approvalEvent.operator) || (stepLabel === '财务审批' ? '财务' : '审批人')
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
}
if (stepLabel === '财务审批') {
const updatedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim())
}
}
if (stepLabel === '归档入账') {
const archivedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta('归档入账', archivedAt)
}
return buildProgressStepMeta('已完成')
}
function resolveCurrentStepStartedAt(claim, label) {
const stepLabel = normalizeText(label)
if (stepLabel === '保存草稿') {
return claim?.created_at
}
if (stepLabel === '待提交') {
const returnEvent = findLatestReturnEvent(claim)
return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at
}
if (stepLabel === 'AI预审') {
return claim?.updated_at || claim?.submitted_at || claim?.created_at
}
if (stepLabel === '直属领导审批') {
return claim?.submitted_at || claim?.updated_at || claim?.created_at
}
if (stepLabel === '财务审批') {
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
}
if (stepLabel === '归档入账') {
return claim?.updated_at || claim?.submitted_at
}
return ''
}
function buildProgressSteps(approvalMeta, workflowNode, claim = {}) {
const currentIndex = resolveProgressCurrentIndex(approvalMeta, workflowNode)
const currentTime =
approvalMeta.key === 'completed'
@@ -252,10 +419,13 @@ function buildProgressSteps(approvalMeta, workflowNode) {
return REIMBURSEMENT_PROGRESS_LABELS.map((label, index) => {
if (approvalMeta.key === 'completed') {
const stepMeta = buildCompletedStepMeta(claim, label)
return {
index: index + 1,
label,
time: '已完成',
time: stepMeta.time,
detail: stepMeta.detail,
title: stepMeta.title,
done: true,
active: true,
current: false
@@ -263,10 +433,13 @@ function buildProgressSteps(approvalMeta, workflowNode) {
}
if (index < currentIndex) {
const stepMeta = buildCompletedStepMeta(claim, label)
return {
index: index + 1,
label,
time: '已完成',
time: stepMeta.time,
detail: stepMeta.detail,
title: stepMeta.title,
done: true,
active: true,
current: false
@@ -274,10 +447,13 @@ function buildProgressSteps(approvalMeta, workflowNode) {
}
if (index === currentIndex) {
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
return {
index: index + 1,
label,
time: currentTime,
time: stayDuration ? `停留 ${stayDuration}` : currentTime,
detail: '',
title: stayDuration ? `当前${label}已停留 ${stayDuration}` : currentTime,
done: false,
active: true,
current: true
@@ -288,6 +464,8 @@ function buildProgressSteps(approvalMeta, workflowNode) {
index: index + 1,
label,
time: '待处理',
detail: '',
title: '待处理',
done: false,
active: false,
current: false
@@ -315,6 +493,7 @@ function buildExpenseItems(claim, riskSummary) {
id: String(item?.id || `${claim?.id || 'claim'}-item-${index}`),
time: formatDate(item?.item_date) || '待补充',
itemDate: formatDate(item?.item_date) || '',
filledAt: formatDateTime(item?.created_at) || '待同步',
itemType,
itemReason,
itemLocation,
@@ -328,8 +507,8 @@ function buildExpenseItems(claim, riskSummary) {
amount: itemAmountDisplay,
status: attachments.length ? '已识别' : '待补充',
tone: attachments.length ? 'ok' : 'bad',
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
attachmentHint: attachments.length ? attachments[0] : '支持上传 JPG、PNG、PDF,未上传也可先保存草稿',
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
attachmentHint: attachments.length ? attachments[0] : '支持上传 1 张 JPG、PNG、PDF 单据',
attachmentTone: attachments.length ? 'ok' : 'missing',
attachments,
riskLabel: riskSummary === '无' ? '无' : '待关注',
@@ -394,7 +573,7 @@ export function mapExpenseClaimToRequest(claim) {
: `${expenseItems.length} 条费用明细,待补充票据`)
: '暂无费用明细',
note: String(claim?.reason || '').trim(),
progressSteps: buildProgressSteps(approvalMeta, workflowNode),
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim),
expenseItems
}
}

View File

@@ -4,6 +4,10 @@ export function fetchExpenseClaims() {
return apiRequest('/reimbursements/claims')
}
export function fetchApprovalExpenseClaims() {
return apiRequest('/reimbursements/claims/approvals')
}
export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
}
@@ -94,6 +98,13 @@ export function returnExpenseClaim(claimId, payload = {}) {
})
}
export function approveExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/approve`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function deleteExpenseClaim(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, {
method: 'DELETE'

View File

@@ -20,6 +20,7 @@ const VIEW_ROLE_RULES = {
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
function normalizedRoleCodes(user) {
if (!user) {
@@ -51,6 +52,14 @@ export function canManageExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canReturnExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false

View File

@@ -0,0 +1,40 @@
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { canManageExpenseClaims } from './accessControl.js'
export function canProcessApprovalRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
const currentName = String(currentUser?.name || '').trim()
const applicantName = String(request?.person || request?.employeeName || '').trim()
if (currentName && applicantName && currentName === applicantName) {
return false
}
if (canManageExpenseClaims(currentUser)) {
return true
}
return (
node.includes('直属领导')
|| node.includes('领导审批')
|| node.includes('部门负责人')
|| node.includes('负责人审批')
)
}
export function listPendingApprovalRequests(claimsPayload, currentUser) {
if (!Array.isArray(claimsPayload)) {
return []
}
return claimsPayload
.map((item) => mapExpenseClaimToRequest(item))
.filter((item) => item.approvalKey === 'in_progress')
.filter((item) => canProcessApprovalRequest(item, currentUser))
}
export function resolvePendingClaimIds(claimsPayload, currentUser) {
return listPendingApprovalRequests(claimsPayload, currentUser)
.map((item) => String(item.claimId || '').trim())
.filter(Boolean)
}

View File

@@ -0,0 +1,188 @@
const DEFAULT_SESSION_TYPE_EXPENSE = 'expense'
const DEFAULT_SESSION_TYPE_KNOWLEDGE = 'knowledge'
const DEFAULT_INTENT_LABELS = {
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '草稿生成',
operate: '动作请求'
}
const DEFAULT_SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
knowledge: '知识',
unknown: '通用'
}
const DEFAULT_EXPENSE_TYPE_LABELS = {
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
meal: '餐费',
meeting: '会务费',
entertainment: '业务招待费',
office: '办公费',
training: '培训费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'
}
export const TRANSPORT_KEYWORD_PATTERN = /交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费/
const FLOW_INTENT_KEYWORDS = {
draft: ['报销', '报账', '草稿', '生成', '提交', '申请', '请走报销'],
query: ['查询', '查一下', '多少', '明细', '统计'],
risk_check: ['风险', '异常', '重复', '超标'],
explain: ['为什么', '依据', '规则', '怎么']
}
function normalizeCompactText(value) {
return String(value || '').trim().replace(/\s+/g, '')
}
function resolveExpenseTypeLabel(type, fallbackLabel = '', expenseTypeLabels = DEFAULT_EXPENSE_TYPE_LABELS) {
const normalized = String(type || '').trim()
return expenseTypeLabels[normalized] || String(fallbackLabel || '').trim() || expenseTypeLabels.other
}
function resolveSemanticExpenseTypeLabel(semanticParse, expenseTypeLabels = DEFAULT_EXPENSE_TYPE_LABELS) {
const entities = Array.isArray(semanticParse?.entities_json) ? semanticParse.entities_json : []
const expenseTypeEntity = entities.find((item) => String(item?.type || '').trim() === 'expense_type')
if (expenseTypeEntity) {
return resolveExpenseTypeLabel(
String(expenseTypeEntity.normalized_value || '').trim(),
String(expenseTypeEntity.value || '').trim(),
expenseTypeLabels
)
}
return resolveExpenseTypeLabel(
String(semanticParse?.expense_type || semanticParse?.expense_type_code || '').trim(),
String(semanticParse?.expense_type_label || '').trim(),
expenseTypeLabels
)
}
export function inferLocalFlowCandidates(rawText) {
const text = String(rawText || '').trim()
const compact = normalizeCompactText(text)
let time = ''
const explicitTimeMatch = text.match(/发生时间[:]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (explicitTimeMatch?.[1]) {
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else {
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (dateMatch?.[1]) {
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else if (/今天|今日/.test(compact)) {
time = '今天'
} else if (/昨天|昨日/.test(compact)) {
time = '昨天'
} else if (/前天/.test(compact)) {
time = '前天'
}
}
let amount = ''
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
if (amountMatch?.[1]) {
const numericValue = Number(amountMatch[1])
if (Number.isFinite(numericValue)) {
amount = Number.isInteger(numericValue) ? `${numericValue}` : `${numericValue.toFixed(2)}`
}
}
let event = ''
let expenseType = ''
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
event = '请客户吃饭'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
event = '交通出行'
expenseType = '交通费'
} else if (/住宿|酒店|宾馆/.test(compact)) {
event = '住宿报销'
expenseType = '住宿费'
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
event = '餐饮用餐'
expenseType = '餐费'
}
return {
time,
amount,
event,
expenseType
}
}
export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_TYPE_EXPENSE, options = {}) {
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
const compact = normalizeCompactText(rawText)
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
keywords.some((keyword) => compact.includes(keyword))
)?.[0] || 'draft'
const intentLabel = intentLabels[intentKey] || DEFAULT_INTENT_LABELS[intentKey] || '处理'
const candidates = inferLocalFlowCandidates(rawText)
const expenseTypeText = candidates.expenseType ? `,费用类型为${candidates.expenseType}` : ''
return `初步识别为报销场景,准备进入${intentLabel}${expenseTypeText}`
}
export function buildLocalExtractionProgressMessages(rawText, options = {}) {
const candidates = inferLocalFlowCandidates(rawText)
const messages = []
messages.push('正在提取发生时间...')
messages.push(
candidates.time
? `发现发生时间 ${candidates.time},继续提取金额...`
: '暂未定位到明确时间,继续提取金额...'
)
messages.push(
candidates.amount
? `发现金额 ${candidates.amount},继续识别事件类型...`
: '暂未定位到明确金额,继续识别事件类型...'
)
if (candidates.event || candidates.expenseType) {
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
} else {
messages.push('正在识别事件类型和费用分类...')
}
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
return messages
}
export function summarizeSemanticIntentDetail(semanticParse, options = {}) {
if (!semanticParse || typeof semanticParse !== 'object') {
return options.fallbackText || '意图识别完成'
}
const scenarioLabels = options.scenarioLabels || DEFAULT_SCENARIO_LABELS
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
const expenseTypeLabels = options.expenseTypeLabels || DEFAULT_EXPENSE_TYPE_LABELS
const scenarioLabel = scenarioLabels[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
const intentLabel = intentLabels[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
const expenseTypeLabel = resolveSemanticExpenseTypeLabel(semanticParse, expenseTypeLabels)
const expenseTypeText = expenseTypeLabel && expenseTypeLabel !== expenseTypeLabels.other
? `,费用类型为${expenseTypeLabel}`
: ''
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}${expenseTypeText}`
}

View File

@@ -194,7 +194,13 @@ export function normalizeRequestForUi(request) {
const sceneTarget = String(request.sceneTarget || request.location || request.city || request.entity || '').trim() || '待补充'
const occurredDisplay = String(request.occurredDisplay || request.period || request.occurredAt || '').trim() || '待补充'
const applyTime = String(request.applyTime || parseRequestDateFromId(request.id) || '').trim() || '待补充'
const workflowNode = String(request.workflowNode || request.node || '').trim() || '待提交'
const workflowNode = String(
request.workflowNode
|| request.node
|| request.approval_stage
|| request.approvalStage
|| ''
).trim() || '待提交'
const secondaryStatusValue =
String(request.secondaryStatusValue || request.travel || '').trim()
|| (detailVariant === 'travel' ? '待安排行程' : '待补充票据')

View File

@@ -0,0 +1,22 @@
import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw'
import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw'
import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw'
import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw'
import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw'
import usersIcon from '../assets/workbench-icons/outline-users.svg?raw'
function prepareHeroiconMarkup(svgRaw) {
return String(svgRaw || '')
.replace(/<svg\b([^>]*)>/i, '<svg class="workbench-heroicon"$1>')
.replace(/\sdata-slot="[^"]*"/g, '')
.replace(/\saria-hidden="[^"]*"/g, '')
}
export const workbenchIconMap = {
hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' },
travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' },
receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' },
flight: { markup: prepareHeroiconMarkup(paperAirplaneIcon), style: 'outline' },
transport: { markup: prepareHeroiconMarkup(truckIcon), style: 'outline' },
procurement: { markup: prepareHeroiconMarkup(shoppingBagIcon), style: 'outline' }
}

View File

@@ -0,0 +1,82 @@
function parseNumber(value) {
const nextValue = Number(value)
return Number.isFinite(nextValue) ? nextValue : 0
}
function toDate(value) {
if (!value) {
return null
}
const nextDate = new Date(value)
return Number.isNaN(nextDate.getTime()) ? null : nextDate
}
function isCurrentMonth(dateValue) {
const date = toDate(dateValue)
if (!date) {
return false
}
const now = new Date()
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()
}
function resolveClaimDate(request) {
return request?.submittedAt || request?.createdAt || request?.occurredAt || ''
}
export function belongsToCurrentUser(request, currentUser) {
const person = String(request?.person || request?.employeeName || '').trim()
if (!person) {
return false
}
const names = [
String(currentUser?.name || '').trim(),
String(currentUser?.username || '').trim()
].filter(Boolean)
return names.some((name) => name === person)
}
export function hasHighRiskFlag(request) {
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
if (riskFlags.some((item) => String(item?.severity || '').trim().toLowerCase() === 'high')) {
return true
}
const summary = String(request?.riskSummary || '').trim()
return summary.includes('高')
}
function formatCurrency(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
}).format(parseNumber(value))
}
export function buildWorkbenchSummary(requests, currentUser) {
const ownedRequests = Array.isArray(requests)
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
: []
const monthlyClaims = ownedRequests.filter((item) => isCurrentMonth(resolveClaimDate(item)))
const monthlyCount = monthlyClaims.length
const monthlyAmount = monthlyClaims.reduce((sum, item) => sum + parseNumber(item.amount), 0)
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
return {
monthlyCount,
monthlyAmount,
monthlyAmountLabel: formatCurrency(monthlyAmount),
returnCount,
highRiskCount
}
}

View File

@@ -36,6 +36,7 @@
:knowledge-summary="knowledgeSummary"
:logs-summary="logsSummary"
:request-summary="requestSummary"
:workbench-summary="workbenchSummary"
:detail-mode="detailMode"
:log-detail-mode="logDetailMode"
:detail-alerts="detailAlerts"
@@ -178,6 +179,7 @@ const {
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,

View File

@@ -1,429 +1,15 @@
<template>
<section class="approval-page">
<!-- Detail Modal Overlay -->
<Teleport to="body">
<Transition name="detail-modal">
<div v-if="false && selectedRow" class="detail-overlay" @click.self="selectedRow = null">
<div class="detail-modal">
<!-- Modal Header -->
<header class="modal-header">
<div class="header-left">
<div class="req-badge">{{ selectedRow.id }}</div>
<div class="header-title-group">
<h2>{{ selectedRow.type }}审批详情</h2>
<p>申请人{{ selectedRow.applicant }} · {{ selectedRow.department }} · {{ selectedRow.time }}</p>
</div>
</div>
<div class="header-right">
<div class="header-indicator" :class="selectedRow.riskTone">
<i class="mdi" :class="selectedRow.riskTone === 'high' ? 'mdi-alert-circle' : selectedRow.riskTone === 'medium' ? 'mdi-alert' : 'mdi-shield-check'"></i>
<span>{{ selectedRow.risk }}</span>
</div>
<div class="header-indicator status" :class="selectedRow.statusTone">
<span>{{ selectedRow.node }}</span>
</div>
<button class="close-btn" type="button" aria-label="关闭" @click="selectedRow = null">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<TravelRequestDetailView
v-if="selectedRow"
:request="selectedRow"
back-label="返回审批列表"
approval-mode
@back-to-requests="closeSelectedDetail"
@request-updated="handleDetailUpdated"
@request-deleted="handleDetailDeleted"
/>
<!-- Progress Bar -->
<div class="modal-progress">
<div class="progress-track">
<div v-for="(step, idx) in approvalSteps" :key="step.label" class="progress-node" :class="{ done: step.done, active: step.active, current: step.current }">
<span class="node-dot">
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<div class="node-label">
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
<span v-if="idx < approvalSteps.length - 1" class="node-line" :class="{ filled: step.done || step.active }"></span>
</div>
</div>
</div>
<!-- Modal Body -->
<div class="modal-body">
<div class="body-grid">
<!-- Left Column -->
<div class="body-main">
<!-- 费用摘要 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-clipboard-text-outline"></i>
<h3>费用摘要</h3>
</div>
</div>
<div class="metrics-strip">
<div class="metric-block amount">
<span class="metric-label">报销金额</span>
<strong class="metric-value">{{ selectedRow.amount }}</strong>
</div>
<div class="metric-block">
<span class="metric-label">SLA 剩余</span>
<strong class="metric-value sla" :class="selectedRow.slaTone">
<i class="mdi mdi-clock-outline"></i>
{{ selectedRow.sla }}
</strong>
</div>
<div class="metric-block">
<span class="metric-label">费用明细</span>
<strong class="metric-value">5 </strong>
</div>
<div class="metric-block">
<span class="metric-label">附件材料</span>
<strong class="metric-value">6 </strong>
</div>
</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-cell">
<div class="cell-icon"><i :class="item.icon"></i></div>
<div class="cell-content">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</article>
<!-- 费用明细 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-receipt-text-outline"></i>
<h3>费用明细</h3>
</div>
<span class="card-badge">合计 6,920</span>
</div>
<div class="expense-table-wrap">
<table class="expense-table">
<thead>
<tr>
<th>费用项目</th>
<th>说明</th>
<th class="right">金额</th>
<th class="center">是否超标</th>
</tr>
</thead>
<tbody>
<tr v-for="item in expenseItems" :key="item.name">
<td><strong>{{ item.name }}</strong></td>
<td>{{ item.desc }}</td>
<td class="right">{{ item.amount }}</td>
<td class="center">
<span class="over-badge" :class="item.tone">
<i class="mdi" :class="item.tone === 'ok' ? 'mdi-check-circle' : 'mdi-alert-circle'"></i>
{{ item.status }}
</span>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>合计</strong></td>
<td class="right"><strong class="total-amount">6,920</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</article>
<!-- 审批意见 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-comment-text-outline"></i>
<h3>审批意见</h3>
</div>
</div>
<div class="opinion-wrap">
<textarea rows="4" placeholder="请输入审批意见..."></textarea>
</div>
</article>
</div>
<!-- Right Column -->
<aside class="body-side">
<!-- AI 风险识别 -->
<article class="side-card risk-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-robot-outline"></i>
<h3>AI 风险识别</h3>
</div>
<div class="risk-total high">
<span>综合风险</span>
<strong></strong>
</div>
</div>
<div class="risk-items">
<div v-for="risk in riskItems" :key="risk.text" class="risk-row" :class="risk.tone">
<div class="risk-icon">
<i :class="risk.icon"></i>
</div>
<span class="risk-text">{{ risk.text }}</span>
<span class="risk-level" :class="risk.tone">{{ risk.level }}</span>
</div>
</div>
<div class="risk-note">
<strong>AI 审核建议</strong>
<p>优先补齐酒店入住清单并复核出租车发票抬头与超标费用说明完成后可继续流转</p>
</div>
</article>
<!-- 附件材料 -->
<article class="side-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-paperclip"></i>
<h3>附件材料</h3>
</div>
<span class="card-badge warn">1 份缺失</span>
</div>
<div class="attachment-list-side">
<div v-for="file in attachments" :key="file.name" class="attachment-row" :class="{ missing: file.missing }">
<div class="file-icon-sm" :class="file.iconClass">
<i :class="file.icon"></i>
</div>
<div class="file-detail">
<strong>{{ file.name }}</strong>
<span>{{ file.size }}</span>
</div>
</div>
</div>
</article>
</aside>
</div>
</div>
<!-- Modal Footer -->
<footer class="modal-footer">
<div class="footer-left">
<button class="action-btn back" type="button" @click="selectedRow = null">
<i class="mdi mdi-arrow-left"></i>
<span>返回列表</span>
</button>
</div>
<div class="footer-right">
<button class="action-btn supplement" type="button">
<i class="mdi mdi-undo"></i>
<span>补充材料</span>
</button>
<button class="action-btn reject" type="button">
<i class="mdi mdi-close-circle-outline"></i>
<span>驳回</span>
</button>
<button class="action-btn approve" type="button">
<i class="mdi mdi-check-circle-outline"></i>
<span>通过</span>
</button>
</div>
</footer>
</div>
</div>
</Transition>
</Teleport>
<div v-if="selectedRow" class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="applicant-card">
<div class="portrait">{{ selectedRow.avatar }}</div>
<div>
<h2>{{ selectedRow.applicant }} <span>{{ selectedRow.department }}</span></h2>
<p>提交时间 <strong>{{ selectedRow.time }}</strong></p>
</div>
</div>
<div class="hero-stat">
<span>金额</span>
<strong>{{ selectedRow.amount }}</strong>
</div>
<div class="hero-stat">
<span>风险等级</span>
<b :class="['risk-pill', selectedRow.riskTone]">{{ selectedRow.risk }}</b>
</div>
<div class="hero-stat">
<span>当前状态</span>
<b class="state-pill">{{ selectedRow.node }}</b>
</div>
<div class="hero-stat">
<span>SLA 剩余时间</span>
<strong class="countdown"><i class="mdi mdi-clock-outline"></i> 剩余 {{ selectedRow.sla }}</strong>
</div>
<div class="hero-summary-panel">
<div v-for="item in heroSummaryItems" :key="item.label" class="hero-summary-item">
<div class="hero-summary-label">
<span class="hero-summary-icon"><i :class="item.icon"></i></span>
<span>{{ item.label }}</span>
</div>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="progress-block">
<div class="progress-head">
<h3>当前进度</h3>
</div>
<div class="progress-line">
<div v-for="step in approvalSteps" :key="step.label" class="progress-step" :class="{ active: step.active, current: step.current }">
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
</div>
</div>
</article>
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>费用明细</h3>
<p>按发生时间逐笔展示附件与 AI 风险直接在表内完成核对</p>
</div>
<span class="detail-total">{{ expenseTotal }}</span>
</div>
<div class="detail-expense-table">
<table>
<thead>
<tr>
<th>时间</th>
<th>费用项目</th>
<th>说明</th>
<th>金额</th>
<th>附件材料</th>
<th>AI 风险识别</th>
</tr>
</thead>
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr>
<td class="expense-time">
<strong>{{ item.time }}</strong>
<span>{{ item.dayLabel }}</span>
</td>
<td class="expense-type">
<strong>{{ item.name }}</strong>
<span>{{ item.category }}</span>
</td>
<td class="expense-desc">
<strong>{{ item.desc }}</strong>
<span>{{ item.detail }}</span>
</td>
<td class="expense-amount">
<strong>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</td>
<td class="expense-attachment">
<div class="expense-attachment-main">
<span :class="['attachment-pill', item.attachmentTone]">{{ item.attachmentStatus }}</span>
<button
v-if="item.attachments.length"
class="inline-action"
type="button"
@click="toggleExpenseAttachments(item.id)"
>
{{ expandedExpenseId === item.id ? '收起附件' : '查看附件' }}
</button>
</div>
<span class="attachment-hint">{{ item.attachmentHint }}</span>
</td>
<td class="expense-risk">
<template v-if="showExpenseRisk(item)">
<span :class="['risk-inline-tag', item.riskTone]">{{ item.riskLabel }}</span>
<p>{{ item.riskText }}</p>
</template>
</td>
</tr>
<tr v-if="expandedExpenseId === item.id" class="expense-expand-row">
<td colspan="6">
<div class="expense-files">
<span v-for="file in item.attachments" :key="file" class="expense-file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</td>
</tr>
</template>
<tr class="total-row">
<td colspan="3">合计</td>
<td>{{ expenseTotal }}</td>
<td>{{ uploadedExpenseCount }} 项已上传票据</td>
<td>1 项待补材料1 项需补充超标说明</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="detail-card panel">
<h3>审批意见</h3>
<textarea rows="3" placeholder="输入审批意见..."></textarea>
</article>
</section>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedRow = null">
<i class="mdi mdi-arrow-left"></i>
<span>退回列表</span>
</button>
<div class="approval-action-group" aria-label="审批操作">
<button class="approve-action" type="button" :disabled="actionBusy">
<i class="mdi mdi-check-circle-outline"></i> 通过
</button>
<button
class="reject-action"
type="button"
:disabled="!canManageClaims || actionBusy"
@click="handleReturnSelected"
>
<i class="mdi mdi-close-circle-outline"></i> 驳回
</button>
<button
class="supplement-action"
type="button"
:disabled="!canManageClaims || actionBusy"
@click="handleReturnSelected"
>
<i class="mdi mdi-undo"></i> 补充
</button>
<button
v-if="canManageClaims"
class="reject-action"
type="button"
:disabled="actionBusy"
@click="handleDeleteSelected"
>
<i class="mdi mdi-trash-can-outline"></i> 删除
</button>
</div>
</footer>
</div>
<!-- Approval List -->
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="审批状态">
<button
@@ -529,38 +115,6 @@
</table>
</div>
</article>
<ConfirmDialog
:open="returnDialogOpen"
badge="退回单据"
badge-tone="warning"
:title="`确认退回 ${selectedRow?.id || ''} 吗?`"
description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
cancel-text="取消"
confirm-text="确认退回"
busy-text="退回中..."
confirm-tone="primary"
confirm-icon="mdi mdi-undo"
:busy="actionBusy"
@close="closeReturnDialog"
@confirm="confirmReturnSelected"
/>
<ConfirmDialog
:open="deleteDialogOpen"
badge="删除单据"
badge-tone="danger"
:title="`确认删除 ${selectedRow?.id || ''} 吗?`"
description="删除后该报销单及费用明细将不可恢复,请确认本次操作。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-trash-can-outline"
:busy="actionBusy"
@close="closeDeleteDialog"
@confirm="confirmDeleteSelected"
/>
</section>
</template>

View File

@@ -287,13 +287,11 @@
class="history-row"
>
<strong>{{ item.action }}</strong>
<div class="history-row-meta">
<span class="history-row-owner">{{ item.owner }}</span>
<small class="history-row-time">{{
formatEmployeeHistoryTime(item.time || item.occurredAt)
}}</small>
</div>
</div>
<p v-if="!recentEmployeeHistory.length" class="manager-picker-empty">
暂无变更记录
</p>

View File

@@ -619,29 +619,45 @@
v-if="activeReviewPayload"
type="button"
class="review-insight-switch-icon-btn"
:class="{
available: true,
active: isReviewOverviewDrawer
}"
:disabled="submitting || reviewActionBusy"
title="报销识别核对"
aria-label="报销识别核对"
@click="switchToReviewOverviewDrawer"
>
<i :class="isReviewOverviewDrawer ? 'mdi mdi-clipboard-check' : 'mdi mdi-clipboard-check-outline'"></i>
</button>
<button
v-if="activeReviewPayload && reviewDocumentDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn"
:class="{
available: reviewDocumentDrawerAvailable,
active: reviewDocumentDrawerAvailable && isReviewDocumentDrawer
}"
:disabled="!reviewDocumentDrawerAvailable || submitting || reviewActionBusy"
:title="reviewDocumentDrawerLabel"
:aria-label="reviewDocumentDrawerLabel"
:disabled="submitting || reviewActionBusy"
title="单据识别"
aria-label="单据识别"
@click="toggleReviewDocumentDrawer"
>
<i :class="reviewDocumentDrawerIcon"></i>
</button>
<button
v-if="activeReviewPayload"
v-if="activeReviewPayload && reviewRiskDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn risk"
:class="{
available: reviewRiskDrawerAvailable,
active: reviewRiskDrawerAvailable && isReviewRiskDrawer
}"
:disabled="!reviewRiskDrawerAvailable || submitting || reviewActionBusy"
:title="reviewRiskDrawerLabel"
:aria-label="reviewRiskDrawerLabel"
:disabled="submitting || reviewActionBusy"
title="显示风险"
aria-label="显示风险"
@click="toggleReviewRiskDrawer"
>
<i :class="reviewRiskDrawerIcon"></i>
@@ -656,8 +672,8 @@
running: flowOverallStatusTone === 'running'
}"
:disabled="!reviewFlowDrawerAvailable || submitting || reviewActionBusy"
:title="reviewFlowDrawerLabel"
:aria-label="reviewFlowDrawerLabel"
title="调用流程"
aria-label="调用流程"
@click="toggleReviewFlowDrawer"
>
<i :class="reviewFlowDrawerIcon"></i>

View File

@@ -14,10 +14,27 @@
<h2>{{ profile.name }}</h2>
<span class="identity-badge">{{ profile.identity }}</span>
</div>
<div class="applicant-meta-line">
<span><em>部门</em><strong>{{ profile.department }}</strong></span>
<span><em>职级</em><strong>{{ profile.grade }}</strong></span>
<span><em>直属上司</em><strong>{{ profile.manager }}</strong></span>
<div class="applicant-profile-meta">
<div class="applicant-profile-meta__org">
<span class="applicant-meta-item">
<em>部门</em>
<strong>{{ profile.department }}</strong>
</span>
<span class="applicant-meta-item applicant-meta-item--sub">
<em>直属上司</em>
<strong>{{ profile.manager }}</strong>
</span>
</div>
<div class="applicant-profile-meta__role">
<span class="applicant-meta-item">
<em>职级</em>
<strong>{{ profile.grade }}</strong>
</span>
<span class="applicant-meta-item">
<em>岗位</em>
<strong>{{ profile.position }}</strong>
</span>
</div>
</div>
</div>
</div>
@@ -59,8 +76,11 @@
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<div class="progress-step-copy" :title="step.title || step.detail || step.time">
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
<small class="progress-step-status">{{ step.time }}</small>
<em v-if="step.detail" class="progress-step-meta">{{ step.detail }}</em>
</div>
</div>
</div>
</div>
@@ -73,11 +93,11 @@
<div>
<h3>费用明细</h3>
<p>
{{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与系统校验。' }}
{{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。' }}
</p>
</div>
<div class="detail-card-actions">
<button class="smart-entry-btn" type="button" @click="openAiEntry">
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
<i class="mdi mdi-robot-outline"></i>
<span>智能录入</span>
</button>
@@ -99,11 +119,11 @@
<thead>
<tr>
<th class="col-time">时间</th>
<th class="col-filled-at">填写时间</th>
<th class="col-type">费用项目</th>
<th class="col-desc">说明</th>
<th class="col-amount">金额</th>
<th class="col-attachment">附件材料</th>
<th v-if="hasExpenseRiskColumn" class="col-risk">系统校验</th>
<th v-if="isEditableRequest" class="col-action">操作</th>
</tr>
</thead>
@@ -122,6 +142,10 @@
<span>{{ item.dayLabel }}</span>
</template>
</td>
<td class="expense-filled-at col-filled-at">
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td class="expense-type col-type">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
@@ -140,9 +164,9 @@
</td>
<td class="expense-desc col-desc">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor editor-stack">
<div class="cell-editor">
<input v-model="expenseEditor.itemReason" class="editor-input" type="text" placeholder="输入费用说明" />
<input v-model="expenseEditor.itemLocation" class="editor-input" type="text" :placeholder="locationInputPlaceholder" />
<span>业务报销说明</span>
</div>
</template>
<template v-else>
@@ -177,9 +201,11 @@
<div class="cell-editor editor-stack">
<div class="attachment-action-group">
<button
v-if="isEditableRequest && !item.invoiceId"
class="icon-action upload"
type="button"
title="上传附件"
title="上传单据"
aria-label="上传单据"
:disabled="actionBusy"
@click="triggerExpenseUpload(item)"
>
@@ -189,51 +215,34 @@
v-if="canPreviewAttachment(item)"
class="icon-action preview"
type="button"
title="查看附件"
:title="resolveAttachmentPreviewTitle(item)"
:aria-label="resolveAttachmentPreviewTitle(item)"
@click="openAttachmentPreview(item)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
v-if="item.invoiceId"
v-if="isEditableRequest && item.invoiceId"
class="icon-action danger"
type="button"
title="删除附件"
aria-label="删除附件"
:disabled="deletingAttachmentId === item.id"
@click="removeExpenseAttachment(item)"
>
<i :class="deletingAttachmentId === item.id ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-close-thick'"></i>
</button>
</div>
<span class="attachment-hint compact">
{{ resolveAttachmentDisplayName(item) || '支持上传 JPG、PNG、PDF未上传也可先保存草稿。' }}
</span>
<div v-if="resolveAttachmentRecognition(item)" class="attachment-recognition">
<div class="attachment-recognition-pills">
<span class="attachment-recognition-pill type">
{{ resolveAttachmentRecognition(item).documentTypeLabel }}
</span>
<span
:class="['attachment-recognition-pill', resolveAttachmentRecognition(item).requirementTone]"
>
{{ resolveAttachmentRecognition(item).requirementLabel }}
</span>
</div>
<p v-if="resolveAttachmentRecognition(item).message" class="attachment-recognition-message">
{{ resolveAttachmentRecognition(item).message }}
</p>
<ul v-if="resolveAttachmentRecognition(item).fields.length" class="attachment-recognition-fields">
<li v-for="field in resolveAttachmentRecognition(item).fields" :key="field">{{ field }}</li>
</ul>
</div>
</div>
</template>
<template v-else>
<div class="attachment-action-group">
<button
v-if="isEditableRequest && !item.invoiceId"
class="icon-action upload"
type="button"
title="上传附件"
title="上传单据"
aria-label="上传单据"
:disabled="actionBusy"
@click="triggerExpenseUpload(item)"
>
@@ -243,58 +252,24 @@
v-if="canPreviewAttachment(item)"
class="icon-action preview"
type="button"
title="查看附件"
:title="resolveAttachmentPreviewTitle(item)"
:aria-label="resolveAttachmentPreviewTitle(item)"
@click="openAttachmentPreview(item)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
v-if="item.invoiceId"
v-if="isEditableRequest && item.invoiceId"
class="icon-action danger"
type="button"
title="删除附件"
aria-label="删除附件"
:disabled="deletingAttachmentId === item.id"
@click="removeExpenseAttachment(item)"
>
<i :class="deletingAttachmentId === item.id ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-close-thick'"></i>
</button>
</div>
<span class="attachment-hint compact">
{{ resolveAttachmentDisplayName(item) || '未上传附件' }}
</span>
<div v-if="resolveAttachmentRecognition(item)" class="attachment-recognition">
<div class="attachment-recognition-pills">
<span class="attachment-recognition-pill type">
{{ resolveAttachmentRecognition(item).documentTypeLabel }}
</span>
<span
:class="['attachment-recognition-pill', resolveAttachmentRecognition(item).requirementTone]"
>
{{ resolveAttachmentRecognition(item).requirementLabel }}
</span>
</div>
<p v-if="resolveAttachmentRecognition(item).message" class="attachment-recognition-message">
{{ resolveAttachmentRecognition(item).message }}
</p>
<ul v-if="resolveAttachmentRecognition(item).fields.length" class="attachment-recognition-fields">
<li v-for="field in resolveAttachmentRecognition(item).fields" :key="field">{{ field }}</li>
</ul>
</div>
</template>
</td>
<td v-if="hasExpenseRiskColumn" class="expense-risk col-risk">
<template v-if="showExpenseRisk(item)">
<span :class="['risk-inline-tag', resolveExpenseRiskState(item).tone]">
{{ resolveExpenseRiskState(item).label }}
</span>
<strong class="risk-headline">{{ resolveExpenseRiskState(item).headline }}</strong>
<p>{{ resolveExpenseRiskState(item).summary }}</p>
<ul v-if="resolveExpenseRiskState(item).points.length" class="risk-point-list">
<li v-for="point in resolveExpenseRiskState(item).points" :key="point">{{ point }}</li>
</ul>
<p v-if="resolveExpenseRiskState(item).suggestion" class="risk-suggestion">
{{ resolveExpenseRiskState(item).suggestion }}
</p>
</template>
</td>
<td v-if="isEditableRequest" class="expense-action-cell col-action">
@@ -350,17 +325,6 @@
当前还没有费用明细点击右上角增加明细继续补充
</td>
</tr>
<tr class="total-row">
<td :colspan="expenseTableColumnCount">
<div class="expense-total-bar">
<strong>合计 {{ expenseTotal }}</strong>
<div class="expense-total-meta">
<span>{{ uploadedExpenseCount }} 项已关联票据</span>
<span>{{ expenseSummaryText }}</span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@@ -375,6 +339,31 @@
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
</div>
<p class="validation-summary">{{ aiAdvice.summary }}</p>
<div v-if="aiAdvice.riskCards.length" class="risk-advice-list">
<article
v-for="card in aiAdvice.riskCards"
:key="card.id"
:class="['risk-advice-card', card.tone]"
>
<div class="risk-advice-card-head">
<span>{{ card.label }}</span>
<strong>{{ card.title }}</strong>
</div>
<p class="risk-advice-point">{{ card.risk }}</p>
<div class="risk-advice-meta">
<div>
<span>规则依据</span>
<ul>
<li v-for="basis in card.ruleBasis" :key="basis">{{ basis }}</li>
</ul>
</div>
<div>
<span>修改建议</span>
<p>{{ card.suggestion }}</p>
</div>
</div>
</article>
</div>
<ul v-if="aiAdvice.items.length" class="validation-list">
<li v-for="item in aiAdvice.items" :key="item">{{ item }}</li>
</ul>
@@ -384,6 +373,20 @@
<h3>附加说明</h3>
<div class="detail-note">{{ detailNote }}</div>
</article>
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
<h3>领导意见</h3>
<textarea
v-model="leaderOpinion"
maxlength="500"
placeholder="请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。"
aria-label="领导意见"
></textarea>
<div class="leader-opinion-meta">
<span>审批通过后将流转至财务审批</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</article>
</section>
</div>
</div>
@@ -391,7 +394,7 @@
<footer class="detail-actions">
<button class="back-action" type="button" @click="emit('backToRequests')">
<i class="mdi mdi-arrow-left"></i>
<span>返回报销列表</span>
<span>{{ backLabel }}</span>
</button>
<div v-if="isEditableRequest" class="approval-action-group" aria-label="申请操作">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
@@ -403,7 +406,7 @@
{{ submitBusy ? '提交中' : '提交审批' }}
</button>
</div>
<div v-else-if="canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<div v-else-if="canReturnRequest || canApproveRequest || canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<button
v-if="canReturnRequest"
class="return-action"
@@ -414,7 +417,23 @@
<i class="mdi mdi-undo"></i>
{{ returnBusy ? '退回中' : '退回单据' }}
</button>
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
<button
v-if="canApproveRequest"
class="approve-action"
type="button"
:disabled="actionBusy"
@click="handleApproveRequest"
>
<i class="mdi mdi-check-circle-outline"></i>
{{ approveBusy ? '通过中' : '审批通过' }}
</button>
<button
v-if="canManageCurrentClaim"
class="reject-action"
type="button"
:disabled="actionBusy"
@click="handleDeleteRequest"
>
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除单据' }}
</button>
@@ -444,12 +463,36 @@
<span class="attachment-preview-badge">附件预览</span>
<h4>{{ attachmentPreviewName || '当前附件' }}</h4>
</div>
<div class="attachment-preview-toolbar">
<button
v-if="canNavigateAttachmentPreview"
class="attachment-preview-nav"
type="button"
title="上一份附件"
:disabled="attachmentPreviewLoading"
@click="goToPreviousAttachmentPreview"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<span v-if="attachmentPreviewIndexLabel" class="attachment-preview-count">{{ attachmentPreviewIndexLabel }}</span>
<button
v-if="canNavigateAttachmentPreview"
class="attachment-preview-nav"
type="button"
title="下一份附件"
:disabled="attachmentPreviewLoading"
@click="goToNextAttachmentPreview"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<button class="attachment-preview-close" type="button" @click="closeAttachmentPreview">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="attachment-preview-body">
<div class="attachment-source-pane">
<div v-if="attachmentPreviewLoading" class="attachment-preview-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载附件预览</span>
@@ -475,10 +518,90 @@
<span>当前附件暂不支持直接预览</span>
</div>
</div>
<aside class="attachment-insight-pane">
<div class="attachment-insight-head">
<span>识别信息</span>
<strong>{{ currentAttachmentPreviewInsight?.documentTypeLabel || '待识别' }}</strong>
</div>
<div v-if="currentAttachmentPreviewInsight" class="attachment-insight-content">
<div class="attachment-insight-pills">
<span :class="['attachment-recognition-pill', currentAttachmentPreviewInsight.requirementTone]">
{{ currentAttachmentPreviewInsight.requirementLabel }}
</span>
</div>
<p v-if="currentAttachmentPreviewInsight.message" class="attachment-recognition-message">
{{ currentAttachmentPreviewInsight.message }}
</p>
<div v-if="currentAttachmentPreviewInsight.fields.length" class="attachment-insight-section">
<span>字段结果</span>
<ul>
<li v-for="field in currentAttachmentPreviewInsight.fields" :key="field">{{ field }}</li>
</ul>
</div>
<div v-if="currentAttachmentPreviewInsight.ruleBasis.length" class="attachment-insight-section">
<span>规则依据</span>
<ul>
<li v-for="basis in currentAttachmentPreviewInsight.ruleBasis" :key="basis">{{ basis }}</li>
</ul>
</div>
<div v-if="currentAttachmentPreviewRiskCards.length" class="attachment-insight-section risk">
<span>风险点</span>
<article
v-for="card in currentAttachmentPreviewRiskCards"
:key="card.id"
:class="['attachment-risk-card', card.tone]"
>
<strong>{{ card.risk }}</strong>
<p>{{ card.suggestion }}</p>
</article>
</div>
</div>
<div v-else class="attachment-preview-state compact">
<i class="mdi mdi-file-search-outline"></i>
<span>预览打开后会在这里展示票据字段规则依据和风险提示</span>
</div>
</aside>
</div>
</section>
</div>
</Transition>
<ConfirmDialog
:open="submitConfirmDialogOpen"
badge="提交确认"
badge-tone="warning"
:title="`确认提交 ${request.id} 吗?`"
description="请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。"
cancel-text="返回核对"
confirm-text="确认提交"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-circle-outline"
:busy="submitBusy"
@close="closeSubmitConfirmDialog"
@confirm="confirmSubmitRequest"
>
<div class="submit-confirm-summary" aria-label="提交前核对摘要">
<div class="submit-confirm-row">
<span>单据编号</span>
<strong>{{ request.documentNo || request.id }}</strong>
</div>
<div class="submit-confirm-row">
<span>报销类型</span>
<strong>{{ request.typeLabel }}</strong>
</div>
<div class="submit-confirm-row">
<span>报销金额</span>
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
</div>
<div class="submit-confirm-row">
<span>费用明细</span>
<strong>{{ expenseItems.length }} / {{ uploadedExpenseCount }} 张单据</strong>
</div>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="deleteDialogOpen"
:badge="deleteActionLabel"
@@ -496,16 +619,44 @@
/>
<ConfirmDialog
:open="approveConfirmDialogOpen"
badge="领导审批"
badge-tone="info"
:title="`确认通过 ${request.id} 吗?`"
description="确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。"
cancel-text="返回核对"
confirm-text="确认通过"
busy-text="通过中..."
confirm-tone="primary"
confirm-icon="mdi mdi-check-circle-outline"
:busy="approveBusy"
@close="closeApproveConfirmDialog"
@confirm="confirmApproveRequest"
>
<div class="submit-confirm-summary" aria-label="领导审批通过摘要">
<div class="submit-confirm-row">
<span>单据编号</span>
<strong>{{ request.documentNo || request.id }}</strong>
</div>
<div class="submit-confirm-row">
<span>当前节点</span>
<strong>{{ request.node }}</strong>
</div>
<div class="submit-confirm-row">
<span>下一节点</span>
<strong>财务审批</strong>
</div>
<div class="submit-confirm-row">
<span>领导意见</span>
<strong>{{ leaderOpinion.trim() || '未填写' }}</strong>
</div>
</div>
</ConfirmDialog>
<ReturnReasonDialog
:open="returnDialogOpen"
badge="退回单据"
badge-tone="warning"
:title="`确认退回 ${request.id} 吗?`"
description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
cancel-text="取消"
confirm-text="确认退回"
busy-text="退回中..."
confirm-tone="primary"
confirm-icon="mdi mdi-undo"
:busy="returnBusy"
@close="closeReturnDialog"
@confirm="confirmReturnRequest"

View File

@@ -1,13 +1,12 @@
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const DEFAULT_SLA_HOURS = 24
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
@@ -61,94 +60,6 @@ function resolveRiskTone(riskFlags, riskSummary) {
return 'low'
}
function resolveRiskItems(request) {
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
const items = riskFlags
.map((item) => {
const tone = resolveRiskTone([item], '')
const text = String(item?.message || item?.label || item?.reason || '').trim()
if (!text) {
return null
}
return {
text,
level: tone === 'high' ? '高' : tone === 'medium' ? '中' : '低',
tone,
icon: tone === 'high' ? 'mdi mdi-alert-circle' : tone === 'medium' ? 'mdi mdi-alert' : 'mdi mdi-shield-check'
}
})
.filter(Boolean)
if (items.length) {
return items
}
const summary = String(request?.riskSummary || '').trim()
if (summary && summary !== '无') {
return summary.split('').filter(Boolean).map((text) => ({
text,
level: '中',
tone: 'medium',
icon: 'mdi mdi-alert'
}))
}
return [
{
text: 'AI预审已通过当前未发现额外风险。',
level: '低',
tone: 'low',
icon: 'mdi mdi-shield-check'
}
]
}
function resolveAttachmentMeta(name) {
const normalized = String(name || '').trim()
const lowerName = normalized.toLowerCase()
if (lowerName.endsWith('.pdf')) {
return { icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' }
}
if (/\.(png|jpg|jpeg|webp|bmp)$/i.test(lowerName)) {
return { icon: 'mdi mdi-image', iconClass: 'img' }
}
return { icon: 'mdi mdi-file-document-outline', iconClass: 'file' }
}
function buildAttachments(expenseItems) {
const seen = new Set()
const attachments = []
for (const item of Array.isArray(expenseItems) ? expenseItems : []) {
for (const fileName of Array.isArray(item?.attachments) ? item.attachments : []) {
const normalized = String(fileName || '').trim()
if (!normalized || seen.has(normalized)) {
continue
}
seen.add(normalized)
attachments.push({
name: normalized,
size: '已识别',
...resolveAttachmentMeta(normalized)
})
}
}
if (attachments.length) {
return attachments
}
return [
{
name: '当前无附件',
size: '待补充',
icon: 'mdi mdi-file-document-outline',
iconClass: 'miss',
missing: true
}
]
}
function resolveSlaMeta(submittedAt) {
const startAt = toDate(submittedAt)
if (!startAt) {
@@ -173,55 +84,8 @@ function resolveSlaMeta(submittedAt) {
return { label, tone: 'safe', urgent: false }
}
function buildHeroSummaryItems(request) {
return [
{ label: '单号', value: request.id || '-', icon: 'mdi mdi-pound-box-outline' },
{ label: '报销类型', value: request.typeLabel || '-', icon: 'mdi mdi-briefcase-outline' },
{ label: '业务地点', value: request.sceneTarget || '待补充', icon: 'mdi mdi-map-marker-outline' },
{ label: '发生时间', value: request.occurredDisplay || '待补充', icon: 'mdi mdi-calendar-range' },
{ label: '票据关联', value: request.attachmentSummary || '无', icon: 'mdi mdi-paperclip' },
{ label: '事由', value: request.title || '待补充', icon: 'mdi mdi-text-box-outline' }
]
}
function buildFlowItems(request) {
return Array.isArray(request?.progressSteps)
? request.progressSteps.map((item) => ({
label: item.label,
desc: item.current ? '当前处理节点' : item.done ? '已完成' : '待处理',
time: item.time,
icon: item.current ? 'mdi mdi-circle-slice-8' : item.done ? 'mdi mdi-check' : 'mdi mdi-circle-outline',
current: item.current,
pending: !item.done && !item.current
}))
: []
}
function canCurrentUserProcessRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
const currentName = String(currentUser?.name || '').trim()
const applicantName = String(request?.person || request?.employeeName || '').trim()
if (currentName && applicantName && currentName === applicantName) {
return false
}
if (canManageExpenseClaims(currentUser)) {
return true
}
return (
node.includes('直属领导')
|| node.includes('领导审批')
|| node.includes('部门负责人')
|| node.includes('负责人审批')
)
}
function buildApprovalRow(request) {
const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary)
const riskItems = resolveRiskItems(request)
const expenseItems = Array.isArray(request.expenseItems) ? request.expenseItems : []
const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt)
const statusTone = slaMeta.urgent ? 'urgent' : 'pending'
@@ -240,37 +104,35 @@ function buildApprovalRow(request) {
node: request.workflowNode || '审批中',
status: statusTone === 'urgent' ? '即将超时' : '待审批',
statusTone,
spotlight: riskTone === 'high' || statusTone === 'urgent',
heroSummaryItems: buildHeroSummaryItems(request),
summaryItems: buildHeroSummaryItems(request).slice(2),
progressSteps: Array.isArray(request.progressSteps) ? request.progressSteps : [],
expenseItems,
attachments: buildAttachments(expenseItems),
riskItems,
flowItems: buildFlowItems(request)
spotlight: riskTone === 'high' || statusTone === 'urgent'
}
}
export default {
name: 'ApprovalCenterView',
components: {
ConfirmDialog,
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
},
setup() {
const { currentUser } = useSystemState()
const { toast } = useToast()
const { markClaimViewed, syncPendingClaimIds } = useApprovalInbox()
const activeTab = ref('全部待审')
const selectedClaimId = ref('')
const expandedExpenseId = ref(null)
const listKeyword = ref('')
const rows = ref([])
const loading = ref(false)
const error = ref('')
const actionBusy = ref(false)
const returnDialogOpen = ref(false)
const deleteDialogOpen = ref(false)
watch(
() => selectedClaimId.value,
(claimId) => {
if (claimId) {
markClaimViewed(claimId)
}
}
)
const selectedRow = computed({
get() {
@@ -278,14 +140,12 @@ export default {
},
set(value) {
selectedClaimId.value = value?.claimId || ''
expandedExpenseId.value = null
}
})
const visibleRows = computed(() => {
let filteredRows = rows.value
// 根据标签筛选
if (activeTab.value === '高风险') {
filteredRows = filteredRows.filter((row) => row.riskTone === 'high')
} else if (activeTab.value === '即将超时') {
@@ -294,25 +154,20 @@ export default {
filteredRows = []
}
// 根据搜索关键词筛选
if (listKeyword.value.trim()) {
const keyword = listKeyword.value.trim().toLowerCase()
filteredRows = filteredRows.filter((row) => {
return (
String(row.id || '').toLowerCase().includes(keyword) ||
String(row.applicant || '').toLowerCase().includes(keyword) ||
String(row.department || '').toLowerCase().includes(keyword) ||
String(row.type || '').toLowerCase().includes(keyword) ||
String(row.amount || '').toLowerCase().includes(keyword)
)
})
filteredRows = filteredRows.filter((row) => (
String(row.id || '').toLowerCase().includes(keyword)
|| String(row.applicant || '').toLowerCase().includes(keyword)
|| String(row.department || '').toLowerCase().includes(keyword)
|| String(row.type || '').toLowerCase().includes(keyword)
|| String(row.amount || '').toLowerCase().includes(keyword)
))
}
return filteredRows
})
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
const approvalEmptyState = computed(() => {
if (!rows.value.length) {
return {
@@ -343,45 +198,6 @@ export default {
}
})
const approvalSteps = computed(() => selectedRow.value?.progressSteps || [])
const summaryItems = computed(() => selectedRow.value?.summaryItems || [])
const heroSummaryItems = computed(() => selectedRow.value?.heroSummaryItems || [])
const expenseItems = computed(() => selectedRow.value?.expenseItems || [])
const expenseTotal = computed(() => selectedRow.value?.amount || formatCurrency(0))
const uploadedExpenseCount = computed(
() => expenseItems.value.filter((item) => Array.isArray(item?.attachments) && item.attachments.length).length
)
const attachments = computed(() => selectedRow.value?.attachments || [])
const riskItems = computed(() => selectedRow.value?.riskItems || [])
const flowItems = computed(() => selectedRow.value?.flowItems || [])
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1]
}
}
}
function showExpenseRisk(item) {
return ['medium', 'high'].includes(String(item?.riskTone || '').trim())
}
function toggleExpenseAttachments(id) {
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
}
function handleEmptyAction() {
if (!rows.value.length) {
void reload()
@@ -391,74 +207,18 @@ export default {
activeTab.value = '全部待审'
}
function handleReturnSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
function closeSelectedDetail() {
selectedClaimId.value = ''
}
returnDialogOpen.value = true
}
function handleDeleteSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
deleteDialogOpen.value = true
}
function closeReturnDialog() {
if (!actionBusy.value) {
returnDialogOpen.value = false
}
}
function closeDeleteDialog() {
if (!actionBusy.value) {
deleteDialogOpen.value = false
}
}
async function confirmReturnSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
await returnExpenseClaim(row.claimId, {
reason: '审批中心退回,请申请人调整后重新提交。'
})
toast(`${row.id} 已退回待提交。`)
returnDialogOpen.value = false
async function handleDetailUpdated() {
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '退回单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
}
async function confirmDeleteSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
const payload = await deleteExpenseClaim(row.claimId)
toast(payload?.message || `${row.id} 报销单已删除。`)
deleteDialogOpen.value = false
async function handleDetailDeleted() {
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '删除单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
}
async function reload() {
@@ -466,15 +226,11 @@ export default {
error.value = ''
try {
const payload = await fetchExpenseClaims()
const mappedRows = Array.isArray(payload)
? payload
.map((item) => mapExpenseClaimToRequest(item))
.filter((item) => item.approvalKey === 'in_progress')
.filter((item) => canCurrentUserProcessRequest(item, currentUser.value))
.map((item) => buildApprovalRow(item))
: []
const payload = await fetchApprovalExpenseClaims()
const pendingRequests = listPendingApprovalRequests(payload, currentUser.value)
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item))
rows.value = mappedRows
syncPendingClaimIds(mappedRows.map((item) => item.claimId))
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
selectedClaimId.value = ''
}
@@ -491,42 +247,21 @@ export default {
return {
activeTab,
selectedRow,
expandedExpenseId,
listKeyword,
tabs,
filters,
rows,
visibleRows,
showTable,
showEmpty,
actionBusy,
approvalEmptyState,
approvalSteps,
canManageClaims,
closeDeleteDialog,
closeReturnDialog,
confirmDeleteSelected,
confirmReturnSelected,
deleteDialogOpen,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
expenseItems,
expenseTotal,
uploadedExpenseCount,
showExpenseRisk,
toggleExpenseAttachments,
attachments,
riskItems,
flowItems,
handleEmptyAction,
handleDeleteSelected,
handleReturnSelected,
loading,
closeSelectedDetail,
error,
returnDialogOpen,
reload
filters,
handleDetailDeleted,
handleDetailUpdated,
handleEmptyAction,
listKeyword,
loading,
reload,
rows,
selectedRow,
showEmpty,
tabs,
visibleRows
}
}
}

View File

@@ -258,6 +258,10 @@ function sameValues(left, right) {
return left.every((value, index) => value === right[index])
}
function padDatePart(value) {
return String(Number(value)).padStart(2, '0')
}
function formatEmployeeHistoryTime(value) {
const raw = normalizeText(value)
if (!raw) {
@@ -269,13 +273,13 @@ function formatEmployeeHistoryTime(value) {
)
if (chineseMatched) {
const [, year, month, day, hour, minute] = chineseMatched
return `${year}${Number(month)}${Number(day)}${Number(hour)}${Number(minute)}`
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
}
const isoMatched = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/)
const isoMatched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2}))?/)
if (isoMatched) {
const [, year, month, day, hour = '0', minute = '0'] = isoMatched
return `${year}${Number(month)}${Number(day)}${Number(hour)}${Number(minute)}`
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
}
return raw.replace(/(\d{1,2}分)\d{1,2}秒$/, '$1')

View File

@@ -24,7 +24,7 @@ export default {
emits: ['ask', 'approve', 'reject', 'create-request', 'reload'],
setup(props, { emit }) {
const activeTab = ref('全部')
const tabs = ['全部', '草稿', '审批中', '待补充', '已完成']
const tabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
const filters = ['报销状态', '报销类型', '所属主体']
const listKeyword = ref('')
@@ -98,8 +98,9 @@ export default {
const matchesTab =
activeTab.value === '全部'
|| (activeTab.value === '草稿' && row.approvalKey === 'draft')
|| (activeTab.value === '待提交' && row.approvalKey === 'supplement' && row.status === 'returned')
|| (activeTab.value === '审批中' && row.approvalKey === 'in_progress')
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement')
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement' && row.status !== 'returned')
|| (activeTab.value === '已完成' && row.approvalKey === 'completed')
return matchesKeyword && matchesDateRange && matchesTab
@@ -150,7 +151,7 @@ export default {
artLabel: hasListFilters.value ? 'FILTER' : 'QUEUE',
tips: hasListFilters.value
? ['关键词、时间段和状态会叠加生效', '可尝试搜索单号、事由或报销类型']
: ['已完成单据会保留在列表中便于追踪', '草稿、审批中和待补充会按真实状态实时归类']
: ['已完成单据会保留在列表中便于追踪', '草稿、待提交、审批中和待补充会按真实状态实时归类']
}
})

View File

@@ -8,6 +8,12 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
import { renderMarkdown } from '../../utils/markdown.js'
import {
buildLocalExtractionProgressMessages,
buildLocalIntentPreview,
summarizeSemanticIntentDetail,
TRANSPORT_KEYWORD_PATTERN
} from '../../utils/reimbursementTextInference.js'
import {
fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail,
@@ -284,7 +290,7 @@ const HOT_KNOWLEDGE_QUESTIONS = [
const CATEGORY_CONFIDENCE_KEYWORDS = {
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
hotel: [/住宿|酒店|宾馆|民宿/],
transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/],
transport: [TRANSPORT_KEYWORD_PATTERN],
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
meeting: [/会务|会议|论坛|展会|参会|会场/],
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
@@ -304,13 +310,6 @@ const FLOW_MISSING_SLOT_LABELS = {
participants: '参与人员',
attachments: '票据附件'
}
const FLOW_INTENT_KEYWORDS = {
draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'],
query: ['查询', '查一下', '多少', '明细', '统计'],
risk_check: ['风险', '异常', '重复', '超标'],
explain: ['为什么', '依据', '规则', '怎么']
}
let messageSeed = 0
function nowTime() {
@@ -439,116 +438,6 @@ function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
return FLOW_STEP_FALLBACKS.extraction.completedText
}
function summarizeSemanticIntentDetail(semanticParse) {
if (!semanticParse || typeof semanticParse !== 'object') {
return FLOW_STEP_FALLBACKS.intent.completedText
}
const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}`
}
function extractLocalFlowCandidates(rawText) {
const text = String(rawText || '').trim()
const compact = text.replace(/\s+/g, '')
let time = ''
const explicitTimeMatch = text.match(/发生时间[:]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (explicitTimeMatch?.[1]) {
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else {
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (dateMatch?.[1]) {
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else if (/今天|今日/.test(compact)) {
time = '今天'
} else if (/昨天|昨日/.test(compact)) {
time = '昨天'
} else if (/前天/.test(compact)) {
time = '前天'
}
}
let amount = ''
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
if (amountMatch?.[1]) {
const numericValue = Number(amountMatch[1])
if (Number.isFinite(numericValue)) {
amount = Number.isInteger(numericValue) ? `${numericValue}` : `${numericValue.toFixed(2)}`
}
}
let event = ''
let expenseType = ''
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
event = '请客户吃饭'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (/打车|网约车|出租车|车费|停车/.test(compact)) {
event = '交通出行'
expenseType = '交通费'
} else if (/住宿|酒店|宾馆/.test(compact)) {
event = '住宿报销'
expenseType = '住宿费'
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
event = '餐饮用餐'
expenseType = '餐费'
}
return {
time,
amount,
event,
expenseType
}
}
function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) {
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
const text = String(rawText || '').trim()
const compact = text.replace(/\s+/g, '')
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
keywords.some((keyword) => compact.includes(keyword))
)?.[0] || 'draft'
const intentLabel = INTENT_LABELS[intentKey] || '处理'
return `初步识别为报销场景,准备进入${intentLabel}`
}
function buildLocalExtractionProgressMessages(rawText, options = {}) {
const candidates = extractLocalFlowCandidates(rawText)
const messages = []
messages.push('正在提取发生时间...')
messages.push(
candidates.time
? `发现发生时间 ${candidates.time},继续提取金额...`
: '暂未定位到明确时间,继续提取金额...'
)
messages.push(
candidates.amount
? `发现金额 ${candidates.amount},继续识别事件类型...`
: '暂未定位到明确金额,继续识别事件类型...'
)
if (candidates.event || candidates.expenseType) {
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
} else {
messages.push('正在识别事件类型和费用分类...')
}
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
return messages
}
function formatFlowDuration(ms) {
const numericValue = Number(ms)
if (!Number.isFinite(numericValue) || numericValue < 0) {
@@ -2039,7 +1928,7 @@ function matchPresetSceneFromReason(reason) {
if (/酒店|住宿/.test(compactReason)) {
return '住宿报销'
}
if (/交通|打车|车费|停车|网约车|出租车|地铁|公交/.test(compactReason)) {
if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) {
return '交通出行'
}
if (/会务|会议|参会|论坛|展会/.test(compactReason)) {
@@ -3162,6 +3051,7 @@ export default {
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS)
const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK)
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
@@ -3175,7 +3065,7 @@ export default {
: '报销识别核对'
))
const reviewDocumentDrawerLabel = computed(() => (
isReviewDocumentDrawer.value ? '显示核对' : '显示票据'
'单据识别'
))
const reviewDocumentDrawerIcon = computed(() => (
isReviewDocumentDrawer.value
@@ -3183,7 +3073,7 @@ export default {
: 'mdi mdi-file-document-multiple-outline'
))
const reviewRiskDrawerLabel = computed(() => (
isReviewRiskDrawer.value ? '显示核对' : '显示风险'
'显示风险'
))
const reviewRiskDrawerIcon = computed(() => (
isReviewRiskDrawer.value
@@ -3191,7 +3081,7 @@ export default {
: 'mdi mdi-shield-alert-outline'
))
const reviewFlowDrawerLabel = computed(() => (
isReviewFlowDrawer.value ? '显示核对' : '显示流程'
'调用流程'
))
const reviewFlowDrawerIcon = computed(() => (
isReviewFlowDrawer.value
@@ -3714,7 +3604,7 @@ export default {
function startSemanticFlowPreview(rawText, options = {}) {
clearFlowSimulationTimers()
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value)
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
const extractionMessages = buildLocalExtractionProgressMessages(rawText, options)
const completeIntentTimer = window.setTimeout(() => {
@@ -3867,7 +3757,12 @@ export default {
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
completePendingFlowStep(
'intent',
summarizeSemanticIntentDetail(run.semantic_parse),
summarizeSemanticIntentDetail(run.semantic_parse, {
scenarioLabels: SCENARIO_LABELS,
intentLabels: INTENT_LABELS,
expenseTypeLabels: EXPENSE_TYPE_LABELS,
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
}),
intentStep?.startedAt ? null : semanticDurations.intentMs
)
completePendingFlowStep(
@@ -4393,34 +4288,36 @@ export default {
insightPanelCollapsed.value = !insightPanelCollapsed.value
}
function switchReviewDrawerMode(mode) {
if (reviewDrawerMode.value === mode) {
return
}
reviewDrawerMode.value = mode
}
function switchToReviewOverviewDrawer() {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
}
function toggleReviewDocumentDrawer() {
if (!reviewDocumentDrawerAvailable.value) {
return
}
reviewDrawerMode.value =
reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_DOCUMENTS
switchReviewDrawerMode(REVIEW_DRAWER_MODE_DOCUMENTS)
}
function toggleReviewRiskDrawer() {
if (!reviewRiskDrawerAvailable.value) {
return
}
reviewDrawerMode.value =
reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_RISK
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
}
function toggleReviewFlowDrawer() {
if (!reviewFlowDrawerAvailable.value) {
return
}
reviewDrawerMode.value =
reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_FLOW
switchReviewDrawerMode(REVIEW_DRAWER_MODE_FLOW)
}
function setInlineReviewFieldError(key, message) {
@@ -5335,6 +5232,7 @@ export default {
activeReviewPayload,
activeReviewFilePreviews,
reviewDrawerMode,
isReviewOverviewDrawer,
isReviewDocumentDrawer,
isReviewRiskDrawer,
isReviewFlowDrawer,
@@ -5433,6 +5331,7 @@ export default {
resolveFlowStepStatusLabel,
resolveFlowStepDetail,
toggleInsightPanel,
switchToReviewOverviewDrawer,
toggleReviewDocumentDrawer,
toggleReviewRiskDrawer,
toggleReviewFlowDrawer,

View File

@@ -3,7 +3,9 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import ReturnReasonDialog from '../../components/shared/ReturnReasonDialog.vue'
import {
approveExpenseClaim,
createExpenseClaimItem,
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
@@ -15,8 +17,13 @@ import {
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards
} from './travelRequestDetailInsights.js'
const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
@@ -30,21 +37,6 @@ const EXPENSE_TYPE_OPTIONS = [
{ value: 'other', label: '其他费用' }
]
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
hotel_invoice: '酒店住宿票据',
taxi_receipt: '出租车/网约车票据',
parking_toll_receipt: '停车/通行费票据',
meal_receipt: '餐饮票据',
office_invoice: '办公用品票据',
meeting_invoice: '会议/会务票据',
training_invoice: '培训票据',
vat_invoice: '增值税发票',
receipt: '一般收据/凭证',
other: '其他单据'
}
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'meeting',
@@ -72,18 +64,10 @@ function resolveExpenseTypeLabel(value) {
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other
}
function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function resolveLocationInputPlaceholder(value) {
return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)'
}
function resolveLocationSummaryLabel(value) {
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
}
@@ -191,9 +175,28 @@ function normalizeIsoDateValue(value) {
return `${year}-${month}-${day}`
}
function formatExpenseFilledTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return ''
}
const candidate = value instanceof Date ? value : new Date(normalized)
if (Number.isNaN(candidate.getTime())) {
return normalized
}
const year = candidate.getFullYear()
const month = String(candidate.getMonth() + 1).padStart(2, '0')
const day = String(candidate.getDate()).padStart(2, '0')
const hours = String(candidate.getHours()).padStart(2, '0')
const minutes = String(candidate.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function resolveExpenseUploadHint(value) {
const normalized = String(value || '').trim()
return normalized || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿'
return normalized || '支持上传 1 张 JPG、PNG、PDF 单据'
}
function extractAttachmentDisplayName(value) {
@@ -216,6 +219,12 @@ function buildExpenseItemViewModel(source, index, requestModel) {
const attachments = invoiceId ? [attachmentName || invoiceId] : []
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const riskText = String(source?.riskText || '').trim()
const filledAt = formatExpenseFilledTime(
source?.filledAt
|| source?.filled_at
|| source?.createdAt
|| source?.created_at
)
return {
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
@@ -226,6 +235,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
itemAmount,
invoiceId,
time: itemDate || '待补充',
filledAt: filledAt || '待同步',
dayLabel: requestModel?.detailVariant === 'travel' ? `${index + 1}` : '业务发生项',
name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType),
@@ -234,7 +244,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
amount: amountDisplay,
status: attachments.length ? '已识别' : '待补充',
tone: attachments.length ? 'ok' : 'bad',
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
attachmentTone: attachments.length ? 'ok' : 'missing',
attachments,
@@ -372,12 +382,21 @@ function mapIssueToAdvice(issue) {
export default {
name: 'TravelRequestDetailView',
components: {
ConfirmDialog
ConfirmDialog,
ReturnReasonDialog
},
props: {
request: {
type: Object,
default: () => ({})
},
backLabel: {
type: String,
default: '返回报销列表'
},
approvalMode: {
type: Boolean,
default: false
}
},
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
@@ -392,10 +411,14 @@ export default {
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const submitConfirmDialogOpen = ref(false)
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
const returnDialogOpen = ref(false)
const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false)
const leaderOpinion = ref('')
const expenseUploadInput = ref(null)
const expenseAttachmentMeta = reactive({})
const attachmentPreviewOpen = ref(false)
@@ -404,6 +427,7 @@ export default {
const attachmentPreviewUrl = ref('')
const attachmentPreviewName = ref('')
const attachmentPreviewMediaType = ref('')
const attachmentPreviewItemId = ref('')
const expenseEditor = reactive({
itemDate: '',
itemType: 'other',
@@ -455,13 +479,28 @@ export default {
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
})
const showLeaderApprovalPanel = computed(() =>
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& isDirectManagerApprovalStage.value
&& Boolean(request.value.claimId)
)
const canReturnRequest = computed(() =>
canManageCurrentClaim.value
canReturnExpenseClaims(currentUser.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
showLeaderApprovalPanel.value
&& canReturnExpenseClaims(currentUser.value)
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
@@ -474,6 +513,7 @@ export default {
|| submitBusy.value
|| deleteBusy.value
|| returnBusy.value
|| approveBusy.value
|| creatingExpense.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
@@ -583,12 +623,8 @@ export default {
})
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length))
const expenseTableColumnCount = computed(
() => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isEditableRequest.value ? 1 : 0)
)
const expenseSummaryText = computed(
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
() => 6 + (isEditableRequest.value ? 1 : 0)
)
const detailNote = computed(
() =>
@@ -599,7 +635,49 @@ export default {
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
const attachmentPreviewEntries = computed(() =>
expenseItems.value
.filter((item) => item.invoiceId)
.map((item, index) => ({
item,
itemId: item.id,
index,
name: resolveAttachmentDisplayName(item) || `${index + 1} 条附件`,
metadata: resolveAttachmentMeta(item)
}))
)
const currentAttachmentPreviewIndex = computed(() =>
attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value)
)
const currentAttachmentPreviewEntry = computed(() => {
const index = currentAttachmentPreviewIndex.value
return index >= 0 ? attachmentPreviewEntries.value[index] : null
})
const attachmentPreviewIndexLabel = computed(() => {
const currentIndex = currentAttachmentPreviewIndex.value
const total = attachmentPreviewEntries.value.length
return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : ''
})
const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1)
const currentAttachmentPreviewInsight = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return null
}
return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item)
})
const currentAttachmentPreviewRiskCards = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return []
}
return buildAttachmentRiskCards({
expenseItems: [entry.item],
attachmentMetaByItemId: expenseAttachmentMeta
})
})
function applyLocalExpenseItemPatch(itemId, patch) {
expenseItems.value = rebuildExpenseItems(
@@ -617,36 +695,13 @@ export default {
return String(metadata?.file_name || item.attachmentHint || '').trim()
}
function resolveAttachmentPreviewTitle(item) {
const fileName = resolveAttachmentDisplayName(item)
return fileName ? `预览附件:${fileName}` : '预览附件'
}
function resolveAttachmentRecognition(item) {
const metadata = resolveAttachmentMeta(item)
const documentInfo = metadata?.document_info
const requirementCheck = metadata?.requirement_check
if (!documentInfo && !requirementCheck) {
return null
}
const fields = Array.isArray(documentInfo?.fields)
? documentInfo.fields
.map((field) => ({
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.label && field.value)
: []
return {
documentTypeLabel:
String(documentInfo?.document_type_label || '').trim()
|| resolveDocumentTypeLabel(documentInfo?.document_type),
requirementLabel: requirementCheck
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
: '待校验附件类型',
requirementTone: requirementCheck
? (requirementCheck.matches ? 'pass' : 'high')
: 'medium',
message: String(requirementCheck?.message || '').trim(),
fields: fields.slice(0, 4).map((field) => `${field.label}${field.value}`)
}
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
}
function buildAttachmentRiskNotice(attachment) {
@@ -676,7 +731,7 @@ export default {
function canPreviewAttachment(item) {
const metadata = resolveAttachmentMeta(item)
return Boolean(item.invoiceId && metadata?.previewable)
return Boolean(item.invoiceId && metadata?.previewable !== false)
}
function revokeAttachmentPreviewUrl() {
@@ -692,6 +747,7 @@ export default {
attachmentPreviewError.value = ''
attachmentPreviewName.value = ''
attachmentPreviewMediaType.value = ''
attachmentPreviewItemId.value = ''
revokeAttachmentPreviewUrl()
}
@@ -769,42 +825,16 @@ export default {
const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const riskItems = expenseItems.value
.map((item, index) => {
const state = resolveExpenseRiskState(item)
if (!state || !['medium', 'high'].includes(state.tone)) {
return ''
}
const adviceText = String(state.suggestion || state.summary || '').trim()
const prefix = state.tone === 'high' ? '优先整改' : '继续核对'
return `${index + 1} 条附件需${prefix}${adviceText || '请根据系统提示补充或更换附件。'}`
const riskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
})
.filter(Boolean)
if (!completionItems.length && !riskItems.length) {
return {
tone: 'ready',
badge: '可直接提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items: [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
]
}
}
const hasHighRisk = expenseItems.value.some((item) => resolveExpenseRiskState(item)?.tone === 'high')
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待补信息',
summary: completionItems.length
? '建议先补齐必填信息,再处理附件核验项,完成后即可提交审批。'
: '草稿信息已基本齐全,建议先处理附件风险后再提交审批。',
items: [...completionItems, ...riskItems]
}
return buildAiAdviceViewModel({
completionItems,
riskCards
})
})
function startExpenseEdit(item) {
@@ -836,12 +866,6 @@ export default {
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (
isLocationRequiredExpenseType(expenseEditor.itemType)
&& isPlaceholderValue(expenseEditor.itemLocation)
) {
return '请输入业务地点。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
@@ -890,7 +914,12 @@ export default {
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法上传附件。')
toast('当前草稿缺少 claimId暂时无法上传单据。')
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
}
@@ -901,22 +930,29 @@ export default {
}
}
async function openAttachmentPreview(item) {
if (!request.value.claimId || !canPreviewAttachment(item)) {
async function loadAttachmentPreview(item) {
if (!request.value.claimId || !item?.invoiceId) {
return
}
closeAttachmentPreview()
attachmentPreviewOpen.value = true
attachmentPreviewLoading.value = true
attachmentPreviewError.value = ''
attachmentPreviewItemId.value = item.id
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
let metadata = resolveAttachmentMeta(item)
try {
if (!metadata) {
metadata = await refreshExpenseAttachmentMeta(item.id)
}
if (metadata?.previewable === false) {
throw new Error('当前附件暂不支持直接预览。')
}
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
const metadata = resolveAttachmentMeta(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
try {
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
revokeAttachmentPreviewUrl()
attachmentPreviewUrl.value = URL.createObjectURL(blob)
@@ -928,11 +964,48 @@ export default {
}
}
async function openAttachmentPreview(item) {
if (!request.value.claimId || !canPreviewAttachment(item)) {
return
}
closeAttachmentPreview()
attachmentPreviewOpen.value = true
await loadAttachmentPreview(item)
}
async function goToAttachmentPreview(offset) {
if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) {
return
}
const entries = attachmentPreviewEntries.value
const currentIndex = currentAttachmentPreviewIndex.value
const nextIndex = (currentIndex + offset + entries.length) % entries.length
const nextEntry = entries[nextIndex]
if (nextEntry?.item) {
await loadAttachmentPreview(nextEntry.item)
}
}
function goToPreviousAttachmentPreview() {
void goToAttachmentPreview(-1)
}
function goToNextAttachmentPreview() {
void goToAttachmentPreview(1)
}
async function uploadExpenseFile(item, file) {
if (!item || !file) {
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
}
uploadingExpenseId.value = item.id
try {
@@ -986,7 +1059,9 @@ export default {
async function handleExpenseFileChange(event) {
const target = event?.target
const file = target?.files?.[0]
const fileList = target?.files
const fileCount = fileList?.length || 0
const file = fileList?.[0]
const itemId = pendingUploadExpenseId.value
pendingUploadExpenseId.value = ''
@@ -994,6 +1069,11 @@ export default {
target.value = ''
}
if (fileCount > 1) {
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
return
}
if (!file || !itemId) {
return
}
@@ -1059,11 +1139,12 @@ export default {
savingExpenseId.value = item.id
try {
const nextInvoiceId = expenseEditor.invoiceId.trim()
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
await updateExpenseClaimItem(request.value.claimId, item.id, {
item_date: expenseEditor.itemDate,
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: expenseEditor.itemLocation.trim(),
item_location: preservedLocation,
item_amount: Number(expenseEditor.itemAmount),
invoice_id: nextInvoiceId
})
@@ -1071,7 +1152,7 @@ export default {
itemDate: expenseEditor.itemDate,
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: expenseEditor.itemLocation.trim(),
itemLocation: preservedLocation,
itemAmount: Number(expenseEditor.itemAmount),
invoiceId: nextInvoiceId
})
@@ -1096,7 +1177,7 @@ export default {
}
}
async function handleSubmit() {
function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
@@ -1107,6 +1188,30 @@ export default {
return
}
submitConfirmDialogOpen.value = true
}
function closeSubmitConfirmDialog() {
if (submitBusy.value) {
return
}
submitConfirmDialogOpen.value = false
}
async function confirmSubmitRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
submitConfirmDialogOpen.value = false
return
}
if (!canSubmit.value) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
submitConfirmDialogOpen.value = false
return
}
submitBusy.value = true
try {
const payload = await submitExpenseClaim(request.value.claimId)
@@ -1119,6 +1224,7 @@ export default {
} else {
toast(`${request.value.id} 提交结果已更新。`)
}
submitConfirmDialogOpen.value = false
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '提交审批失败,请稍后重试。')
@@ -1190,7 +1296,7 @@ export default {
returnDialogOpen.value = false
}
async function confirmReturnRequest() {
async function confirmReturnRequest(payload) {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
@@ -1198,9 +1304,7 @@ export default {
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, {
reason: '详情页退回,请申请人调整后重新提交。'
})
await returnExpenseClaim(request.value.claimId, payload)
returnDialogOpen.value = false
toast(`${request.value.id} 已退回待提交。`)
emit('request-updated', { claimId: request.value.claimId })
@@ -1211,7 +1315,62 @@ export default {
}
}
function handleApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
return
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
return
}
approveConfirmDialogOpen.value = true
}
function closeApproveConfirmDialog() {
if (approveBusy.value) {
return
}
approveConfirmDialogOpen.value = false
}
async function confirmApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
approveConfirmDialogOpen.value = false
return
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
approveConfirmDialogOpen.value = false
return
}
approveBusy.value = true
try {
await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim()
})
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
} finally {
approveBusy.value = false
}
}
function openAiEntry() {
if (!canOpenAiEntry.value) {
return
}
emit('openAssistant', {
source: 'detail',
prompt: '',
@@ -1229,21 +1388,33 @@ export default {
actionBusy,
aiAdvice,
attachmentPreviewError,
attachmentPreviewIndexLabel,
attachmentPreviewLoading,
attachmentPreviewMediaType,
attachmentPreviewName,
attachmentPreviewOpen,
attachmentPreviewUrl,
approveBusy,
approveConfirmDialogOpen,
canDeleteRequest,
canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry,
canApproveRequest,
canReturnRequest,
canSubmit,
canPreviewAttachment,
closeApproveConfirmDialog,
closeDeleteDialog,
closeAttachmentPreview,
closeSubmitConfirmDialog,
closeReturnDialog,
confirmApproveRequest,
confirmDeleteRequest,
confirmSubmitRequest,
confirmReturnRequest,
currentAttachmentPreviewInsight,
currentAttachmentPreviewRiskCards,
currentProgressRingMotion,
deleteActionLabel,
deleteBusy,
@@ -1258,39 +1429,43 @@ export default {
creatingExpense,
expenseEditor,
expenseItems,
expenseSummaryText,
expenseTableColumnCount,
expenseTotal,
expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleAddExpenseItem,
handleApproveRequest,
handleDeleteRequest,
handleExpenseFileChange,
handleReturnRequest,
handleSubmit,
hasExpenseRiskColumn,
heroFactItems,
isDraftRequest,
isEditableRequest,
isTravelRequest,
locationInputPlaceholder,
openAiEntry,
openAttachmentPreview,
goToNextAttachmentPreview,
goToPreviousAttachmentPreview,
profile,
progressSteps,
removeExpenseItem,
request,
leaderOpinion,
removeExpenseAttachment,
removeExpenseItem,
resolveAttachmentDisplayName,
resolveAttachmentPreviewTitle,
resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,
returnDialogOpen,
savingExpenseId,
showLeaderApprovalPanel,
showExpenseRisk,
startExpenseEdit,
submitBusy,
submitConfirmDialogOpen,
triggerExpenseUpload,
uploadedExpenseCount,
uploadingExpenseId,

View File

@@ -0,0 +1,290 @@
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
hotel_invoice: '酒店住宿票据',
taxi_receipt: '出租车/网约车票据',
parking_toll_receipt: '停车/通行费票据',
meal_receipt: '餐饮票据',
office_invoice: '办公用品票据',
meeting_invoice: '会议/会务票据',
training_invoice: '培训票据',
vat_invoice: '增值税发票',
receipt: '一般收据/凭证',
other: '其他单据'
}
function normalizeText(value) {
return String(value || '').trim()
}
function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (tone === 'pass') return 'pass'
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
if (tone === 'low') return 'low'
return 'medium'
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
}
function normalizeRuleBasis(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeText(item)).filter(Boolean)
}
const text = normalizeText(value)
return text ? [text] : []
}
export function buildAttachmentInsightViewModel(metadata, item = {}) {
if (!metadata) {
return null
}
const documentInfo = metadata.document_info || {}
const requirementCheck = metadata.requirement_check || null
const analysis = metadata.analysis || null
const documentTypeLabel =
normalizeText(documentInfo.document_type_label) || resolveDocumentTypeLabel(documentInfo.document_type)
const fields = Array.isArray(documentInfo.fields)
? documentInfo.fields
.map((field) => ({
label: normalizeText(field?.label),
value: normalizeText(field?.value)
}))
.filter((field) => field.label && field.value)
.map((field) => `${field.label}${field.value}`)
: []
const ruleBasis = uniqueTexts([
...normalizeRuleBasis(analysis?.rule_basis || analysis?.ruleBasis),
...normalizeRuleBasis(requirementCheck?.rule_basis || requirementCheck?.ruleBasis),
normalizeText(requirementCheck?.message),
documentTypeLabel ? `票据识别依据:系统将附件识别为${documentTypeLabel}` : '',
normalizeText(item?.name) ? `费用项目依据:当前明细为${normalizeText(item.name)}` : ''
])
return {
fileName: normalizeText(metadata.file_name || item.attachmentHint || item.invoiceId),
mediaType: normalizeText(metadata.media_type),
previewable: metadata.previewable !== false,
documentTypeLabel,
requirementLabel: requirementCheck
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
: '待校验附件类型',
requirementTone: requirementCheck
? (requirementCheck.matches ? 'pass' : 'high')
: 'medium',
message: normalizeText(requirementCheck?.message),
fields: fields.slice(0, 8),
ruleBasis,
analysis: analysis
? {
label: normalizeText(analysis.label) || 'AI提示',
tone: normalizeTone(analysis.severity),
headline: normalizeText(analysis.headline) || normalizeText(analysis.label) || 'AI提示',
summary: normalizeText(analysis.summary),
points: Array.isArray(analysis.points) ? analysis.points.map((point) => normalizeText(point)).filter(Boolean) : [],
suggestion: normalizeText(analysis.suggestion)
}
: null
}
}
function buildCardSuggestion(analysis, insight) {
return (
normalizeText(analysis?.suggestion)
|| normalizeText(insight?.message)
|| '请根据规则依据核对附件和费用明细,必要时补充说明、更换附件或调整费用项目。'
)
}
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
const tone = normalizeTone(analysis?.severity)
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险')
return {
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
tone,
label,
title: `${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight)
}
}
function parseReturnCount(flag) {
const count = Number(flag?.return_count ?? flag?.returnCount ?? 0)
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0
}
function resolveLatestManualReturnFlag(flags) {
const manualReturnFlags = flags.filter(
(flag) => flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return'
)
if (!manualReturnFlags.length) {
return null
}
return manualReturnFlags.reduce((latest, flag) => {
const latestCount = parseReturnCount(latest)
const nextCount = parseReturnCount(flag)
if (nextCount !== latestCount) {
return nextCount > latestCount ? flag : latest
}
const latestTime = Date.parse(normalizeText(latest?.created_at || latest?.createdAt))
const nextTime = Date.parse(normalizeText(flag?.created_at || flag?.createdAt))
if (Number.isFinite(nextTime) && (!Number.isFinite(latestTime) || nextTime >= latestTime)) {
return flag
}
return latest
}, manualReturnFlags[0])
}
function buildManualReturnRiskCard(flag) {
if (!flag) {
return null
}
const returnCount = parseReturnCount(flag)
const stageReturnCount = Number(flag.stage_return_count ?? flag.stageReturnCount ?? 0)
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
const riskPoints = Array.isArray(flag.risk_points || flag.riskPoints)
? (flag.risk_points || flag.riskPoints).map((item) => normalizeText(item)).filter(Boolean)
: []
const risk = normalizeText(flag.message || flag.reason || flag.summary) || '审批人退回该单据,请补充后重新提交。'
const ruleBasis = uniqueTexts([
returnCount ? `累计退回 ${returnCount} 次。` : '',
returnStage ? `本次退回环节:${returnStage}` : '',
stageReturnCount > 0 ? `该环节累计退回 ${Math.floor(stageReturnCount)} 次。` : '',
...riskPoints.map((item) => `退回风险点:${item}`)
])
return {
id: `manual-return-${returnCount || 'latest'}`,
tone: 'medium',
label: '退回原因',
title: returnCount ? `${returnCount} 次退回` : '审批退回',
risk,
summary: normalizeText(flag.reason),
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
}
}
export function buildAttachmentRiskCards({
expenseItems = [],
attachmentMetaByItemId = {},
claimRiskFlags = []
} = {}) {
const attachmentCards = expenseItems.flatMap((item, index) => {
if (!item?.invoiceId) {
return []
}
const metadata = attachmentMetaByItemId[item.id]
const insight = buildAttachmentInsightViewModel(metadata, item)
const analysis = metadata?.analysis
const tone = normalizeTone(analysis?.severity)
if (!analysis || !['medium', 'high'].includes(tone)) {
return []
}
const points = Array.isArray(analysis.points) && analysis.points.length
? analysis.points
: [analysis.summary || analysis.headline || analysis.label]
return points
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
.filter((card) => card.risk)
})
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
const claimCards = normalizedClaimRiskFlags
.map((flag, index) => {
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
return null
}
if (!flag || typeof flag !== 'object') {
const risk = normalizeText(flag)
return risk
? {
id: `claim-risk-${index}`,
tone: 'medium',
label: '单据风险',
title: '单据风险提示',
risk,
summary: '',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
}
: null
}
const tone = normalizeTone(flag.severity)
if (!['medium', 'high'].includes(tone)) {
return null
}
return {
id: `claim-risk-${index}`,
tone,
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
title: normalizeText(flag.label) || '单据风险提示',
risk: normalizeText(flag.message || flag.reason || flag.summary),
summary: normalizeText(flag.summary),
ruleBasis: normalizeRuleBasis(flag.rule_basis || flag.ruleBasis).length
? normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
: ['系统预审规则命中该风险提示。'],
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
}
})
.filter(Boolean)
if (latestManualReturnCard) {
claimCards.unshift(latestManualReturnCard)
}
return [...attachmentCards, ...claimCards]
}
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
return {
tone: 'ready',
badge: '可直接提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items: [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
],
riskCards: []
}
}
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对',
summary: normalizedRiskCards.length
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
: '建议先补齐必填信息,完成后即可提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards
}
}

View File

@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { canManageExpenseClaims, canReturnExpenseClaims } from '../src/utils/accessControl.js'
test('direct approvers can return claims without receiving delete permissions', () => {
const managerUser = { roleCodes: ['manager'] }
const approverUser = { roleCodes: ['approver'] }
assert.equal(canReturnExpenseClaims(managerUser), true)
assert.equal(canReturnExpenseClaims(approverUser), true)
assert.equal(canManageExpenseClaims(managerUser), false)
assert.equal(canManageExpenseClaims(approverUser), false)
})
test('finance and executives can return and manage claims', () => {
assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true)
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), true)
assert.equal(canReturnExpenseClaims({ roleCodes: ['executive'] }), true)
assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true)
})

View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const approvalTemplate = readFileSync(
fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
'utf8'
)
const approvalScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
'utf8'
)
const detailTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
test('approval center reuses reimbursement detail view instead of its old detail shell', () => {
assert.match(approvalTemplate, /<TravelRequestDetailView/)
assert.match(approvalTemplate, /back-label="返回审批列表"/)
assert.match(approvalTemplate, /approval-mode/)
assert.doesNotMatch(approvalTemplate, /class="approval-detail"/)
assert.doesNotMatch(approvalTemplate, /<ReturnReasonDialog/)
assert.doesNotMatch(approvalTemplate, /<ConfirmDialog/)
assert.match(approvalScript, /import TravelRequestDetailView from '\.\.\/TravelRequestDetailView\.vue'/)
assert.match(approvalScript, /fetchApprovalExpenseClaims/)
assert.doesNotMatch(approvalScript, /fetchExpenseClaims/)
assert.doesNotMatch(approvalScript, /import ConfirmDialog/)
assert.doesNotMatch(approvalScript, /import ReturnReasonDialog/)
assert.match(detailScript, /backLabel:/)
assert.match(detailTemplate, /\{\{ backLabel \}\}/)
})

View File

@@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const employeeViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/EmployeeManagementView.js', import.meta.url)),
'utf8'
)
const employeeViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/EmployeeManagementView.vue', import.meta.url)),
'utf8'
)
const employeeViewStyles = readFileSync(
fileURLToPath(
new URL('../src/assets/styles/views/employee-management-view.css', import.meta.url)
),
'utf8'
)
function extractFormatEmployeeHistoryTime() {
const padMatched = employeeViewScript.match(
/function padDatePart\(value\) \{[\s\S]*?\n\}\n\n(?:export\s+)?function formatEmployeeHistoryTime/
)
assert.ok(padMatched, 'padDatePart should be present before history time formatter')
const matched = employeeViewScript.match(
/(?:export\s+)?function formatEmployeeHistoryTime\(value\) \{[\s\S]*?\n\}\n\nfunction resolveOrganizationUnitCode/
)
assert.ok(matched, 'formatEmployeeHistoryTime should be present before organization helpers')
const padSource = padMatched[0].replace(
/\n\n(?:export\s+)?function formatEmployeeHistoryTime[\s\S]*$/u,
''
)
const source = matched[0].replace(/\n\nfunction resolveOrganizationUnitCode[\s\S]*$/u, '')
return new Function(
'normalizeText',
`${padSource}; ${source}; return formatEmployeeHistoryTime;`
)((value) => String(value || '').trim())
}
test('employee history time uses fixed-width date and minute format', () => {
const formatEmployeeHistoryTime = extractFormatEmployeeHistoryTime()
assert.equal(formatEmployeeHistoryTime('2026年5月6日10时4分'), '2026-05-06 10:04')
assert.equal(formatEmployeeHistoryTime('2026-05-06T10:04:33+08:00'), '2026-05-06 10:04')
assert.equal(formatEmployeeHistoryTime('2026-05-06 10:04'), '2026-05-06 10:04')
})
test('employee history row keeps owner and time in aligned grid columns', () => {
assert.match(employeeViewTemplate, /class="history-row-owner"/)
assert.match(employeeViewTemplate, /class="history-row-time"/)
assert.doesNotMatch(employeeViewTemplate, /class="history-row-meta"/)
assert.match(employeeViewStyles, /\.history-row\s*\{[^}]*display:\s*grid/s)
assert.match(
employeeViewStyles,
/\.history-row\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s*128px\s*112px/s
)
assert.match(employeeViewStyles, /\.history-row-time\s*\{[^}]*font-variant-numeric:\s*tabular-nums/s)
})

View File

@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const workbench = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
'utf8'
)
test('workbench assistant greets the current employee without the old helper tag', () => {
assert.doesNotMatch(workbench, /assistant-tag/)
assert.doesNotMatch(workbench, /AI 报销助手/)
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\}描述费用或上传票据AI 直接帮你判断怎么报/)
assert.match(workbench, /const assistantGreetingName = computed/)
assert.match(workbench, /user\.name/)
})

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildLocalExtractionProgressMessages,
buildLocalIntentPreview,
inferLocalFlowCandidates,
summarizeSemanticIntentDetail
} from '../src/utils/reimbursementTextInference.js'
const ridingFareMessage = '业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用'
test('local flow intent preview names transport expense for riding fare text', () => {
const candidates = inferLocalFlowCandidates(ridingFareMessage)
assert.equal(candidates.time, '2026-03-04')
assert.equal(candidates.event, '交通出行')
assert.equal(candidates.expenseType, '交通费')
assert.match(buildLocalIntentPreview(ridingFareMessage), /交通费/)
assert.ok(
buildLocalExtractionProgressMessages(ridingFareMessage).some(
(item) => item.includes('交通出行') && item.includes('交通费')
)
)
})
test('semantic intent detail includes recognized expense type', () => {
assert.equal(
summarizeSemanticIntentDetail({
scenario: 'expense',
intent: 'draft',
entities_json: [
{
type: 'expense_type',
value: '交通',
normalized_value: 'transport'
}
]
}),
'已识别为报销场景,当前目标是草稿生成,费用类型为交通费'
)
})

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
test('progress steps show approval operator time and current stay duration', () => {
const originalNow = Date.now
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
try {
const request = mapExpenseClaimToRequest({
id: 'claim-1',
claim_no: 'EXP-202605-001',
employee_name: '张三',
department_name: '市场部',
expense_type: 'transport',
reason: '交通报销',
location: '上海',
amount: 88,
invoice_count: 1,
occurred_at: '2026-05-20T01:00:00.000Z',
submitted_at: '2026-05-20T02:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T03:30:00.000Z',
status: 'submitted',
approval_stage: '财务审批',
risk_flags_json: [
{
source: 'manual_approval',
operator: '李经理',
previous_approval_stage: '直属领导审批',
next_approval_stage: '财务审批',
created_at: '2026-05-20T03:30:00.000Z'
}
],
items: []
})
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
assert.equal(leaderStep.time, '李经理通过')
assert.match(leaderStep.detail, /2026-05-20/)
assert.match(leaderStep.title, /李经理审批通过/)
assert.equal(aiStep.time, 'AI预审通过')
assert.match(aiStep.detail, /2026-05-20/)
assert.equal(financeStep.current, true)
assert.equal(financeStep.time, '停留 1小时30分钟')
} finally {
Date.now = originalNow
}
})
test('current direct manager step shows how long the claim has stayed there', () => {
const originalNow = Date.now
Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime()
try {
const request = mapExpenseClaimToRequest({
id: 'claim-2',
claim_no: 'EXP-202605-002',
employee_name: '王五',
department_name: '市场部',
expense_type: 'office',
reason: '办公用品',
location: '上海',
amount: 128,
invoice_count: 1,
occurred_at: '2026-05-20T01:00:00.000Z',
submitted_at: '2026-05-20T02:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T02:00:00.000Z',
status: 'submitted',
approval_stage: '直属领导审批',
risk_flags_json: [],
items: []
})
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
const submitStep = request.progressSteps.find((step) => step.label === '待提交')
assert.equal(submitStep.time, '王五提交')
assert.match(submitStep.detail, /2026-05-20/)
assert.equal(leaderStep.current, true)
assert.equal(leaderStep.time, '停留 3小时15分钟')
} finally {
Date.now = originalNow
}
})

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeRequestForUi } from '../src/utils/requestViewModel.js'
test('normalizes backend approval_stage for in-progress claim details', () => {
const request = normalizeRequestForUi({
id: 'EXP-202605-001',
claim_id: 'claim-1',
status: 'submitted',
approval_stage: '直属领导审批',
expense_type: 'transport',
amount: 88
})
assert.equal(request.approvalKey, 'in_progress')
assert.equal(request.node, '直属领导审批')
})
test('normalizes returned backend claims as editable pending submission', () => {
const request = normalizeRequestForUi({
id: 'EXP-202605-002',
claim_id: 'claim-2',
status: 'returned',
approval_stage: '待提交',
expense_type: 'transport',
amount: 66
})
assert.equal(request.approvalKey, 'supplement')
assert.equal(request.approvalStatus, '待提交')
assert.equal(request.node, '待提交')
})

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewDocumentDrawerAvailable"[\s\S]*title="单据识别"/)
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewRiskDrawerAvailable"[\s\S]*title="显示风险"/)
assert.match(createViewTemplate, /title="调用流程"/)
assert.ok(
createViewTemplate.indexOf('title="报销识别核对"') < createViewTemplate.indexOf('title="单据识别"'),
'default review button should be placed before the document recognition button'
)
})
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
assert.match(createViewScript, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
assert.match(createViewScript, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
assert.match(createViewScript, /function switchToReviewOverviewDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_REVIEW\)/)
assert.match(createViewScript, /function toggleReviewDocumentDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_DOCUMENTS\)/)
assert.match(createViewScript, /function toggleReviewRiskDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
assert.match(createViewScript, /function toggleReviewFlowDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_FLOW\)/)
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_DOCUMENTS\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
})

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const detailTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
)
function extractFunction(source, name) {
const signatureIndex = source.indexOf(`function ${name}(`)
assert.notEqual(signatureIndex, -1, `${name} should exist`)
const bodyStart = source.indexOf('{', signatureIndex)
assert.notEqual(bodyStart, -1, `${name} should have a body`)
let depth = 0
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index]
if (char === '{') {
depth += 1
} else if (char === '}') {
depth -= 1
if (depth === 0) {
return source.slice(signatureIndex, index + 1)
}
}
}
assert.fail(`${name} body should be closed`)
}
test('approval-mode detail collects leader opinion and confirms approval before API call', () => {
assert.match(detailScript, /approvalMode:/)
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const canApproveRequest = computed/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
assert.match(detailTemplate, /领导意见/)
assert.match(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /@click="handleApproveRequest"/)
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
assert.match(detailTemplate, /confirm-text="确认通过"/)
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
assert.match(confirmApproveRequest, /approveExpenseClaim/)
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
assert.match(reimbursementService, /\/approve/)
})

View File

@@ -0,0 +1,186 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards
} from '../src/views/scripts/travelRequestDetailInsights.js'
const detailViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const requestsComposableScript = readFileSync(
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
'utf8'
)
const approvalCenterTemplate = readFileSync(
fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
'utf8'
)
const approvalCenterScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
'utf8'
)
const returnReasonDialog = readFileSync(
fileURLToPath(new URL('../src/components/shared/ReturnReasonDialog.vue', import.meta.url)),
'utf8'
)
const attachmentMeta = {
file_name: 'taxi-invoice.pdf',
media_type: 'application/pdf',
previewable: true,
document_info: {
document_type: 'taxi_receipt',
document_type_label: '出租车/网约车票据',
fields: [
{ label: '金额', value: '121.54' },
{ label: '日期', value: '2026-03-04' }
]
},
requirement_check: {
matches: false,
message: '附件类型与当前费用项目不匹配。'
},
analysis: {
severity: 'high',
label: '高风险',
headline: '票据类型不匹配',
summary: '交通票据挂在办公费明细下。',
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公费'],
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
}
}
test('attachment insight exposes recognition fields and rule basis', () => {
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
name: '办公费',
itemType: 'office'
})
assert.equal(insight.documentTypeLabel, '出租车/网约车票据')
assert.equal(insight.requirementLabel, '不符合当前费用类型')
assert.deepEqual(insight.fields, ['金额121.54', '日期2026-03-04'])
assert.ok(insight.ruleBasis.some((item) => item.includes('附件类型与当前费用项目不匹配')))
})
test('AI advice card splits every attachment risk point with basis and suggestion', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'item-1',
name: '办公费',
invoiceId: 'taxi-invoice.pdf'
}
],
attachmentMetaByItemId: {
'item-1': attachmentMeta
}
})
const advice = buildAiAdviceViewModel({
completionItems: [],
riskCards
})
assert.equal(riskCards.length, 2)
assert.equal(advice.badge, '优先整改')
assert.equal(advice.riskCards.length, 2)
assert.ok(advice.riskCards.every((card) => card.ruleBasis.length > 0))
assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
})
test('AI advice shows only the latest manual return while preserving return count context', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'manual_return',
severity: 'medium',
label: '人工退回',
message: '第一次退回:缺少附件。',
reason: '缺少附件。',
return_count: 1,
return_stage: '直属领导审批',
risk_points: ['附件缺失或不清晰']
},
{
source: 'manual_return',
severity: 'medium',
label: '人工退回',
message: '第二次退回:超标说明不完整。',
reason: '超标说明不完整。',
return_count: 2,
return_stage: '财务审批',
risk_points: ['超出制度标准或缺少超标说明']
}
]
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].risk, '第二次退回:超标说明不完整。')
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('累计退回 2 次')))
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('财务审批')))
})
test('expense attachment actions keep preview as the only recognition entry point', () => {
assert.match(detailViewTemplate, /:aria-label="resolveAttachmentPreviewTitle\(item\)"/)
assert.match(detailViewScript, /return fileName \? `预览附件:\$\{fileName\}` : '预览附件'/)
assert.doesNotMatch(detailViewTemplate, /aria-label="识别附件"/)
assert.doesNotMatch(detailViewTemplate, /点击识别按钮/)
assert.doesNotMatch(detailViewScript, /recognizeExpenseAttachment/)
assert.doesNotMatch(detailViewScript, /recognizingExpenseId/)
})
test('expense detail table omits compact-breaking summary labels', () => {
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
assert.match(detailViewTemplate, /当前还没有费用明细/)
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
assert.doesNotMatch(detailViewTemplate, /expense-total-bar/)
assert.doesNotMatch(detailViewTemplate, /合计 \{\{ expenseTotal \}\}/)
assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/)
assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/)
})
test('expense detail table shows each item filled time from item creation time', () => {
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>/)
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}/)
assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 6 \+ \(isEditableRequest\.value \? 1 : 0\)/)
assert.match(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
})
test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/)
assert.equal(
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId"/g) || []).length,
2
)
assert.match(detailViewScript, /const attachments = invoiceId \? \[attachmentName \|\| invoiceId\] : \[\]/)
assert.match(detailViewScript, /attachmentStatus: attachments\.length \? '已关联票据' : '未上传'/)
assert.match(detailViewScript, /if \(item\?\.invoiceId\) \{[\s\S]*每条费用明细只能关联一张单据/)
assert.match(detailViewScript, /const fileCount = fileList\?\.length \|\| 0/)
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
})
test('return reason dialog is wired into approval and detail return actions', () => {
assert.match(returnReasonDialog, /missing_attachment/)
assert.match(returnReasonDialog, /invoice_mismatch/)
assert.match(returnReasonDialog, /reason_codes/)
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
assert.match(detailViewTemplate, /<ReturnReasonDialog/)
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
assert.doesNotMatch(detailViewScript, /详情页退回/)
})

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const detailViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
function extractFunction(source, name) {
const signatureIndex = source.indexOf(`function ${name}(`)
assert.notEqual(signatureIndex, -1, `${name} should exist`)
const bodyStart = source.indexOf('{', signatureIndex)
assert.notEqual(bodyStart, -1, `${name} should have a body`)
let depth = 0
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index]
if (char === '{') {
depth += 1
} else if (char === '}') {
depth -= 1
if (depth === 0) {
return source.slice(signatureIndex, index + 1)
}
}
}
assert.fail(`${name} body should be closed`)
}
test('detail submit opens a confirmation dialog before calling submit API', () => {
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*confirm-text="确认提交"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
assert.match(detailViewTemplate, /cancel-text="返回核对"/)
assert.match(detailViewTemplate, /@click="handleSubmit"/)
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = false/)
assert.match(detailViewScript, /submitConfirmDialogOpen,/)
assert.match(detailViewScript, /closeSubmitConfirmDialog,/)
assert.match(detailViewScript, /confirmSubmitRequest,/)
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.doesNotMatch(handleSubmit, /submitExpenseClaim/)
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
})