feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -104,7 +104,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
||||
scene_code="transport",
|
||||
scene_label="交通票据",
|
||||
expense_type="transport",
|
||||
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
|
||||
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "乘车", "用车", "叫车", "车费", "车资", "的士", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
|
||||
score_bias=0.38,
|
||||
),
|
||||
DocumentRule(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ("出差", "机票", "火车", "高铁", "行程单")):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
web/src/assets/workbench-icons/README.md
Normal 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.
|
||||
3
web/src/assets/workbench-icons/outline-briefcase.svg
Normal 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 |
3
web/src/assets/workbench-icons/outline-document-text.svg
Normal 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 |
@@ -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 |
3
web/src/assets/workbench-icons/outline-shopping-bag.svg
Normal 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 |
3
web/src/assets/workbench-icons/outline-truck.svg
Normal 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 |
3
web/src/assets/workbench-icons/outline-users.svg
Normal 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 |
3
web/src/assets/workbench-icons/solid-shopping-bag.svg
Normal 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 |
5
web/src/assets/workbench-icons/solid-truck.svg
Normal 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 |
@@ -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 {
|
||||
|
||||
@@ -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 || '管理员',
|
||||
|
||||
@@ -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)
|
||||
|
||||
222
web/src/components/shared/ReturnReasonDialog.vue
Normal 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>
|
||||
107
web/src/components/shared/WorkbenchListIcon.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
152
web/src/composables/useApprovalInbox.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
40
web/src/utils/approvalInbox.js
Normal 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)
|
||||
}
|
||||
188
web/src/utils/reimbursementTextInference.js
Normal 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}`
|
||||
}
|
||||
@@ -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' ? '待安排行程' : '待补充票据')
|
||||
|
||||
22
web/src/utils/workbenchIconAssets.js
Normal 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' }
|
||||
}
|
||||
82
web/src/utils/workbenchSummary.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
? ['关键词、时间段和状态会叠加生效', '可尝试搜索单号、事由或报销类型']
|
||||
: ['已完成单据会保留在列表中便于追踪', '草稿、审批中和待补充会按真实状态实时归类']
|
||||
: ['已完成单据会保留在列表中便于追踪', '草稿、待提交、审批中和待补充会按真实状态实时归类']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
290
web/src/views/scripts/travelRequestDetailInsights.js
Normal 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
|
||||
}
|
||||
}
|
||||
21
web/tests/accessControl.test.mjs
Normal 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)
|
||||
})
|
||||
39
web/tests/approval-center-detail-reuse.test.mjs
Normal 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 \}\}/)
|
||||
})
|
||||
62
web/tests/employee-management-history.test.mjs
Normal 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)
|
||||
})
|
||||
17
web/tests/personal-workbench-assistant.test.mjs
Normal 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/)
|
||||
})
|
||||
42
web/tests/reimbursementTextInference.test.mjs
Normal 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'
|
||||
}
|
||||
]
|
||||
}),
|
||||
'已识别为报销场景,当前目标是草稿生成,费用类型为交通费'
|
||||
)
|
||||
})
|
||||
90
web/tests/requestProgressSteps.test.mjs
Normal 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
|
||||
}
|
||||
})
|
||||
33
web/tests/requestViewModel.test.mjs
Normal 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, '待提交')
|
||||
})
|
||||
37
web/tests/travel-reimbursement-review-drawer-switch.test.mjs
Normal 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/)
|
||||
})
|
||||
64
web/tests/travel-request-detail-leader-approval.test.mjs
Normal 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/)
|
||||
})
|
||||
186
web/tests/travel-request-detail-risk-advice.test.mjs
Normal 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, /详情页退回/)
|
||||
})
|
||||
54
web/tests/travel-request-detail-submit-confirm.test.mjs
Normal 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\)/)
|
||||
})
|
||||