7 Commits

Author SHA1 Message Date
caoxiaozhu
b8915a29c0 chore(storage): 归档用户报销票据附件 2026-06-17 14:39:41 +08:00
caoxiaozhu
4199feb681 test: 同步报销审批流与预算分析测试
- 新增预算审批合并、风险标记去重与占位条目校验用例
- 补充预算分析对当前审核人与财务的可见性断言
- 调整单据删除权限测试以匹配 admin 限制
2026-06-17 14:39:26 +08:00
caoxiaozhu
0fac8b615f feat(web): 优化差旅详情、风险建议卡片与文档中心交互
- 拆分阶段风险建议卡片样式到独立文件
- 完善差旅申请审批对话框与详情视图交互
- 调整文档中心列表共享样式与状态筛选
- 同步应用外壳、视图初始化与系统状态 composables
2026-06-17 14:39:12 +08:00
caoxiaozhu
a3e5295915 feat(rules): 更新财务差旅与通信费用规则表 2026-06-17 14:38:51 +08:00
caoxiaozhu
1f4681f486 feat(claim): 重构报销审批流并收敛风险标记
- 直属领导兼任部门 P8 预算审批人时合并预算审批,直接流转至财务审批
- 预算超过警戒值时强制要求预算管理者填写审批意见
- 新增风险标记去重工具,消除各审核阶段重复风险卡片
- 新增工作流修复 Mixin,纠正重复预算审批阶段的历史数据
- 收紧单据删除权限至 admin,放宽预算分析可见范围至当前审核人
- 提交校验放宽已上传票据条目的 OCR 字段缺失并忽略尾部占位条目
2026-06-17 14:38:07 +08:00
caoxiaozhu
09a66c72cb chore: 将 web 端口由 5173 调整为 5273 2026-06-17 14:37:50 +08:00
caoxiaozhu
0d525fa64c chore: 忽略 .codex-temp 工具临时目录 2026-06-17 14:34:36 +08:00
76 changed files with 3573 additions and 637 deletions

6
.env
View File

@@ -14,9 +14,9 @@ VITE_ADMIN_EMAIL='admin@admin.com'
# Admin login credentials are stored separately under server/.secrets/ # Admin login credentials are stored separately under server/.secrets/
WEB_HOST=10.10.10.122 WEB_HOST=10.10.10.122
WEB_PORT=5173 WEB_PORT=5273
VITE_WEB_HOST=10.10.10.122 VITE_WEB_HOST=10.10.10.122
VITE_WEB_PORT=5173 VITE_WEB_PORT=5273
SERVER_HOST=0.0.0.0 SERVER_HOST=0.0.0.0
SERVER_PORT=8000 SERVER_PORT=8000
@@ -48,4 +48,4 @@ SQLALCHEMY_ECHO=false
REDIS_URL= REDIS_URL=
VITE_REDIS_URL= VITE_REDIS_URL=
CORS_ORIGINS='["http://10.10.10.122:5173"]' CORS_ORIGINS='["http://10.10.10.122:5273"]'

View File

@@ -14,9 +14,9 @@ VITE_ADMIN_EMAIL=
# Admin login credentials are stored separately under server/.secrets/ # Admin login credentials are stored separately under server/.secrets/
WEB_HOST=0.0.0.0 WEB_HOST=0.0.0.0
WEB_PORT=5173 WEB_PORT=5273
VITE_WEB_HOST=0.0.0.0 VITE_WEB_HOST=0.0.0.0
VITE_WEB_PORT=5173 VITE_WEB_PORT=5273
SERVER_HOST=0.0.0.0 SERVER_HOST=0.0.0.0
SERVER_PORT=8000 SERVER_PORT=8000
@@ -52,4 +52,4 @@ OCR_DEVICE=
OCR_TIMEOUT_SECONDS=180 OCR_TIMEOUT_SECONDS=180
OCR_MAX_CONCURRENT_WORKERS=1 OCR_MAX_CONCURRENT_WORKERS=1
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]' CORS_ORIGINS='["http://127.0.0.1:5273","http://localhost:5273","http://0.0.0.0:5273"]'

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ web/.vite/
.omx/ .omx/
.claude/ .claude/
.codex/ .codex/
.codex-temp/
.superpowers/ .superpowers/
*.log *.log
.DS_Store .DS_Store

View File

@@ -20,7 +20,7 @@ services:
QDRANT_URL: "http://qdrant:6333" QDRANT_URL: "http://qdrant:6333"
LIGHTRAG_WORKSPACE: "x_financial_knowledge" LIGHTRAG_WORKSPACE: "x_financial_knowledge"
ports: ports:
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}" - "${WEB_PORT:-5273}:${WEB_PORT:-5273}"
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}" - "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
- "2223:22" - "2223:22"
volumes: volumes:
@@ -59,7 +59,7 @@ services:
cd /app && cd /app &&
./start.sh all ./start.sh all
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"] test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5273}/ >/dev/null || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10

View File

@@ -14,7 +14,7 @@ docker compose up -d
Open: Open:
```text ```text
http://<your-linux-host>:5173 http://<your-linux-host>:5273
``` ```
## Container Layout ## Container Layout

View File

@@ -187,13 +187,13 @@ def get_expense_claim_budget_analysis(
current_user: CurrentUser, current_user: CurrentUser,
) -> BudgetClaimAnalysisRead: ) -> BudgetClaimAnalysisRead:
service = ExpenseClaimService(db) service = ExpenseClaimService(db)
if not service.can_view_budget_analysis(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有预算监控员或高级财务人员可以查看预算分析。")
claim = service.get_claim(claim_id, current_user) claim = service.get_claim(claim_id, current_user)
if claim is None: if claim is None:
if not service.can_view_budget_analysis(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
if not service.can_view_budget_analysis(current_user, claim): if not service.can_view_budget_analysis(current_user, claim):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有该部门 P8 预算监控员或高级财务人员可以查看预算分析。") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
return BudgetService(db).analyze_claim_budget(claim) return BudgetService(db).analyze_claim_budget(claim)

View File

@@ -38,7 +38,7 @@ class Settings(BaseSettings):
admin_email: str = Field(default="", alias="ADMIN_EMAIL") admin_email: str = Field(default="", alias="ADMIN_EMAIL")
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST") web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
web_port: int = Field(default=5173, alias="WEB_PORT") web_port: int = Field(default=5273, alias="WEB_PORT")
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST") app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT") app_port: int = Field(default=8000, alias="SERVER_PORT")
server_workers: int = Field(default=1, alias="SERVER_WORKERS") server_workers: int = Field(default=1, alias="SERVER_WORKERS")

View File

@@ -28,7 +28,6 @@ APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
BUDGET_APPROVAL_ROLE_CODES = {"budget_monitor", "executive"} BUDGET_APPROVAL_ROLE_CODES = {"budget_monitor", "executive"}
BUDGET_MONITOR_ROLE_CODE = "budget_monitor" BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
BUDGET_MONITOR_APPROVAL_GRADE = "P8" BUDGET_MONITOR_APPROVAL_GRADE = "P8"
CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = (APPLICATION_ARCHIVE_STAGE,) APPLICATION_ARCHIVED_STAGES = (APPLICATION_ARCHIVE_STAGE,)
ARCHIVED_REIMBURSEMENT_STAGES = ( ARCHIVED_REIMBURSEMENT_STAGES = (
@@ -95,9 +94,7 @@ class ExpenseClaimAccessPolicy:
@staticmethod @staticmethod
def has_claim_delete_access(current_user: CurrentUserContext) -> bool: def has_claim_delete_access(current_user: CurrentUserContext) -> bool:
if current_user.is_admin: return bool(current_user.is_admin)
return True
return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & CLAIM_DELETE_ROLE_CODES)
@staticmethod @staticmethod
def is_archived_claim(claim: ExpenseClaim) -> bool: def is_archived_claim(claim: ExpenseClaim) -> bool:

View File

@@ -2,9 +2,11 @@ from __future__ import annotations
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from typing import Any from typing import Any
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.services.budget import BudgetService
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE, APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
@@ -76,7 +78,16 @@ class ExpenseClaimApprovalFlowMixin:
next_stage = APPLICATION_LINK_STATUS_STAGE next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。" default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
else: else:
if requires_budget_review: merged_budget_approval = (
requires_budget_review
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
)
if merged_budget_approval:
label = "领导及预算审核通过"
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}"
elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if next_budget_manager is None: if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。") raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
@@ -120,6 +131,19 @@ class ExpenseClaimApprovalFlowMixin:
raise ValueError("当前节点不支持审批通过。") raise ValueError("当前节点不支持审批通过。")
approval_opinion = str(opinion or "").strip() approval_opinion = str(opinion or "").strip()
if (
previous_stage == BUDGET_MANAGER_APPROVAL_STAGE
and self._budget_approval_opinion_required(claim)
and not approval_opinion
):
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
if (
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
and merged_budget_approval
and self._budget_approval_opinion_required(claim)
and not approval_opinion
):
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion: if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
approval_opinion = "同意" approval_opinion = "同意"
@@ -327,3 +351,28 @@ class ExpenseClaimApprovalFlowMixin:
if opinion: if opinion:
return opinion return opinion
return "" return ""
def _budget_approval_opinion_required(self, claim) -> bool:
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
context = (
budget_result.get("budget_context")
if isinstance(budget_result.get("budget_context"), dict)
else {}
)
over_budget_amount = self._budget_decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
return True
after_usage_rate = self._budget_decimal(metrics.get("after_usage_rate"))
claim_amount_ratio = self._budget_decimal(metrics.get("claim_amount_ratio"))
warning_threshold = self._budget_decimal(context.get("warning_threshold") or "80.00")
return max(after_usage_rate, claim_amount_ratio) >= warning_threshold
@staticmethod
def _budget_decimal(value: Any) -> Decimal:
try:
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return Decimal("0.00")

View File

@@ -641,6 +641,12 @@ class ExpenseClaimItemSyncMixin:
issues: list[str] = [] issues: list[str] = []
claim_location_required = self._is_location_required_expense_type(claim.expense_type) claim_location_required = self._is_location_required_expense_type(claim.expense_type)
claim_min_attachment_count = self._resolve_claim_required_attachment_count(claim) claim_min_attachment_count = self._resolve_claim_required_attachment_count(claim)
substantive_items = [
item
for item in list(claim.items or [])
if str(item.item_type or "").strip().lower() not in SYSTEM_GENERATED_ITEM_TYPES
and not self._is_submission_placeholder_item(item)
]
if self._is_missing_value(claim.employee_name): if self._is_missing_value(claim.employee_name):
issues.append("申请人未完善") issues.append("申请人未完善")
@@ -658,28 +664,39 @@ class ExpenseClaimItemSyncMixin:
issues.append("发生时间未完善") issues.append("发生时间未完善")
if int(claim.invoice_count or 0) < claim_min_attachment_count: if int(claim.invoice_count or 0) < claim_min_attachment_count:
issues.append("票据附件数量不足") issues.append("票据附件数量不足")
if not claim.items: if not substantive_items:
issues.append("费用明细不能为空") issues.append("费用明细不能为空")
for index, item in enumerate(claim.items, start=1): for index, item in enumerate(claim.items, start=1):
prefix = f"费用明细第 {index}" prefix = f"费用明细第 {index}"
is_system_generated = str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES is_system_generated = str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES
if is_system_generated or self._is_submission_placeholder_item(item):
continue
item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type) item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type)
if item.item_date is None: item_has_attachment = not self._is_missing_value(item.invoice_id)
if not item_has_attachment and item.item_date is None:
issues.append(f"{prefix}缺少日期") issues.append(f"{prefix}缺少日期")
if self._is_missing_value(item.item_type): if self._is_missing_value(item.item_type):
issues.append(f"{prefix}缺少费用项目") issues.append(f"{prefix}缺少费用项目")
if self._is_missing_value(item.item_reason): if not item_has_attachment and self._is_missing_value(item.item_reason):
issues.append(f"{prefix}缺少说明") issues.append(f"{prefix}缺少说明")
if item_location_required and self._is_missing_value(item.item_location): if not item_has_attachment and item_location_required and self._is_missing_value(item.item_location):
issues.append(f"{prefix}缺少地点") issues.append(f"{prefix}缺少地点")
if item.item_amount is None or item.item_amount <= Decimal("0.00"): if not item_has_attachment and (item.item_amount is None or item.item_amount <= Decimal("0.00")):
issues.append(f"{prefix}缺少金额") issues.append(f"{prefix}缺少金额")
if self._is_attachment_required_item_type(item.item_type) and self._is_missing_value(item.invoice_id): if self._is_attachment_required_item_type(item.item_type) and not item_has_attachment:
issues.append(f"{prefix}缺少票据标识") issues.append(f"{prefix}缺少票据标识")
return issues return issues
def _is_submission_placeholder_item(self, item: ExpenseClaimItem) -> bool:
if not self._is_missing_value(item.invoice_id):
return False
missing_reason = self._is_missing_value(item.item_reason)
missing_location = self._is_missing_value(item.item_location)
missing_amount = item.item_amount is None or item.item_amount <= Decimal("0.00")
return missing_reason and missing_location and missing_amount
def _is_location_required_expense_type(self, expense_type: str | None) -> bool: def _is_location_required_expense_type(self, expense_type: str | None) -> bool:
policy = self._get_expense_scene_policy(expense_type) policy = self._get_expense_scene_policy(expense_type)
if policy is None: if policy is None:

View File

@@ -30,6 +30,7 @@ class ExpenseClaimPaginationMixin:
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user) stmt = self._access_policy.apply_claim_scope(stmt, current_user)
result = paginate_select(self.db, stmt, page=page, page_size=page_size) result = paginate_select(self.db, stmt, page=page, page_size=page_size)
self._repair_duplicate_budget_approval_stages(result.items)
self._access_policy.attach_budget_approval_snapshots(result.items) self._access_policy.attach_budget_approval_snapshots(result.items)
return result return result
@@ -46,6 +47,7 @@ class ExpenseClaimPaginationMixin:
) )
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
result = paginate_select(self.db, stmt, page=page, page_size=page_size) result = paginate_select(self.db, stmt, page=page, page_size=page_size)
self._repair_duplicate_budget_approval_stages(result.items)
self._access_policy.attach_budget_approval_snapshots(result.items) self._access_policy.attach_budget_approval_snapshots(result.items)
return result return result

View File

@@ -23,6 +23,7 @@ from app.services.expense_rule_runtime import (
RuntimeTravelPolicy, RuntimeTravelPolicy,
) )
from app.services.expense_type_keywords import resolve_expense_type_code_from_text from app.services.expense_type_keywords import resolve_expense_type_code_from_text
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_platform_route_risk import resolve_multi_city_related_item_ids from app.services.expense_claim_platform_route_risk import resolve_multi_city_related_item_ids
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
from app.services.expense_claim_platform_text_risk import ( from app.services.expense_claim_platform_text_risk import (
@@ -79,6 +80,13 @@ class ExpenseClaimPlatformRiskMixin:
continue continue
flags.append(flag) flags.append(flag)
flags = [
flag
for flag in dedupe_claim_risk_flags(flags)
if isinstance(flag, dict)
]
for flag in flags:
severity = str(flag.get("severity") or "").strip().lower() severity = str(flag.get("severity") or "").strip().lower()
action = str(flag.get("action") or "").strip().lower() action = str(flag.get("action") or "").strip().lower()
if severity in {"high", "critical"} or action == "block": if severity in {"high", "critical"} or action == "block":

View File

@@ -6,6 +6,7 @@ from typing import Any
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
@@ -48,7 +49,9 @@ class ExpenseClaimPreReviewMixin:
claim, claim,
business_stage="expense_application", business_stage="expense_application",
) )
review_flags = [*preserved_flags, *list(application_review.get("flags") or [])] review_flags = dedupe_claim_risk_flags(
[*preserved_flags, *list(application_review.get("flags") or [])]
)
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags) blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
passed = blocking_count <= 0 passed = blocking_count <= 0
else: else:
@@ -168,7 +171,9 @@ class ExpenseClaimPreReviewMixin:
claim, claim,
business_stage="expense_application", business_stage="expense_application",
) )
review_flags = [*preserved_flags, *list(application_review.get("flags") or [])] review_flags = dedupe_claim_risk_flags(
[*preserved_flags, *list(application_review.get("flags") or [])]
)
else: else:
review_result = self._run_ai_submission_review(claim) review_result = self._run_ai_submission_review(claim)
review_flags = list(review_result.get("risk_flags") or []) review_flags = list(review_result.get("risk_flags") or [])

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from typing import Any
_SEVERITY_WEIGHT = {
"critical": 0,
"high": 1,
"medium": 2,
"low": 3,
"info": 4,
"pass": 5,
}
def _text(value: Any) -> str:
return str(value or "").strip()
def _severity_weight(flag: dict[str, Any]) -> int:
severity = _text(flag.get("severity") or flag.get("tone") or flag.get("level")).lower()
return _SEVERITY_WEIGHT.get(severity, 9)
def _list_values(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if _text(value):
return [value]
return []
def _item_ids(flag: dict[str, Any]) -> list[str]:
values: list[Any] = [
flag.get("item_id"),
flag.get("itemId"),
*_list_values(flag.get("item_ids")),
*_list_values(flag.get("itemIds")),
]
item_ids: list[str] = []
seen: set[str] = set()
for value in values:
item_id = _text(value)
if not item_id or item_id in seen:
continue
seen.add(item_id)
item_ids.append(item_id)
return item_ids
def _card_text(flag: dict[str, Any]) -> str:
return " ".join(
_text(flag.get(key))
for key in (
"label",
"title",
"name",
"message",
"summary",
"reason",
"description",
"rule_code",
"ruleCode",
)
)
def _duplicate_group(flag: dict[str, Any]) -> str:
text = _card_text(flag)
if any(
term in text
for term in (
"多城市行程",
"中转",
"多地拜访",
"改签",
"多地出差",
"后续行程",
"行程终点异常",
"连续闭环",
)
) and any(
term in text for term in ("待说明", "未说明", "缺少说明", "原因", "说明", "不一致", "异常")
):
return "route-explanation"
if any(term in text for term in ("票据城市", "申报目的地", "行程城市", "酒店票据地点")) and any(
term in text for term in ("不一致", "不匹配", "额外中转", "绕行")
):
return "travel-city-consistency"
if any(term in text for term in ("住宿", "酒店", "宾馆")) and any(
term in text for term in ("超标", "超出", "报销标准", "住宿标准", "差标")
):
return "hotel-over-standard"
rule_code = _text(flag.get("rule_code") or flag.get("ruleCode"))
if rule_code:
return f"rule:{rule_code}"
return ""
def _same_stage(left: dict[str, Any], right: dict[str, Any]) -> bool:
left_stage = _text(left.get("business_stage") or left.get("businessStage"))
right_stage = _text(right.get("business_stage") or right.get("businessStage"))
return not left_stage or not right_stage or left_stage == right_stage
def _same_issue(left: dict[str, Any], right: dict[str, Any]) -> bool:
if not _same_stage(left, right):
return False
left_group = _duplicate_group(left)
if not left_group or left_group != _duplicate_group(right):
return False
left_items = _item_ids(left)
right_items = _item_ids(right)
if not left_items or not right_items:
return True
return any(item_id in right_items for item_id in left_items)
def dedupe_claim_risk_flags(flags: list[Any] | None) -> list[Any]:
"""Remove lower-severity duplicate business risk flags at the data source."""
normalized_flags = list(flags or [])
deduped: list[Any] = []
for index, flag in enumerate(normalized_flags):
if not isinstance(flag, dict):
deduped.append(flag)
continue
current_weight = _severity_weight(flag)
is_shadowed = False
for other_index, other in enumerate(normalized_flags):
if other_index == index or not isinstance(other, dict):
continue
if not _same_issue(flag, other):
continue
other_weight = _severity_weight(other)
if other_weight < current_weight or (other_weight == current_weight and other_index < index):
is_shadowed = True
break
if not is_shadowed:
deduped.append(flag)
return deduped

View File

@@ -15,6 +15,7 @@ from app.services.expense_claim_constants import (
from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.risk_observations import RiskObservationService from app.services.risk_observations import RiskObservationService
@@ -103,11 +104,12 @@ class ExpenseClaimRiskReviewMixin(
) )
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags] review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
final_risk_flags = dedupe_claim_risk_flags([*preserved_flags, *review_flags])
return { return {
"status": "submitted", "status": "submitted",
"approval_stage": "直属领导审批", "approval_stage": "直属领导审批",
"risk_flags": preserved_flags + review_flags, "risk_flags": final_risk_flags,
"message": ( "message": (
f"报销单 {claim.claim_no} 已完成自动检测," f"报销单 {claim.claim_no} 已完成自动检测,"
f"现已提交给直属领导 {manager_name or '审批人'} 审批。" f"现已提交给直属领导 {manager_name or '审批人'} 审批。"

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
class ExpenseClaimWorkflowRepairMixin:
def _repair_duplicate_budget_approval_stages(self, claims: list[ExpenseClaim]) -> None:
repaired_claims = [
claim
for claim in claims
if claim is not None and self._repair_duplicate_budget_approval_stage(claim)
]
if not repaired_claims:
return
self.db.commit()
for claim in repaired_claims:
self.db.refresh(claim)
def _repair_duplicate_budget_approval_stage(self, claim: ExpenseClaim) -> bool:
if self._is_expense_application_claim(claim):
return False
if str(claim.status or "").strip().lower() != "submitted":
return False
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
return False
if self._has_duplicate_budget_stage_repair_flag(claim):
return False
approval_event = self._find_duplicate_budget_handoff_event(claim)
if approval_event is None:
return False
claim.approval_stage = FINANCE_APPROVAL_STAGE
claim.risk_flags_json = [
*list(claim.risk_flags_json or []),
self._build_duplicate_budget_stage_repair_flag(approval_event),
]
return True
def _find_duplicate_budget_handoff_event(self, claim: ExpenseClaim) -> dict[str, Any] | None:
flags = [
flag
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "manual_approval"
and str(flag.get("event_type") or "").strip() == "expense_claim_approval"
and str(flag.get("previous_approval_stage") or "").strip() == DIRECT_MANAGER_APPROVAL_STAGE
and str(flag.get("next_approval_stage") or "").strip() == BUDGET_MANAGER_APPROVAL_STAGE
]
for flag in reversed(flags):
operator = self._normalize_repair_identity(flag.get("operator"))
next_approver_name = self._normalize_repair_identity(flag.get("next_approver_name"))
if operator and next_approver_name and operator == next_approver_name:
return flag
budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if budget_manager is None:
continue
if operator and operator == self._normalize_repair_identity(budget_manager.name):
return flag
return None
def _has_duplicate_budget_stage_repair_flag(self, claim: ExpenseClaim) -> bool:
return any(
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "approval_flow_repair"
and str(flag.get("event_type") or "").strip() == "duplicate_budget_approval_stage_repaired"
for flag in list(claim.risk_flags_json or [])
)
def _build_duplicate_budget_stage_repair_flag(self, approval_event: dict[str, Any]) -> dict[str, Any]:
return with_risk_business_stage(
{
"source": "approval_flow_repair",
"event_type": "duplicate_budget_approval_stage_repaired",
"severity": "info",
"label": "重复预算审批已跳过",
"message": "系统识别直属领导与预算管理者为同一人,已跳过重复预算审批并流转至财务审批。",
"previous_approval_stage": BUDGET_MANAGER_APPROVAL_STAGE,
"next_approval_stage": FINANCE_APPROVAL_STAGE,
"related_approval_event_id": approval_event.get("approval_event_id"),
"budget_approval_merged": True,
"budget_approval_merged_reason": "direct_manager_is_department_budget_approver",
"created_at": datetime.now(UTC).isoformat(),
},
risk_business_stage_for_claim(is_application_claim=False),
)
@staticmethod
def _normalize_repair_identity(value: Any) -> str:
return str(value or "").strip().lower()

View File

@@ -49,6 +49,7 @@ from app.services.expense_claim_attachment_document import ExpenseClaimAttachmen
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
@@ -58,6 +59,7 @@ from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.receipt_folder import ReceiptFolderService from app.services.receipt_folder import ReceiptFolderService
@@ -156,6 +158,7 @@ class ExpenseClaimService(
ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimAttachmentAnalysisMixin,
ExpenseClaimReadModelMixin, ExpenseClaimReadModelMixin,
ExpenseClaimRiskReviewMixin, ExpenseClaimRiskReviewMixin,
ExpenseClaimWorkflowRepairMixin,
): ):
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
@@ -210,7 +213,9 @@ class ExpenseClaimService(
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user) stmt = self._access_policy.apply_claim_scope(stmt, current_user)
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all())) claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims)
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = ( stmt = (
@@ -224,7 +229,9 @@ class ExpenseClaimService(
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) .order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
) )
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all())) claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims)
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = ( stmt = (
@@ -252,7 +259,10 @@ class ExpenseClaimService(
.where(ExpenseClaim.id == claim_id) .where(ExpenseClaim.id == claim_id)
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True) stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self._access_policy.attach_approval_snapshot(self.db.scalar(stmt)) claim = self.db.scalar(stmt)
if claim is not None:
self._repair_duplicate_budget_approval_stages([claim])
return self._access_policy.attach_approval_snapshot(claim)
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool: def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
if claim is None: if claim is None:
@@ -262,6 +272,13 @@ class ExpenseClaimService(
role_codes = self._access_policy.normalize_role_codes(current_user) role_codes = self._access_policy.normalize_role_codes(current_user)
if "executive" in role_codes: if "executive" in role_codes:
return True return True
if (
self._access_policy.has_privileged_claim_access(current_user)
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
):
return True
if self._access_policy.can_approve_claim(current_user, claim):
return True
if self._access_policy.is_claim_owned_by_current_user(claim, current_user): if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
return False return False
return self._access_policy.is_department_p8_budget_monitor(current_user, claim) return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
@@ -545,7 +562,7 @@ class ExpenseClaimService(
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
) )
] ]
claim.risk_flags_json = [*preserved_flags, *adjustment_flags] claim.risk_flags_json = dedupe_claim_risk_flags([*preserved_flags, *adjustment_flags])
self._sync_claim_from_items(claim) self._sync_claim_from_items(claim)
self.db.commit() self.db.commit()
@@ -805,6 +822,7 @@ class ExpenseClaimService(
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
claim.submitted_at = datetime.now(UTC) claim.submitted_at = datetime.now(UTC)
claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json)
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -829,9 +847,7 @@ class ExpenseClaimService(
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None: def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user) claim = self.get_claim(claim_id, current_user)
if claim is None and ( if claim is None and current_user.is_admin:
current_user.is_admin or self._access_policy.has_archive_center_access(current_user)
):
candidate_claim = self.db.scalar( candidate_claim = self.db.scalar(
select(ExpenseClaim) select(ExpenseClaim)
.options( .options(
@@ -841,13 +857,14 @@ class ExpenseClaimService(
) )
.where(ExpenseClaim.id == claim_id) .where(ExpenseClaim.id == claim_id)
) )
if candidate_claim is not None and ( if candidate_claim is not None:
current_user.is_admin or self._access_policy.is_archived_claim(candidate_claim)
):
claim = candidate_claim claim = candidate_claim
if claim is None: if claim is None:
return None return None
if not self._access_policy.has_claim_delete_access(current_user):
raise ValueError("只有 admin 管理员可以删除单据。")
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin: if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。") raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")

View File

@@ -0,0 +1,122 @@
{
"id": "0c2b040a-7b4d-49e3-b889-e39aeda53eec",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"file_sha256": "203b92047a43cb41fe2fc0dffcc27da8f1eaebd651494b63badd74c24171a150",
"uploaded_at": "2026-06-15T12:22:13.537867+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "8d37d92c-0dfe-46ae-82b4-126446b3d39b",
"linked_at": "2026-06-15T12:22:13.537867+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620034111042818,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,106 @@
{
"id": "1dedc126-9719-40cf-8c02-7b644d62019e",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月21日_上海-深圳.png",
"source_file_name": "2月21日_上海-深圳.png",
"media_type": "image/png",
"size_bytes": 1500480,
"file_sha256": "74dc6598a21fcd581a4e7370612b3dc75133f6e128aa219220606df3d293caa7",
"uploaded_at": "2026-06-15T12:32:42.695935+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "999f2e09-a4a2-48b9-8b5a-a970c8f6f2e7",
"linked_at": "2026-06-15T12:32:42.695935+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "行程单示意\n出票渠道示例平台\n非官方车票\n不可报销\n仅供演示\n创建日期2026年02月15日\nQ\n订单号DEMO202602210001\n单据编号DEMO-IT-000001\n上海虹桥\nG999\n深圳北\n站\n站\nShanghaihongqiao\nShenzhenbei\n2026年02月21日\n08:30出发\n全程约7小时30分\n15:00到达\nDEMO\n乘客示例旅客\n车厢05车\n席别二等座\n-\n扫码无效\n证件号310101199001010000\n座位08A\n票价¥438.00\n仅为演示\n乘车提示请提前到达车站预留充足时间办理安检及检票。\n温馨提示此行程单仅供演示使用不具备法律效力。\n本行程单为演示用途信息为虚构示例非真实有效凭证\n★★★仅供演示使用★★★",
"summary": "行程单示意;出票渠道:示例平台;非官方车票",
"ocr_avg_score": 0.958684408927665,
"ocr_line_count": 34,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.74,
"ocr_classification_evidence": [
"检票",
"二等座",
"票价"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "438元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-21 08:30"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "DEMO202602210001"
},
{
"key": "route",
"label": "行程",
"value": "上海-深圳"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "二等座"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "扫码无效"
},
{
"key": "train_no",
"label": "车次",
"value": "G999"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "座位"
},
{
"key": "id_number",
"label": "身份证号",
"value": "310101199001010000"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "05车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08A"
},
{
"key": "fare",
"label": "票价",
"value": "438.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,122 @@
{
"id": "1e92ef32-e1a8-4724-a60e-e0b3b93588c3",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"file_sha256": "203b92047a43cb41fe2fc0dffcc27da8f1eaebd651494b63badd74c24171a150",
"uploaded_at": "2026-06-15T12:20:49.368603+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "31514799-5fd6-411b-b53a-2febc24cd24e",
"linked_at": "2026-06-15T12:20:49.368603+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620034111042818,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -0,0 +1,56 @@
{
"id": "27cbaab2-e421-4794-a09a-a60c4ca329b3",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "酒店3.jpg",
"source_file_name": "酒店3.jpg",
"media_type": "image/jpeg",
"size_bytes": 153582,
"file_sha256": "903e41fbf4c156288748330365b7411ca43d89cee17f5737bc4e76c007041a6b",
"uploaded_at": "2026-06-15T12:23:31.694634+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "4e9534f4-2e81-4ff3-82dc-8c60f53d5637",
"linked_at": "2026-06-15T12:23:31.694634+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号SH-SAMPLE-20260223-001\n开单期2026年223\n宾客姓名曹笑\n住期2026年220\n离店期2026年223\n住晚数3晚\n房型豪华床房\n房号1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写壹仟零捌拾陆元整\n合计¥1086\n备注\n1.如有疑问请致电前台021-28958888。\n2.退房时间为中午1200超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址上海市浦东新区银城中路88号 邮编200120\n样例票据|仅供系统测试|无效凭证",
"summary": "上海喜来登酒店样例住宿费用单单据编号SH-SAMPLE-20260223-001",
"ocr_avg_score": 0.9887906948725382,
"ocr_line_count": 30,
"page_count": 1,
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.71,
"ocr_classification_evidence": [
"住宿",
"离店",
"酒店"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "1086元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-20"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.jpg",
"preview_media_type": "image/jpeg"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -0,0 +1,122 @@
{
"id": "3146453b-8374-406c-939b-3d50d6aa1fc3",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"file_sha256": "203b92047a43cb41fe2fc0dffcc27da8f1eaebd651494b63badd74c24171a150",
"uploaded_at": "2026-06-15T12:06:10.650184+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "8269f611-2e96-4b15-b89e-853bb7c36def",
"linked_at": "2026-06-15T12:06:10.650184+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620034111042818,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,96 @@
{
"id": "62202861-bd00-4538-9278-e89ca5c62abb",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月22日_深圳-上海.png",
"source_file_name": "2月22日_深圳-上海.png",
"media_type": "image/png",
"size_bytes": 1527491,
"file_sha256": "0a161c49b961c6c38edc53b6f284ceec396665aee852c9512b81780ad072d3cb",
"uploaded_at": "2026-06-15T12:33:19.636062+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "5ebb4320-17a3-431c-a7cb-0c21a4963610",
"linked_at": "2026-06-15T12:33:19.636062+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "行程单示意\n示例编号DEMO20260222A001\n仅供演示使用·非官方凭证一\n打印日期2026年02月15日\n深圳北站\n上海虹桥站\nG998\nShenzhenbei\nShanghaihongqiao\n2026年02月22日\n09:15开\n02车08B号\n二等座\n非官方车票\n乘客示例旅客\n不可报销\n票价¥388.00\n温馨提示\n仅供演示\n• 请提前到达车站,预留充足时间\n身份证44030019*******15\n具体车次、时间以实际为准\n·本行程单不作为乘车凭证\n·仅用于界面设计与功能演示\n示意码DEMO1234\n此码仅为演示占位无实际价值\n订单编号DEMO20260222A001\n购买渠道示例演示平台\n统一客户代码DEMO0000000000\n祝您旅途愉快一路平安\n本行程单仅为示意非官方票据不可用于乘车或报销。",
"summary": "行程单示意示例编号DEMO20260222A001仅供演示使用·非官方凭证一",
"ocr_avg_score": 0.9907940914553981,
"ocr_line_count": 31,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.74,
"ocr_classification_evidence": [
"车次",
"二等座",
"票价"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "388元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-22 09:15"
},
{
"key": "route",
"label": "行程",
"value": "深圳-上海"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "深圳北"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G998"
},
{
"key": "id_number",
"label": "身份证号",
"value": "44030019******"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "02车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "388.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,122 @@
{
"id": "a69238a5-65bf-45b7-9a9e-3aa4f8e662b8",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"file_sha256": "618de348b6d8c822af23b6f603167847ef4fe73a149eafbc97ebf29b0932d58d",
"uploaded_at": "2026-06-15T12:22:40.316616+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "b60b4316-9a56-4792-85be-22a751a59bf5",
"linked_at": "2026-06-15T12:22:40.316616+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580971281975508,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -0,0 +1,122 @@
{
"id": "b7af7d91-7577-4d68-8a9d-ebcb2f552326",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"file_sha256": "618de348b6d8c822af23b6f603167847ef4fe73a149eafbc97ebf29b0932d58d",
"uploaded_at": "2026-06-15T12:05:36.780929+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "bc11962c-d345-4bc2-acde-3c3b1348327b",
"linked_at": "2026-06-15T12:05:36.780929+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580971281975508,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -313,15 +313,34 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
with session_factory() as db: with session_factory() as db:
seed_budget_allocations(db) seed_budget_allocations(db)
budget_role, market_department = seed_market_budget_monitor(db) budget_role, market_department = seed_market_budget_monitor(db)
manager_role = Role(role_code="manager", name="直属领导")
finance_role = Role(role_code="finance", name="财务")
manager = Employee(
employee_no="E-BUDGET-MANAGER",
name="预算领导",
email="budget-manager-review@example.com",
grade="P7",
organization_unit=market_department,
roles=[manager_role],
)
finance_user = Employee(
employee_no="E-BUDGET-FINANCE",
name="预算财务",
email="budget-finance-review@example.com",
grade="P6",
organization_unit=market_department,
roles=[finance_role],
)
p6_budget_monitor = Employee( p6_budget_monitor = Employee(
employee_no="E-BUDGET-MARKET-P6", employee_no="E-BUDGET-MARKET-P6",
name="低级预算", name="低级预算",
email="p6-budget-monitor@example.com", email="p6-budget-monitor@example.com",
grade="P6", grade="P6",
organization_unit=market_department, organization_unit=market_department,
manager=manager,
roles=[budget_role], roles=[budget_role],
) )
db.add(p6_budget_monitor) db.add_all([manager, finance_user, p6_budget_monitor])
db.flush() db.flush()
claim = ExpenseClaim( claim = ExpenseClaim(
claim_no="APP-BUDGET-ANALYSIS-001", claim_no="APP-BUDGET-ANALYSIS-001",
@@ -342,9 +361,49 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
approval_stage="预算管理者审批", approval_stage="预算管理者审批",
risk_flags_json=[], risk_flags_json=[],
) )
db.add(claim) leader_claim = ExpenseClaim(
claim_no="RE-BUDGET-ANALYSIS-LEADER",
employee_id=p6_budget_monitor.id,
employee_name="低级预算",
department_id="dept-market",
department_name="市场部",
project_code=None,
expense_type="travel",
reason="客户现场交付报销",
location="上海",
amount=Decimal("6000.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=[],
)
finance_claim = ExpenseClaim(
claim_no="RE-BUDGET-ANALYSIS-FINANCE",
employee_id=p6_budget_monitor.id,
employee_name="低级预算",
department_id="dept-market",
department_name="市场部",
project_code=None,
expense_type="travel",
reason="客户现场交付报销",
location="上海",
amount=Decimal("8000.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_all([claim, leader_claim, finance_claim])
db.commit() db.commit()
claim_id = claim.id claim_id = claim.id
leader_claim_id = leader_claim.id
finance_claim_id = finance_claim.id
ordinary_response = client.get( ordinary_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis", f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
@@ -367,8 +426,26 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
"x-auth-role-codes": "budget_monitor", "x-auth-role-codes": "budget_monitor",
}, },
) )
leader_response = client.get(
f"/api/v1/reimbursements/claims/{leader_claim_id}/budget-analysis",
headers={
"x-auth-username": "budget-manager-review@example.com",
"x-auth-role-codes": "manager",
},
)
finance_response = client.get(
f"/api/v1/reimbursements/claims/{finance_claim_id}/budget-analysis",
headers={
"x-auth-username": "budget-finance-review@example.com",
"x-auth-role-codes": "finance",
},
)
assert ordinary_response.status_code == 403 assert ordinary_response.status_code == 403
assert p6_monitor_response.status_code == 403 assert p6_monitor_response.status_code == 403
assert monitor_response.status_code == 200 assert monitor_response.status_code == 200
assert leader_response.status_code == 200
assert finance_response.status_code == 200
assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00") assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00")
assert Decimal(leader_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("12.00")
assert Decimal(finance_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("16.00")

View File

@@ -4,6 +4,7 @@ import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -236,7 +237,7 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() -> None: def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() -> None:
with build_session() as db: with build_session() as db:
department, manager, _budget_manager, employee = _seed_people(db, suffix="OVER-90-APP") department, manager, budget_manager, employee = _seed_people(db, suffix="OVER-90-APP")
_seed_budget_allocation( _seed_budget_allocation(
db, db,
department_id=department.id, department_id=department.id,
@@ -288,6 +289,18 @@ def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() ->
for flag in routed.risk_flags_json for flag in routed.risk_flags_json
) )
with pytest.raises(ValueError, match="预算已超过警戒值"):
ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=budget_manager.email,
name=budget_manager.name,
role_codes=["budget_monitor"],
is_admin=False,
),
opinion=" ",
)
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None: def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
with build_session() as db: with build_session() as db:
@@ -496,3 +509,57 @@ def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
for flag in budget_approved.risk_flags_json for flag in budget_approved.risk_flags_json
) )
def test_budget_manager_blank_opinion_defaults_to_agree_when_budget_under_warning() -> None:
with build_session() as db:
department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL")
_seed_budget_allocation(
db,
department_id=department.id,
department_name=department.name,
amount=Decimal("10000.00"),
)
claim = ExpenseClaim(
claim_no="RE-20260530-BUDGET-NORMAL",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code=None,
expense_type="travel",
reason="客户现场沟通",
location="上海",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=BUDGET_MANAGER_APPROVAL_STAGE,
risk_flags_json=[],
)
db.add(claim)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=budget_manager.email,
name=budget_manager.name,
role_codes=["budget_monitor"],
is_admin=False,
),
opinion=" ",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == FINANCE_APPROVAL_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_approval"
and flag.get("event_type") == "expense_claim_budget_approval"
and flag.get("opinion") == "同意"
for flag in approved.risk_flags_json
)

View File

@@ -0,0 +1,47 @@
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
def test_dedupe_claim_risk_flags_keeps_highest_severity_for_same_route_issue() -> None:
flags = [
{
"severity": "high",
"label": "多城市行程待说明",
"message": "检测到本次差旅涉及深圳多个目的地,但当前报销事由未说明中转原因。",
"item_ids": ["route-1", "route-2"],
"business_stage": "reimbursement",
},
{
"severity": "medium",
"label": "多城市行程缺少说明中风险",
"message": "本次报销识别到多城市行程,但事由中未说明中转、多地拜访或改签原因。",
"item_ids": ["route-2"],
"business_stage": "reimbursement",
},
]
deduped = dedupe_claim_risk_flags(flags)
assert [flag["label"] for flag in deduped] == ["多城市行程待说明"]
def test_dedupe_claim_risk_flags_keeps_distinct_item_risks() -> None:
flags = [
{
"severity": "high",
"label": "住宿金额超出报销标准",
"message": "第一张住宿票超出住宿标准。",
"item_id": "hotel-1",
"business_stage": "reimbursement",
},
{
"severity": "medium",
"label": "住宿金额超出报销标准",
"message": "第二张住宿票超出住宿标准。",
"item_id": "hotel-2",
"business_stage": "reimbursement",
},
]
deduped = dedupe_claim_risk_flags(flags)
assert [flag["item_id"] for flag in deduped] == ["hotel-1", "hotel-2"]

View File

@@ -37,6 +37,7 @@ from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE, APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
) )
from app.services.ontology import SemanticOntologyService from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService from app.services.ocr import OcrService
@@ -267,6 +268,81 @@ def test_validate_claim_for_submission_still_requires_hotel_receipt() -> None:
assert any("缺少票据标识" in item for item in issues) assert any("缺少票据标识" in item for item in issues)
def test_validate_claim_for_submission_does_not_block_uploaded_receipt_ocr_gaps() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="hotel", location="北京")
claim.invoice_count = 1
claim.amount = Decimal("1086.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_date = None
claim.items[0].item_reason = ""
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
issues = service._validate_claim_for_submission(claim)
assert not any("缺少日期" in item for item in issues)
assert not any("缺少说明" in item for item in issues)
assert not any("缺少金额" in item for item in issues)
assert not any("缺少票据标识" in item for item in issues)
def test_validate_claim_for_submission_ignores_trailing_placeholder_item() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="travel", location="上海")
claim.amount = Decimal("1086.00")
claim.invoice_count = 1
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "上海喜来登酒店"
claim.items[0].item_location = "上海"
claim.items[0].item_amount = Decimal("1086.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
claim.items.append(
ExpenseClaimItem(
id="item-2",
claim_id="claim-1",
item_date=date(2026, 2, 23),
item_type="hotel_ticket",
item_reason="",
item_location="",
item_amount=Decimal("0.00"),
invoice_id="",
)
)
issues = service._validate_claim_for_submission(claim)
assert not any(item.startswith("费用明细第 2 条") for item in issues)
def test_validate_claim_for_submission_skips_generated_allowance_item() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="travel", location="上海")
claim.amount = Decimal("1486.00")
claim.invoice_count = 1
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "上海喜来登酒店"
claim.items[0].item_location = "上海"
claim.items[0].item_amount = Decimal("1086.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
claim.items.append(
ExpenseClaimItem(
id="allowance-1",
claim_id="claim-1",
item_date=date(2026, 2, 23),
item_type="travel_allowance",
item_reason="",
item_location="",
item_amount=Decimal("0.00"),
invoice_id="",
)
)
issues = service._validate_claim_for_submission(claim)
assert not any(item.startswith("费用明细第 2 条") for item in issues)
def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None: def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
user_id = "preview-only@example.com" user_id = "preview-only@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报" message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
@@ -2979,10 +3055,10 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None: def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-1", username="admin",
name="张三", name="张三",
role_codes=[], role_codes=["admin"],
is_admin=False, is_admin=True,
) )
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
@@ -3018,6 +3094,27 @@ def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path)
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳南山")
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="只有 admin 管理员可以删除单据"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None: def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-1", username="emp-1",
@@ -3624,6 +3721,29 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
current_user=current_user, current_user=current_user,
) )
def fake_platform_route_review(self, claim, *, rule_codes=None, business_stage=None):
return {
"flags": [
{
"source": "submission_review",
"hit_source": "rule_center",
"rule_code": "risk.travel.medium.multi_city_no_reason",
"severity": "medium",
"label": "多城市行程缺少说明中风险",
"message": "本次报销识别到多城市行程(上海、武汉、成都),但事由中未说明中转、多地拜访或改签原因。",
"item_ids": ["travel-item-2"],
"business_stage": "reimbursement",
}
],
"blocking_reasons": [],
}
monkeypatch.setattr(
ExpenseClaimService,
"evaluate_platform_risk_rules",
fake_platform_route_review,
)
submitted = service.submit_claim(claim.id, current_user) submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None assert submitted is not None
@@ -3648,6 +3768,11 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
assert route_flags assert route_flags
assert all(flag.get("item_ids") for flag in route_flags) assert all(flag.get("item_ids") for flag in route_flags)
assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags) assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags)
assert not any(
isinstance(flag, dict)
and str(flag.get("label") or "").strip() == "多城市行程缺少说明中风险"
for flag in list(submitted.risk_flags_json or [])
)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route( def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
@@ -5049,6 +5174,175 @@ def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None:
assert claim.risk_flags_json == [] assert claim.risk_flags_json == []
def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance() -> None:
current_user = CurrentUserContext(
username="manager-budget-monitor-reimbursement@example.com",
name="李预算经理",
role_codes=["manager", "budget_monitor", "executive"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-REIMBURSEMENT-MERGED",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E-RB-MERGED-MGR",
name="李预算经理",
email="manager-budget-monitor-reimbursement@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-RB-MERGED-EMP",
name="张三",
email="zhangsan-budget-monitor-reimbursement@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="RE-20260525-MERGED",
employee_id=employee.id,
employee_name="张三",
department_id=department.id,
department_name="交付部",
project_code="PRJ-A",
expense_type="travel",
reason="上海出差报销",
location="上海",
amount=Decimal("3020.00"),
currency="CNY",
invoice_count=3,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "报销风险复核",
"message": "多城市行程和住宿超标需要预算管理者二次确认。",
}
],
)
db.add(claim)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
claim.id,
current_user,
opinion="业务必要且预算可承接,同意报销。",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == "财务审批"
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_claim_approval"
and flag.get("label") == "领导及预算审核通过"
and flag.get("opinion") == "业务必要且预算可承接,同意报销。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "submitted"
and flag.get("next_approval_stage") == "财务审批"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() -> None:
admin_user = CurrentUserContext(
username="admin",
name="admin",
role_codes=["admin"],
is_admin=True,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-LEGACY-REPAIR",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E-LEGACY-MGR",
name="李预算经理",
email="manager-legacy-repair@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-LEGACY-EMP",
name="张三",
email="zhangsan-legacy-repair@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="RE-20260525-LEGACY",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel",
reason="上海出差报销",
location="上海",
amount=Decimal("3020.00"),
currency="CNY",
invoice_count=3,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=BUDGET_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "manual_approval",
"event_type": "expense_claim_approval",
"approval_event_id": "legacy-approval-event",
"operator": manager.name,
"operator_username": manager.email,
"previous_approval_stage": DIRECT_MANAGER_APPROVAL_STAGE,
"next_approval_stage": BUDGET_MANAGER_APPROVAL_STAGE,
"next_approver_name": manager.name,
"next_approver_employee_id": manager.id,
}
],
)
db.add(claim)
db.commit()
repaired = ExpenseClaimService(db).get_claim(claim.id, admin_user)
assert repaired is not None
assert repaired.approval_stage == FINANCE_APPROVAL_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "approval_flow_repair"
and flag.get("event_type") == "duplicate_budget_approval_stage_repaired"
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
for flag in repaired.risk_flags_json
)
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None: def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="application-owner@example.com", username="application-owner@example.com",

View File

@@ -507,7 +507,8 @@ td small {
.doc-kind-tag, .doc-kind-tag,
.type-tag, .type-tag,
.status-tag { .status-tag,
.risk-level-tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -612,6 +613,49 @@ td small {
color: #475569; color: #475569;
} }
.risk-level-tags {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-wrap: wrap;
}
.risk-level-tag {
min-height: 24px;
padding: 0 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 850;
}
.risk-level-tag.high {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.risk-level-tag.medium {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.risk-level-tag.low {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.risk-level-tag.none {
border-color: #e2e8f0;
background: #f8fafc;
color: #64748b;
}
.list-foot { .list-foot {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;

View File

@@ -0,0 +1,302 @@
.employee-risk-profile-card {
display: grid;
gap: 10px;
padding: 12px 14px;
}
.employee-risk-head {
margin-bottom: 0;
}
.employee-risk-title-wrap {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.detail-card h3 {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.detail-card-head h3 {
margin-bottom: 0;
}
.detail-card-title-with-icon {
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1.5;
}
.detail-card-title-with-icon i {
margin-top: 1px;
color: #334155;
font-size: 16px;
line-height: 1;
}
.employee-risk-tone-pill {
height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #f8fafc;
color: #475569;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.employee-risk-tone-pill.normal {
border-color: #bbf7d0;
background: #f0fdf4;
color: #047857;
}
.employee-risk-tone-pill.medium {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-tone-pill.high {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-body {
display: grid;
gap: 10px;
}
.employee-risk-decision-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 32%);
align-items: stretch;
gap: 12px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.employee-risk-decision-panel.medium {
border-color: #fed7aa;
background: #fff7ed;
}
.employee-risk-decision-panel.high {
border-color: #fecaca;
background: #fef2f2;
}
.employee-risk-decision-main {
min-width: 0;
display: grid;
gap: 4px;
}
.employee-risk-decision-main > span,
.employee-risk-decision-action span {
color: #64748b;
font-size: 10px;
font-weight: 850;
line-height: 1.5;
}
.employee-risk-decision-main strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
overflow-wrap: anywhere;
}
.employee-risk-decision-panel.medium .employee-risk-decision-main strong {
color: #c2410c;
}
.employee-risk-decision-panel.high .employee-risk-decision-main strong {
color: #b91c1c;
}
.employee-risk-decision-main p {
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.5;
}
.employee-risk-decision-action {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 5px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.employee-risk-decision-action strong {
min-width: 0;
color: #0f172a;
font-size: 12px;
font-weight: 800;
line-height: 1.5;
overflow-wrap: anywhere;
}
.employee-risk-decision-action strong.medium {
color: #c2410c;
}
.employee-risk-decision-action strong.high {
color: #b91c1c;
}
.employee-risk-profile-section {
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.employee-risk-section-head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.employee-risk-section-head span {
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.employee-risk-section-head small {
min-width: 0;
color: #64748b;
font-size: 11px;
line-height: 1.5;
text-align: right;
}
.employee-risk-profile-list {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.employee-risk-evidence-row {
min-width: 0;
display: grid;
gap: 5px;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.employee-risk-evidence-row.medium {
border-color: #fed7aa;
background: #fffbf5;
}
.employee-risk-evidence-row.high {
border-color: #fecaca;
background: #fff7f7;
}
.employee-risk-evidence-title {
min-height: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #0f172a;
font-size: 11px;
font-weight: 850;
}
.employee-risk-evidence-title span {
min-width: 0;
overflow-wrap: anywhere;
}
.employee-risk-evidence-title strong {
height: 20px;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
padding: 0 6px;
border-radius: 4px;
background: #eef2f7;
color: #475569;
font-size: 10px;
font-weight: 900;
white-space: nowrap;
}
.employee-risk-evidence-row.medium .employee-risk-evidence-title strong {
background: #ffedd5;
color: #c2410c;
}
.employee-risk-evidence-row.high .employee-risk-evidence-title strong {
background: #fee2e2;
color: #b91c1c;
}
.employee-risk-evidence-row ul {
display: grid;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
align-content: start;
}
.employee-risk-evidence-row li {
min-width: 0;
color: #475569;
font-size: 11px;
line-height: 1.45;
overflow-wrap: anywhere;
white-space: normal;
}
.employee-risk-muted {
margin: 0;
color: #94a3b8;
font-size: 11px;
}
@media (max-width: 960px) {
.employee-risk-decision-panel {
grid-template-columns: 1fr;
}
.employee-risk-title-wrap,
.employee-risk-section-head {
flex-wrap: wrap;
}
.employee-risk-section-head small {
text-align: left;
}
}

View File

@@ -24,7 +24,7 @@
.col-title { width: 16%; } .col-title { width: 16%; }
.col-amount { width: 9%; } .col-amount { width: 9%; }
.col-node { width: 12%; } .col-node { width: 12%; }
.col-status { width: 8%; } .col-risk { width: 8%; }
.col-updated { width: 9%; } .col-updated { width: 9%; }
.new-document-badge { .new-document-badge {

View File

@@ -2059,36 +2059,45 @@
gap: 12px; gap: 12px;
} }
.risk-override-nav { .risk-override-card-shell {
display: grid; display: grid;
grid-template-columns: 34px minmax(0, 1fr) 34px; grid-template-columns: 28px minmax(0, 1fr) 28px;
align-items: center; align-items: stretch;
gap: 8px; gap: 6px;
} }
.risk-override-nav span { .risk-override-side-nav {
width: 28px;
min-height: 76px;
display: inline-flex;
align-items: center;
justify-content: center;
align-self: stretch;
border: 1px solid #dbe3ee;
border-radius: 4px;
background: #f8fafc;
color: #475569;
font-size: 16px;
}
.risk-override-side-nav:not(:disabled):hover {
border-color: #b8c5d6;
background: #eef4fb;
color: #1e293b;
}
.risk-override-side-nav:disabled {
cursor: not-allowed;
opacity: .48;
}
.risk-override-index {
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
font-weight: 850; font-weight: 850;
} line-height: 1;
.risk-override-nav-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
color: #475569;
}
.risk-override-nav-btn:disabled {
cursor: not-allowed;
opacity: .48;
} }
.risk-override-card { .risk-override-card {
@@ -2135,6 +2144,26 @@
line-height: 1.6; line-height: 1.6;
} }
.risk-override-notes {
display: grid;
gap: 6px;
padding-top: 8px;
border-top: 1px dashed #cbd5e1;
}
.risk-override-notes span {
color: #64748b;
font-size: 11px;
font-weight: 850;
}
.risk-override-notes strong {
color: #0f172a;
font-size: 12px;
font-weight: 780;
line-height: 1.55;
}
.risk-override-guidance { .risk-override-guidance {
display: grid; display: grid;
gap: 4px; gap: 4px;

View File

@@ -31,16 +31,27 @@
class="shared-confirm-btn cancel" class="shared-confirm-btn cancel"
:disabled="busy" :disabled="busy"
@click="handleCancel" @click="handleCancel"
> >
{{ cancelText }} {{ cancelText }}
</button> </button>
<button <button
type="button" v-if="secondaryText"
class="shared-confirm-btn confirm" type="button"
:class="confirmTone" class="shared-confirm-btn secondary"
:disabled="busy" :class="secondaryTone"
@click="$emit('confirm')" :disabled="busy"
> @click="$emit('secondary')"
>
<i v-if="secondaryIcon" :class="secondaryIcon"></i>
<span>{{ secondaryText }}</span>
</button>
<button
type="button"
class="shared-confirm-btn confirm"
:class="confirmTone"
:disabled="busy || confirmDisabled"
@click="$emit('confirm')"
>
<i v-if="confirmIcon" :class="busy ? 'mdi mdi-loading mdi-spin' : confirmIcon"></i> <i v-if="confirmIcon" :class="busy ? 'mdi mdi-loading mdi-spin' : confirmIcon"></i>
<span>{{ busy ? busyText : confirmText }}</span> <span>{{ busy ? busyText : confirmText }}</span>
</button> </button>
@@ -60,18 +71,22 @@ const props = defineProps({
badgeTone: { type: String, default: 'info' }, badgeTone: { type: String, default: 'info' },
title: { type: String, required: true }, title: { type: String, required: true },
description: { type: String, default: '' }, description: { type: String, default: '' },
cancelText: { type: String, default: '取消' }, cancelText: { type: String, default: '取消' },
confirmText: { type: String, default: '确认' }, secondaryText: { type: String, default: '' },
secondaryTone: { type: String, default: 'warning' },
secondaryIcon: { type: String, default: '' },
confirmText: { type: String, default: '确认' },
busyText: { type: String, default: '处理中...' }, busyText: { type: String, default: '处理中...' },
confirmTone: { type: String, default: 'primary' }, confirmTone: { type: String, default: 'primary' },
confirmIcon: { type: String, default: '' }, confirmIcon: { type: String, default: '' },
confirmDisabled: { type: Boolean, default: false },
busy: { type: Boolean, default: false }, busy: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true }, closeOnMask: { type: Boolean, default: true },
size: { type: String, default: 'default' }, size: { type: String, default: 'default' },
actionsAlign: { type: String, default: 'end' } actionsAlign: { type: String, default: 'end' }
}) })
const emit = defineEmits(['close', 'cancel', 'confirm']) const emit = defineEmits(['close', 'cancel', 'secondary', 'confirm'])
const instance = getCurrentInstance() const instance = getCurrentInstance()
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`) const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
@@ -213,15 +228,33 @@ function handleCancel() {
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease; transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
} }
.shared-confirm-btn.cancel { .shared-confirm-btn.cancel {
border: 1px solid #d7e0ea; border: 1px solid #d7e0ea;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
color: #475569; color: #475569;
} }
.shared-confirm-btn.confirm { .shared-confirm-btn.secondary {
border: 1px solid transparent; border: 1px solid rgba(245, 158, 11, 0.28);
color: #fff; background: rgba(255, 251, 235, 0.92);
color: #b45309;
}
.shared-confirm-btn.secondary.primary {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.shared-confirm-btn.secondary.danger {
border-color: rgba(var(--danger-rgb), 0.24);
background: var(--danger-soft);
color: var(--danger-hover);
}
.shared-confirm-btn.confirm {
border: 1px solid transparent;
color: #fff;
} }
.shared-confirm-btn.confirm.primary { .shared-confirm-btn.confirm.primary {
@@ -238,10 +271,15 @@ function handleCancel() {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3); border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3);
color: var(--theme-primary-active); color: var(--theme-primary-active);
} }
.shared-confirm-btn.confirm:hover:not(:disabled) { .shared-confirm-btn.secondary:hover:not(:disabled) {
transform: translateY(-1px); border-color: rgba(245, 158, 11, 0.42);
} background: rgba(254, 243, 199, 0.96);
}
.shared-confirm-btn.confirm:hover:not(:disabled) {
transform: translateY(-1px);
}
.shared-confirm-btn:disabled { .shared-confirm-btn:disabled {
cursor: not-allowed; cursor: not-allowed;

View File

@@ -3,7 +3,7 @@
<div class="detail-card-head employee-risk-head"> <div class="detail-card-head employee-risk-head">
<div class="employee-risk-title-wrap"> <div class="employee-risk-title-wrap">
<h3 class="detail-card-title-with-icon"> <h3 class="detail-card-title-with-icon">
<i class="mdi mdi-account-search-outline"></i> <i class="mdi mdi-file-document-alert-outline"></i>
<span>{{ stageTitle }}</span> <span>{{ stageTitle }}</span>
</h3> </h3>
<span :class="['employee-risk-tone-pill', decisionTone]">{{ decisionBadgeLabel }}</span> <span :class="['employee-risk-tone-pill', decisionTone]">{{ decisionBadgeLabel }}</span>
@@ -11,45 +11,39 @@
</div> </div>
<div class="employee-risk-body"> <div class="employee-risk-body">
<section :class="['employee-risk-ai-note', decisionTone]"> <section :class="['employee-risk-decision-panel', decisionTone]">
<div class="employee-risk-ai-main"> <div class="employee-risk-decision-main">
<span>AI 审核建议</span> <span>综合审核结论</span>
<strong>{{ decisionTitle }}</strong> <strong>{{ decisionTitle }}</strong>
<p>{{ decisionDescription }}</p> <p>{{ decisionDescription }}</p>
</div> </div>
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list"> <div class="employee-risk-decision-action">
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p> <span>建议结论</span>
</div>
<div class="employee-risk-action">
<span>建议动作</span>
<strong :class="decisionTone">{{ decisionAction }}</strong> <strong :class="decisionTone">{{ decisionAction }}</strong>
</div> </div>
</section> </section>
<section class="employee-risk-profile-section"> <section class="employee-risk-profile-section" aria-label="单据风险依据">
<div class="employee-risk-section-head"> <div class="employee-risk-section-head">
<span>{{ stageBasisTitle }}</span> <span>{{ stageBasisTitle }}</span>
<small>{{ stageBasisHint }}</small> <small>{{ stageBasisHint }}</small>
</div> </div>
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
<div class="employee-risk-profile-list"> <article
<section
v-for="item in compactEvidenceItems" v-for="item in compactEvidenceItems"
:key="item.code" :key="item.code"
:class="['employee-risk-profile', item.tone]" :class="['employee-risk-evidence-row', item.tone]"
> >
<div class="employee-risk-profile-title"> <div class="employee-risk-evidence-title">
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<strong :class="item.tone">{{ item.status }}</strong> <strong>{{ item.status }}</strong>
</div> </div>
<ul v-if="item.evidence.length" class="employee-risk-evidence-list"> <ul v-if="item.evidence.length">
<li v-for="basis in item.evidence" :key="basis"> <li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
{{ basis }}
</li>
</ul> </ul>
<p v-else class="employee-risk-muted">暂无显著贡献项</p> </article>
</section>
</div> </div>
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据</p>
</section> </section>
</div> </div>
</article> </article>
@@ -81,6 +75,7 @@ export default {
setup(props) { setup(props) {
const requestModel = computed(() => props.request || {}) const requestModel = computed(() => props.request || {})
const currentItems = computed(() => Array.isArray(props.expenseItems) ? props.expenseItems : []) const currentItems = computed(() => Array.isArray(props.expenseItems) ? props.expenseItems : [])
// 只消费语义层归一后的风险卡片,展示层不新增业务风险字段。
const currentRiskCards = computed(() => const currentRiskCards = computed(() =>
(Array.isArray(props.aiAdvice?.riskCards) ? props.aiAdvice.riskCards : []) (Array.isArray(props.aiAdvice?.riskCards) ? props.aiAdvice.riskCards : [])
.filter((card) => matchesCurrentStage(card, props.isApplicationDocument)) .filter((card) => matchesCurrentStage(card, props.isApplicationDocument))
@@ -88,6 +83,7 @@ export default {
) )
const highRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'high')) const highRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'high'))
const mediumRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'medium')) const mediumRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'medium'))
const riskExplanationItems = computed(() => uniqueTexts(currentRiskCards.value.flatMap((card) => riskExplanationTexts([card]))))
const materialIssues = computed(() => props.isApplicationDocument ? [] : resolveReimbursementMaterialIssues(currentItems.value)) const materialIssues = computed(() => props.isApplicationDocument ? [] : resolveReimbursementMaterialIssues(currentItems.value))
const sceneIssues = computed(() => resolveSceneIssues(requestModel.value, currentItems.value, props.isApplicationDocument)) const sceneIssues = computed(() => resolveSceneIssues(requestModel.value, currentItems.value, props.isApplicationDocument))
const decisionTone = computed(() => { const decisionTone = computed(() => {
@@ -100,14 +96,19 @@ export default {
return 'normal' return 'normal'
}) })
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议') const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请环节风险依据' : '报销环节风险依据') const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请风险依据' : '报销风险依据')
const stageBasisHint = computed(() => ( const stageBasisHint = computed(() => (
props.isApplicationDocument props.isApplicationDocument
? '展示本次申请可能影响预算和审批的风险。' ? '展示申请单本身的金额、预算触发、事由和规则命中依据。'
: '展示本次报销可能影响票据、金额和付款的风险。' : '展示报销单本身的票据、金额、行程和规则命中依据。'
)) ))
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title) const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
const decisionAction = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).action) const decisionAction = computed(() => {
if (!props.isApplicationDocument && riskExplanationItems.value.length && ['medium', 'high'].includes(decisionTone.value)) {
return '请核对已补充说明是否覆盖风险点,再决定通过或退回补充。'
}
return resolveDecision(decisionTone.value, props.isApplicationDocument).action
})
const decisionBadgeLabel = computed(() => { const decisionBadgeLabel = computed(() => {
if (decisionTone.value === 'high') { if (decisionTone.value === 'high') {
return '高风险' return '高风险'
@@ -120,6 +121,9 @@ export default {
const decisionDescription = computed(() => { const decisionDescription = computed(() => {
const riskCount = currentRiskCards.value.length const riskCount = currentRiskCards.value.length
if (riskCount) { if (riskCount) {
if (!props.isApplicationDocument && riskExplanationItems.value.length) {
return `当前报销已识别 ${riskCount} 个需核对风险点,用户已补充异常说明,审批人应核对说明与票据佐证是否充分。`
}
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}已识别 ${riskCount} 个需核对风险点,审批人应优先查看中高风险依据。` return `${props.isApplicationDocument ? '当前申请' : '当前报销'}已识别 ${riskCount} 个需核对风险点,审批人应优先查看中高风险依据。`
} }
if (materialIssues.value.length || sceneIssues.value.length) { if (materialIssues.value.length || sceneIssues.value.length) {
@@ -127,24 +131,13 @@ export default {
} }
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。` return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
}) })
const adviceItems = computed(() => {
const fromRiskCards = currentRiskCards.value
.map((card) => String(card?.suggestion || card?.risk || '').trim())
.filter(Boolean)
return uniqueTexts(fromRiskCards.length ? fromRiskCards : resolveDecision(decisionTone.value, props.isApplicationDocument).advice).slice(0, 4)
})
const compactAdviceItems = computed(() => adviceItems.value.slice(0, 2))
const stageEvidenceItems = computed(() => ( const stageEvidenceItems = computed(() => (
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence() props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
)) ))
const compactEvidenceItems = computed(() => { const compactEvidenceItems = computed(() => {
const abnormalItems = stageEvidenceItems.value.filter((item) => isAbnormalEvidence(item)) const abnormalItems = stageEvidenceItems.value.filter((item) => isAbnormalEvidence(item))
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
return sourceItems.slice(0, 3).map((item) => ({ return sourceItems.map((item) => ({ ...item }))
...item,
evidence: item.evidence.slice(0, 2)
}))
}) })
function buildApplicationEvidence() { function buildApplicationEvidence() {
@@ -156,7 +149,7 @@ export default {
`申请金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`, `申请金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
...riskTexts(amountCards) ...riskTexts(amountCards)
]), ]),
evidenceItem('apply_budget', '预算影响', budgetCards.length ? '需复核' : '未命中', budgetCards.length ? highestTone(budgetCards) : 'normal', ( evidenceItem('apply_budget', '预算触发规则', budgetCards.length ? '需复核' : '未命中', budgetCards.length ? highestTone(budgetCards) : 'normal', (
budgetCards.length ? riskTexts(budgetCards) : ['当前申请暂未命中预算余额或预算占用类中高风险。'] budgetCards.length ? riskTexts(budgetCards) : ['当前申请暂未命中预算余额或预算占用类中高风险。']
)), )),
evidenceItem('apply_scene', '申请事由与场景', sceneIssues.value.length ? '待补充' : '已说明', sceneIssues.value.length ? 'medium' : 'normal', [ evidenceItem('apply_scene', '申请事由与场景', sceneIssues.value.length ? '待补充' : '已说明', sceneIssues.value.length ? 'medium' : 'normal', [
@@ -172,32 +165,39 @@ export default {
} }
function buildReimbursementEvidence() { function buildReimbursementEvidence() {
const attachmentCards = currentRiskCards.value.filter((card) => /附件|票据|发票|OCR|识别|单据/.test(cardText(card))) const riskGroups = classifyReimbursementRiskCards(currentRiskCards.value)
const amountCards = currentRiskCards.value.filter((card) => /金额|标准|阈值|超标|不一致/.test(cardText(card))) const attachmentCards = riskGroups.attachment
const routeCards = currentRiskCards.value.filter((card) => /城市|行程|住宿|交通|出差|地点|日期|时间/.test(cardText(card))) const amountCards = riskGroups.amount
const routeCards = riskGroups.route
const otherCards = riskGroups.other
const needAttachmentItems = currentItems.value.filter((item) => !item?.isSystemGenerated) const needAttachmentItems = currentItems.value.filter((item) => !item?.isSystemGenerated)
const uploadedCount = needAttachmentItems.filter((item) => String(item?.invoiceId || '').trim()).length const uploadedCount = needAttachmentItems.filter((item) => String(item?.invoiceId || '').trim()).length
return [ const evidenceItems = [
evidenceItem('reimburse_attachment', '票据与附件', materialIssues.value.length || attachmentCards.length ? '需核对' : '完整', materialIssues.value.length || attachmentCards.length ? highestTone(attachmentCards, 'medium') : 'normal', [ evidenceItem('reimburse_attachment', '票据与附件', materialIssues.value.length || attachmentCards.length ? '需核对' : '完整', materialIssues.value.length || attachmentCards.length ? highestTone(attachmentCards, 'medium') : 'normal', [
`需附件明细 ${needAttachmentItems.length} 条,已关联 ${uploadedCount} 条,未上传 ${materialIssues.value.length} 条。`, `需附件明细 ${needAttachmentItems.length} 条,已关联 ${uploadedCount} 条,未上传 ${materialIssues.value.length} 条。`,
...materialIssues.value.slice(0, 3), ...materialIssues.value.slice(0, 3),
...riskTexts(attachmentCards) ...riskTexts(attachmentCards)
]), ]),
evidenceItem('reimburse_amount', '报销金额与明细', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [ evidenceItem('reimburse_amount', '报销金额与明细', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [
...riskTexts(amountCards),
...riskExplanationTexts(amountCards),
`报销金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`, `报销金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
`费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}`, `费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}`
...riskTexts(amountCards)
]), ]),
evidenceItem('reimburse_route', '行程/时间/地点', routeCards.length || sceneIssues.value.length ? '需核对' : '已匹配', routeCards.length ? highestTone(routeCards) : sceneIssues.value.length ? 'medium' : 'normal', [ evidenceItem('reimburse_route', '行程/时间/地点', routeCards.length || sceneIssues.value.length ? '需核对' : '已匹配', routeCards.length ? highestTone(routeCards) : sceneIssues.value.length ? 'medium' : 'normal', [
...riskTexts(routeCards),
...riskExplanationTexts(routeCards),
`报销事由:${displayValue(requestModel.value.reason, '待补充')}`, `报销事由:${displayValue(requestModel.value.reason, '待补充')}`,
`报销地点/目的地:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`, `报销地点/目的地:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`,
...sceneIssues.value.map((item) => `当前缺少:${item}`), ...sceneIssues.value.map((item) => `当前缺少:${item}`)
...riskTexts(routeCards) ])
]),
evidenceItem('reimburse_risk', '报销规则命中', currentRiskCards.value.length ? '有风险' : '无异常', decisionTone.value, (
currentRiskCards.value.length ? riskTexts(currentRiskCards.value) : ['报销环节未命中中高风险规则。']
))
] ]
if (otherCards.length || (!amountCards.length && !routeCards.length && !attachmentCards.length)) {
evidenceItems.push(evidenceItem('reimburse_risk', '其他规则命中', otherCards.length ? '有风险' : '无异常', otherCards.length ? highestTone(otherCards) : decisionTone.value, (
otherCards.length ? riskTexts(otherCards) : ['报销环节未命中中高风险规则。']
)))
}
return evidenceItems
} }
function evidenceItem(code, label, status, tone, evidence) { function evidenceItem(code, label, status, tone, evidence) {
@@ -211,8 +211,6 @@ export default {
} }
return { return {
adviceItems,
compactAdviceItems,
compactEvidenceItems, compactEvidenceItems,
decisionBadgeLabel, decisionBadgeLabel,
decisionTone, decisionTone,
@@ -232,18 +230,15 @@ function resolveDecision(tone, isApplicationDocument) {
const map = { const map = {
normal: { normal: {
title: `当前${subject}未发现中高风险阻断项`, title: `当前${subject}未发现中高风险阻断项`,
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`, action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`
advice: [`按当前${subject}信息、预算/票据结果和审批权限继续处理。`, '如审批人掌握额外业务背景,可在审批意见中补充。']
}, },
medium: { medium: {
title: `当前${subject}存在中风险,建议核对后处理`, title: `当前${subject}存在中风险,建议核对后处理`,
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。', action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。'
advice: ['请优先核对橙色风险项对应的业务说明、金额和材料。', '信息补齐或说明充分后,再决定通过或退回。']
}, },
high: { high: {
title: `当前${subject}存在高风险,不建议直接通过`, title: `当前${subject}存在高风险,不建议直接通过`,
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。', action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。'
advice: ['请优先处理红色高风险项,核对命中规则和业务佐证。', '若属于真实业务例外,应要求申请人补充原因和证明材料。']
} }
} }
return map[tone] || map.normal return map[tone] || map.normal
@@ -258,6 +253,40 @@ function isAbnormalEvidence(item) {
return !['正常', '未命中', '已说明', '完整', '已匹配', '无异常'].includes(status) return !['正常', '未命中', '已说明', '完整', '已匹配', '无异常'].includes(status)
} }
function classifyReimbursementRiskCards(cards = []) {
const groups = {
attachment: [],
amount: [],
route: [],
other: []
}
for (const card of cards) {
const text = cardText(card)
if (isAmountRiskText(text)) {
groups.amount.push(card)
} else if (isRouteRiskText(text)) {
groups.route.push(card)
} else if (isAttachmentRiskText(text)) {
groups.attachment.push(card)
} else {
groups.other.push(card)
}
}
return groups
}
function isAmountRiskText(value) {
return /金额|标准|阈值|超标|超出|报销标准|住宿标准|差标|费用上限/.test(String(value || ''))
}
function isRouteRiskText(value) {
return /多城市|中转|多地|改签|城市|行程|交通|出差|地点|日期|时间|目的地|起始地|返回|火车|高铁|机票|航班/.test(String(value || ''))
}
function isAttachmentRiskText(value) {
return /附件|票据|发票|OCR|识别|单据/.test(String(value || ''))
}
function matchesCurrentStage(card, isApplicationDocument) { function matchesCurrentStage(card, isApplicationDocument) {
const businessStage = resolveCardBusinessStage(card) const businessStage = resolveCardBusinessStage(card)
if (businessStage) { if (businessStage) {
@@ -367,11 +396,33 @@ function highestTone(cards, fallback = 'normal') {
function riskTexts(cards) { function riskTexts(cards) {
return cards return cards
.map((card) => String(card?.risk || card?.summary || card?.title || '').trim()) .map((card) => stripEmbeddedExplanationText(card?.risk || card?.summary || card?.title || ''))
.filter(Boolean) .filter(Boolean)
.slice(0, 4) .slice(0, 4)
} }
function riskExplanationTexts(cards) {
return uniqueTexts(cards.flatMap((card) => {
const summary = String(card?.relatedExplanationSummary || '').trim()
const entries = Array.isArray(card?.relatedExplanations)
? card.relatedExplanations.map((item) => String(item?.text || item?.note || '').trim())
: []
if (summary) {
return [`已补充异常说明:${summary}`]
}
return entries.map((item) => item ? `已补充异常说明:${item}` : '')
})).slice(0, 4)
}
function stripEmbeddedExplanationText(value) {
return String(value || '')
.trim()
.replace(/?用户已在相关费用明细补充异常说明[^。;;]*[。;;]?/g, '')
.replace(/?用户已在费用明细补充异常说明[^。;;]*[。;;]?/g, '')
.replace(/[,;。]+$/g, '')
.trim()
}
function cardText(card) { function cardText(card) {
return [ return [
card?.label, card?.label,
@@ -413,327 +464,4 @@ function uniqueTexts(values) {
} }
</script> </script>
<style scoped> <style scoped src="../../assets/styles/components/stage-risk-advice-card.css"></style>
.employee-risk-profile-card {
display: grid;
gap: 10px;
padding: 12px 14px;
}
.employee-risk-head {
margin-bottom: 0;
}
.employee-risk-title-wrap {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.detail-card h3 {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.detail-card-head h3 {
margin-bottom: 0;
}
.detail-card-title-with-icon {
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1.5;
}
.detail-card-title-with-icon i {
margin-top: 1px;
color: #334155;
font-size: 16px;
line-height: 1;
}
.employee-risk-tone-pill {
height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #f8fafc;
color: #475569;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.employee-risk-tone-pill.normal {
border-color: #bbf7d0;
background: #f0fdf4;
color: #047857;
}
.employee-risk-tone-pill.medium {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-tone-pill.high {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-profile-title strong.normal {
background: #ecfdf5;
color: #047857;
}
.employee-risk-profile-title strong.medium {
background: #fff7ed;
color: #c2410c;
}
.employee-risk-profile-title strong.high {
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-body {
display: grid;
gap: 10px;
}
.employee-risk-ai-note,
.employee-risk-profile {
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.employee-risk-ai-note > span,
.employee-risk-ai-main > span,
.employee-risk-section-head span {
color: #64748b;
font-size: 10px;
font-weight: 850;
}
.employee-risk-ai-note strong,
.employee-risk-ai-main strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
overflow-wrap: anywhere;
}
.employee-risk-ai-note {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 10px 12px;
background: #f8fafc;
}
.employee-risk-ai-main {
min-width: 0;
display: grid;
gap: 4px;
}
.employee-risk-ai-note.medium {
border-color: #fed7aa;
background: #fff7ed;
}
.employee-risk-ai-note.high {
border-color: #fecaca;
background: #fef2f2;
}
.employee-risk-ai-note.medium strong {
color: #c2410c;
}
.employee-risk-ai-note.high strong {
color: #b91c1c;
}
.employee-risk-ai-note p {
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.5;
}
.employee-risk-advice-list {
display: grid;
grid-column: 1 / -1;
gap: 4px;
margin-top: -2px;
}
.employee-risk-action {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
text-align: center;
}
.employee-risk-action span {
flex: 0 0 auto;
color: #64748b;
font-size: 10px;
font-weight: 800;
line-height: 1.5;
}
.employee-risk-action strong {
min-width: 0;
color: #0f172a;
font-size: 12px;
font-weight: 800;
line-height: 1.5;
text-align: center;
}
.employee-risk-action strong.medium {
color: #c2410c;
}
.employee-risk-action strong.high {
color: #b91c1c;
}
.employee-risk-advice-list p {
margin: 0;
padding-left: 8px;
border-left: 2px solid #cbd5e1;
color: #475569;
font-size: 12px;
line-height: 1.5;
}
.employee-risk-profile-section {
display: grid;
gap: 6px;
}
.employee-risk-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.employee-risk-section-head small {
color: #94a3b8;
font-size: 10px;
font-weight: 700;
}
.employee-risk-profile-list {
display: grid;
gap: 6px;
align-items: start;
}
.employee-risk-profile {
min-width: 0;
display: grid;
grid-template-columns: 142px minmax(0, 1fr);
gap: 10px;
min-height: 0;
padding: 8px 10px;
}
.employee-risk-profile.medium {
border-color: #fed7aa;
background: #fffaf4;
}
.employee-risk-profile.high {
border-color: #fecaca;
background: #fff7f7;
}
.employee-risk-profile-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 22px;
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.employee-risk-profile-title span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.employee-risk-profile-title strong {
width: 48px;
height: 20px;
flex: 0 0 48px;
display: inline-grid;
place-items: center;
border-radius: 4px;
font-size: 10px;
font-weight: 900;
white-space: nowrap;
}
.employee-risk-evidence-list {
display: grid;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
align-content: start;
}
.employee-risk-evidence-list li {
min-width: 0;
color: #475569;
font-size: 11px;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.employee-risk-muted {
margin: 0;
color: #94a3b8;
font-size: 11px;
}
@media (max-width: 960px) {
.employee-risk-ai-note,
.employee-risk-profile {
grid-template-columns: 1fr;
}
.employee-risk-title-wrap {
flex-wrap: wrap;
}
}
</style>

View File

@@ -10,10 +10,36 @@
:busy-text="busyText" :busy-text="busyText"
confirm-tone="primary" confirm-tone="primary"
confirm-icon="mdi mdi-check-circle-outline" confirm-icon="mdi mdi-check-circle-outline"
:confirm-disabled="confirmDisabled"
:busy="busy" :busy="busy"
@close="emit('close')" @close="emit('close')"
@confirm="emit('confirm')" @confirm="emit('confirm')"
> >
<section v-if="riskConfirmRequired" class="approval-risk-confirm-panel" aria-label="风险说明确认">
<div class="approval-risk-confirm-head">
<span><i class="mdi mdi-alert-decagram-outline"></i>风险说明确认</span>
<strong>{{ riskConfirmItems.length }} 项需核对</strong>
</div>
<ul v-if="riskConfirmItems.length" class="approval-risk-confirm-list">
<li v-for="item in riskConfirmItems" :key="item.id || item.title">
<em :class="item.tone">{{ item.label }}</em>
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.description }}</span>
</div>
</li>
</ul>
<label class="approval-risk-confirm-check">
<input
type="checkbox"
:checked="riskConfirmed"
:disabled="busy"
@change="handleRiskConfirmedChange"
/>
<span>我已核对风险说明异常原因和佐证材料确认继续当前审批动作</span>
</label>
</section>
<label class="approval-opinion-field"> <label class="approval-opinion-field">
<span> <span>
{{ opinionTitle }} {{ opinionTitle }}
@@ -53,19 +79,132 @@ const props = defineProps({
opinion: { type: String, default: '' }, opinion: { type: String, default: '' },
opinionPlaceholder: { type: String, default: '' }, opinionPlaceholder: { type: String, default: '' },
opinionHint: { type: String, default: '' }, opinionHint: { type: String, default: '' },
opinionRequired: { type: Boolean, default: false } opinionRequired: { type: Boolean, default: false },
riskConfirmRequired: { type: Boolean, default: false },
riskConfirmed: { type: Boolean, default: false },
riskConfirmItems: { type: Array, default: () => [] }
}) })
const emit = defineEmits(['close', 'confirm', 'update:opinion']) const emit = defineEmits(['close', 'confirm', 'update:opinion', 'update:risk-confirmed'])
const currentOpinion = computed(() => String(props.opinion || '')) const currentOpinion = computed(() => String(props.opinion || ''))
const confirmDisabled = computed(() => (
(props.riskConfirmRequired && !props.riskConfirmed)
|| (props.opinionRequired && !currentOpinion.value.trim())
))
function handleOpinionInput(event) { function handleOpinionInput(event) {
emit('update:opinion', event.target.value) emit('update:opinion', event.target.value)
} }
function handleRiskConfirmedChange(event) {
emit('update:risk-confirmed', Boolean(event.target.checked))
}
</script> </script>
<style scoped> <style scoped>
.approval-risk-confirm-panel {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #fed7aa;
border-radius: 4px;
background: #fff7ed;
}
.approval-risk-confirm-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.approval-risk-confirm-head span {
display: inline-flex;
align-items: center;
gap: 7px;
color: #9a3412;
font-size: 13px;
font-weight: 850;
}
.approval-risk-confirm-head strong {
color: #c2410c;
font-size: 12px;
font-weight: 850;
}
.approval-risk-confirm-list {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.approval-risk-confirm-list li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 9px;
align-items: flex-start;
padding: 8px;
border: 1px solid rgba(251, 146, 60, 0.28);
border-radius: 4px;
background: #fff;
}
.approval-risk-confirm-list em {
min-width: 46px;
padding: 2px 6px;
border-radius: 4px;
background: #fff7ed;
color: #c2410c;
font-style: normal;
font-size: 11px;
font-weight: 850;
text-align: center;
}
.approval-risk-confirm-list em.high {
background: #fef2f2;
color: #dc2626;
}
.approval-risk-confirm-list div {
min-width: 0;
display: grid;
gap: 3px;
}
.approval-risk-confirm-list strong {
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.approval-risk-confirm-list span {
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.approval-risk-confirm-check {
display: flex;
align-items: flex-start;
gap: 8px;
color: #334155;
font-size: 12px;
font-weight: 750;
line-height: 1.6;
}
.approval-risk-confirm-check input {
width: 15px;
height: 15px;
margin-top: 2px;
accent-color: var(--theme-primary-active);
}
.approval-opinion-field { .approval-opinion-field {
display: grid; display: grid;
gap: 8px; gap: 8px;

View File

@@ -547,9 +547,14 @@ export function useAppShell() {
router.push({ name: 'app-documents', query: buildDocumentReturnQuery() }) router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
} }
async function handleRequestUpdated() { async function handleRequestUpdated(payload = {}) {
if (payload?.claim && typeof payload.claim === 'object') {
const mappedRequest = mapExpenseClaimToRequest(payload.claim)
upsertRequestSnapshot(mappedRequest)
}
const claimId = String(payload?.claimId || payload?.claim_id || route.params.requestId || '').trim()
await reloadWorkbenchRequests() await reloadWorkbenchRequests()
await refreshSelectedRequestDetail(String(route.params.requestId || '')) await refreshSelectedRequestDetail(claimId)
} }
async function handleRequestDeleted(payload = {}) { async function handleRequestDeleted(payload = {}) {

View File

@@ -4,11 +4,11 @@ function readCurrentWebEndpoint(initialState) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return { return {
host: initialState?.web?.host || '0.0.0.0', host: initialState?.web?.host || '0.0.0.0',
port: Number(initialState?.web?.port || 5173) port: Number(initialState?.web?.port || 5273)
} }
} }
const fallbackPort = Number(initialState?.web?.port || 5173) const fallbackPort = Number(initialState?.web?.port || 5273)
const port = Number(window.location.port || fallbackPort) const port = Number(window.location.port || fallbackPort)
return { return {

View File

@@ -57,7 +57,7 @@ function readClientBootstrapState() {
}, },
web: { web: {
host: env.VITE_WEB_HOST || '0.0.0.0', host: env.VITE_WEB_HOST || '0.0.0.0',
port: Number(env.VITE_WEB_PORT || 5173) port: Number(env.VITE_WEB_PORT || 5273)
}, },
server: { server: {
host: env.VITE_SERVER_HOST || '0.0.0.0', host: env.VITE_SERVER_HOST || '0.0.0.0',

View File

@@ -83,6 +83,55 @@ export const BUDGET_ONTOLOGY_FIELDS = [
required: false, required: false,
aliases: ['剩余可用', '可用余额', '剩余预算', '可用预算'] aliases: ['剩余可用', '可用余额', '剩余预算', '可用预算']
}, },
{
key: 'claim_amount',
label: '本单金额',
scope: 'budget_execution',
required: false,
aliases: ['本单金额', '申请金额', '报销金额']
},
{
key: 'claim_amount_ratio',
label: '本单占用比例',
scope: 'budget_execution',
required: false,
aliases: ['本单占用比例', '占用比例', '本次费用占预算']
},
{
key: 'usage_rate',
label: '当前使用率',
scope: 'budget_execution',
required: false,
aliases: ['当前使用率', '预算使用率']
},
{
key: 'after_usage_rate',
label: '审批后使用率',
scope: 'budget_execution',
required: false,
aliases: ['审批后使用率', '审批后预算使用率']
},
{
key: 'remaining_budget_ratio',
label: '剩余比例',
scope: 'budget_execution',
required: false,
aliases: ['剩余比例', '预算剩余比例']
},
{
key: 'available_before_amount',
label: '审批前可用预算',
scope: 'budget_execution',
required: false,
aliases: ['审批前可用预算', '审批前余额']
},
{
key: 'over_budget_amount',
label: '超预算金额',
scope: 'budget_control',
required: false,
aliases: ['超预算金额', '超预算风险', '超出预算']
},
{ {
key: 'warning_threshold', key: 'warning_threshold',
label: '预警线', label: '预警线',

View File

@@ -41,7 +41,7 @@
v-if="openFilterKey === 'status'" v-if="openFilterKey === 'status'"
class="document-filter-menu status-filter-menu" class="document-filter-menu status-filter-menu"
role="listbox" role="listbox"
aria-label="单据状态" aria-label="风险等级"
> >
<button <button
v-for="option in statusFilterOptions" v-for="option in statusFilterOptions"
@@ -187,7 +187,7 @@
<col class="col-title"> <col class="col-title">
<col class="col-amount"> <col class="col-amount">
<col class="col-node"> <col class="col-node">
<col class="col-status"> <col class="col-risk">
<col class="col-updated"> <col class="col-updated">
</colgroup> </colgroup>
<thead> <thead>
@@ -201,7 +201,7 @@
<th>事项</th> <th>事项</th>
<th>金额</th> <th>金额</th>
<th>当前环节</th> <th>当前环节</th>
<th>状态</th> <th>风险等级</th>
<th>更新时间</th> <th>更新时间</th>
</tr> </tr>
</thead> </thead>
@@ -219,7 +219,18 @@
<td data-label="事项">{{ row.reason }}</td> <td data-label="事项">{{ row.reason }}</td>
<td data-label="金额">{{ row.amountDisplay }}</td> <td data-label="金额">{{ row.amountDisplay }}</td>
<td data-label="当前环节">{{ row.node }}</td> <td data-label="当前环节">{{ row.node }}</td>
<td data-label="状态"><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td> <td data-label="风险等级">
<span class="risk-level-tags">
<span
v-for="tag in row.riskTags"
:key="tag.label"
class="risk-level-tag"
:class="tag.tone"
>
{{ tag.label }}
</span>
</span>
</td>
<td data-label="更新时间">{{ row.updatedAtDisplay }}</td> <td data-label="更新时间">{{ row.updatedAtDisplay }}</td>
</tr> </tr>
</tbody> </tbody>
@@ -253,6 +264,7 @@ import {
fetchAllApprovalExpenseClaims, fetchAllApprovalExpenseClaims,
fetchAllArchivedExpenseClaims fetchAllArchivedExpenseClaims
} from '../services/reimbursements.js' } from '../services/reimbursements.js'
import { countClaimRisks, resolveArchiveRiskTone } from '../utils/archiveCenterListFilters.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js' import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import { import {
buildDocumentViewedStatePatch, buildDocumentViewedStatePatch,
@@ -292,46 +304,52 @@ const DOCUMENT_CENTER_QUERY_KEYS = new Set([
'dc_start', 'dc_start',
'dc_end' 'dc_end'
]) ])
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成'] const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
const RISK_TONE_META = {
high: { label: '高风险', tone: 'high' },
medium: { label: '中风险', tone: 'medium' },
low: { label: '低风险', tone: 'low' },
none: { label: '无风险', tone: 'none' }
}
const FILTER_CONFIG_BY_SCOPE = { const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: { [DOCUMENT_SCOPE_ALL]: {
searchPlaceholder: '搜索单号、事项、费用场景...', searchPlaceholder: '搜索单号、事项、费用场景...',
sceneFallbackLabel: '单据场景', sceneFallbackLabel: '单据场景',
dateLabel: '单据时间', dateLabel: '单据时间',
statusTitle: '单据状态', statusTitle: '风险等级',
statusTabs, statusTabs: riskLevelTabs,
showDocumentType: true showDocumentType: true
}, },
[DOCUMENT_SCOPE_APPLICATION]: { [DOCUMENT_SCOPE_APPLICATION]: {
searchPlaceholder: '搜索申请单号、申请事项、申请场景...', searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
sceneFallbackLabel: '申请场景', sceneFallbackLabel: '申请场景',
dateLabel: '申请时间', dateLabel: '申请时间',
statusTitle: '申请状态', statusTitle: '风险等级',
statusTabs: ['全部', '草稿', '审批中', '已完成'], statusTabs: riskLevelTabs,
showDocumentType: false showDocumentType: false
}, },
[DOCUMENT_SCOPE_REIMBURSEMENT]: { [DOCUMENT_SCOPE_REIMBURSEMENT]: {
searchPlaceholder: '搜索报销单号、报销事由、费用场景...', searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
sceneFallbackLabel: '费用场景', sceneFallbackLabel: '费用场景',
dateLabel: '报销时间', dateLabel: '报销时间',
statusTitle: '报销状态', statusTitle: '风险等级',
statusTabs, statusTabs: riskLevelTabs,
showDocumentType: false showDocumentType: false
}, },
[DOCUMENT_SCOPE_REVIEW]: { [DOCUMENT_SCOPE_REVIEW]: {
searchPlaceholder: '搜索审核单号、事项、当前环节...', searchPlaceholder: '搜索审核单号、事项、当前环节...',
sceneFallbackLabel: '审核场景', sceneFallbackLabel: '审核场景',
dateLabel: '审核时间', dateLabel: '审核时间',
statusTitle: '审核状态', statusTitle: '风险等级',
statusTabs: ['全部', '审批中', '待补充', '已完成'], statusTabs: riskLevelTabs,
showDocumentType: false showDocumentType: false
}, },
[DOCUMENT_SCOPE_ARCHIVE]: { [DOCUMENT_SCOPE_ARCHIVE]: {
searchPlaceholder: '搜索归档单号、事项、费用场景...', searchPlaceholder: '搜索归档单号、事项、费用场景...',
sceneFallbackLabel: '归档场景', sceneFallbackLabel: '归档场景',
dateLabel: '归档时间', dateLabel: '归档时间',
statusTitle: '归档状态', statusTitle: '风险等级',
statusTabs: ['全部', '已付款', '已完成'], statusTabs: riskLevelTabs,
showDocumentType: false showDocumentType: false
} }
} }
@@ -458,7 +476,7 @@ const documentTypeFilterLabel = computed(() =>
const statusFilterOptions = computed(() => const statusFilterOptions = computed(() =>
activeFilterConfig.value.statusTabs.map((tab) => ({ activeFilterConfig.value.statusTabs.map((tab) => ({
value: tab, value: tab,
label: tab === '全部' ? '全部状态' : tab label: tab === '全部' ? '全部风险' : tab
})) }))
) )
@@ -546,7 +564,7 @@ const sceneFilterLabel = computed(() =>
) )
const statusFilterLabel = computed(() => const statusFilterLabel = computed(() =>
statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部状态' statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部风险'
) )
const filteredRows = computed(() => { const filteredRows = computed(() => {
@@ -560,7 +578,8 @@ const filteredRows = computed(() => {
row.initiatorName, row.initiatorName,
row.reason, row.reason,
row.node, row.node,
row.statusLabel row.statusLabel,
row.riskLabel
].filter(Boolean).join('').toLowerCase().includes(keyword) ].filter(Boolean).join('').toLowerCase().includes(keyword)
const matchesDocumentType = const matchesDocumentType =
@@ -569,10 +588,10 @@ const filteredRows = computed(() => {
|| row.documentTypeCode === activeDocumentType.value || row.documentTypeCode === activeDocumentType.value
const matchesScene = activeScene.value === SCENE_ALL || row.typeCode === activeScene.value const matchesScene = activeScene.value === SCENE_ALL || row.typeCode === activeScene.value
const matchesStatus = matchesStatusTab(row, activeStatusTab.value) const matchesRiskLevel = matchesRiskLevelTab(row, activeStatusTab.value)
const matchesDateRange = matchesAppliedDateRange(row) const matchesDateRange = matchesAppliedDateRange(row)
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange return matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange
})) }))
}) })
@@ -674,6 +693,7 @@ function buildDocumentRow(request, options = {}) {
const archived = Boolean(options.archived) const archived = Boolean(options.archived)
const statusGroup = resolveStatusGroup(normalized, archived) const statusGroup = resolveStatusGroup(normalized, archived)
const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup) const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
const riskMeta = buildDocumentRiskMeta(normalized)
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成' const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
const claimId = normalized.claimId || normalized.id || documentNo const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
@@ -708,6 +728,10 @@ function buildDocumentRow(request, options = {}) {
statusGroup, statusGroup,
statusLabel, statusLabel,
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup), statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
riskTone: riskMeta.tone,
riskLabel: riskMeta.label,
riskCount: riskMeta.count,
riskTags: riskMeta.tags,
source: options.source || 'owned', source: options.source || 'owned',
archived, archived,
createdAtDisplay: formatDocumentListTime(createdAtSource), createdAtDisplay: formatDocumentListTime(createdAtSource),
@@ -744,19 +768,48 @@ function resolveStatusTone(row, statusGroup) {
return row.approvalTone || 'neutral' return row.approvalTone || 'neutral'
} }
function matchesStatusTab(row, tab) { function resolveDocumentRiskFlags(row) {
if (Array.isArray(row?.riskFlags)) {
return row.riskFlags
}
if (Array.isArray(row?.risk_flags_json)) {
return row.risk_flags_json
}
return []
}
function buildDocumentRiskMeta(row) {
const riskFlags = resolveDocumentRiskFlags(row)
const riskSummary = row?.riskSummary || row?.risk
const count = countClaimRisks(riskFlags, riskSummary)
if (!count) {
const meta = RISK_TONE_META.none
return {
...meta,
count: 0,
tags: [{ ...meta }]
}
}
const tone = resolveArchiveRiskTone(riskFlags, riskSummary)
const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium
return {
...meta,
count,
tags: [{ tone: meta.tone, label: `${meta.label} ${count}` }]
}
}
function matchesRiskLevelTab(row, tab) {
if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) { if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
return false return false
} }
if (tab === '全部') return true if (tab === '全部') return true
if (tab === '草稿') return row.statusGroup === 'draft' if (tab === '高风险') return row.riskTone === 'high'
if (tab === '待提交') return row.statusGroup === 'pending_submit' if (tab === '中风险') return row.riskTone === 'medium'
if (tab === '审批中') return row.statusGroup === 'in_progress' if (tab === '低风险') return row.riskTone === 'low'
if (tab === '待补充') return row.statusGroup === 'supplement' if (tab === '无风险') return row.riskTone === 'none'
if (tab === '待付款') return row.statusGroup === 'pending_payment'
if (tab === '已付款') return row.statusLabel === '已付款' || row.node === '已付款'
if (tab === '已完成') return row.statusGroup === 'completed'
return true return true
} }

View File

@@ -777,12 +777,16 @@
:title="`确认提交 ${request.id} 吗?`" :title="`确认提交 ${request.id} 吗?`"
:description="submitConfirmDescription" :description="submitConfirmDescription"
cancel-text="返回核对" cancel-text="返回核对"
:secondary-text="submitConfirmSecondaryText"
secondary-tone="warning"
secondary-icon="mdi mdi-calculator-variant-outline"
:confirm-text="submitConfirmText" :confirm-text="submitConfirmText"
busy-text="提交中..." busy-text="提交中..."
confirm-tone="primary" confirm-tone="primary"
confirm-icon="mdi mdi-send-circle-outline" confirm-icon="mdi mdi-send-circle-outline"
:busy="submitBusy" :busy="submitBusy"
@close="closeSubmitConfirmDialog" @close="closeSubmitConfirmDialog"
@secondary="confirmStandardAdjustment"
@confirm="confirmSubmitRequest" @confirm="confirmSubmitRequest"
> >
<div class="submit-confirm-summary" aria-label="提交前核对摘要"> <div class="submit-confirm-summary" aria-label="提交前核对摘要">
@@ -807,51 +811,55 @@
<ConfirmDialog <ConfirmDialog
:open="riskOverrideDialogOpen" :open="riskOverrideDialogOpen"
badge="异常说明" badge="异常说明"
badge-tone="danger" :badge-tone="riskOverrideBadgeTone"
size="review" size="review"
:title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`" :title="riskOverrideDialogTitle"
description="请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,可选择按职级最高可报销金额重新计算。" :description="riskOverrideDialogDescription"
cancel-text="返回整改" :cancel-text="riskOverrideCancelText"
confirm-text="按职级标准重算" :confirm-text="riskOverrideConfirmText"
busy-text="处理中..." busy-text="处理中..."
confirm-tone="danger" :confirm-tone="riskOverrideConfirmTone"
confirm-icon="mdi mdi-calculator-variant-outline" :confirm-icon="riskOverrideConfirmIcon"
:busy="riskOverrideBusy" :busy="riskOverrideBusy"
@close="closeRiskOverrideDialog" @close="closeRiskOverrideDialog"
@confirm="confirmStandardAdjustment" @confirm="confirmRiskOverrideDialog"
> >
<div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="异常说明"> <div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="异常说明">
<div class="risk-override-nav"> <div class="risk-override-card-shell">
<button <button
type="button" type="button"
class="risk-override-nav-btn" class="risk-override-side-nav risk-override-side-nav--previous"
:disabled="submitRiskWarnings.length <= 1 || riskOverrideBusy" :disabled="submitRiskReviewWarnings.length <= 1 || riskOverrideBusy"
aria-label="上一条风险" aria-label="上一条风险"
@click="goToPreviousSubmitRisk" @click="goToPreviousSubmitRisk"
> >
<i class="mdi mdi-chevron-left"></i> <i class="mdi mdi-chevron-left"></i>
</button> </button>
<span>{{ riskOverrideIndexLabel }}</span> <article :class="['risk-override-card', currentSubmitRiskWarning.tone]">
<div class="risk-override-card-head">
<span>{{ currentSubmitRiskWarning.label }}</span>
<strong>{{ currentSubmitRiskWarning.title }}</strong>
</div>
<p>{{ currentSubmitRiskWarning.risk }}</p>
<div v-if="currentSubmitRiskWarningNotes.length" class="risk-override-notes">
<span>已填写异常说明</span>
<strong v-for="note in currentSubmitRiskWarningNotes" :key="note">{{ note }}</strong>
</div>
</article>
<button <button
type="button" type="button"
class="risk-override-nav-btn" class="risk-override-side-nav risk-override-side-nav--next"
:disabled="submitRiskWarnings.length <= 1 || riskOverrideBusy" :disabled="submitRiskReviewWarnings.length <= 1 || riskOverrideBusy"
aria-label="下一条风险" aria-label="下一条风险"
@click="goToNextSubmitRisk" @click="goToNextSubmitRisk"
> >
<i class="mdi mdi-chevron-right"></i> <i class="mdi mdi-chevron-right"></i>
</button> </button>
</div> </div>
<article :class="['risk-override-card', currentSubmitRiskWarning.tone]"> <div class="risk-override-index">{{ riskOverrideIndexLabel }}</div>
<div class="risk-override-card-head">
<span>{{ currentSubmitRiskWarning.label }}</span>
<strong>{{ currentSubmitRiskWarning.title }}</strong>
</div>
<p>{{ currentSubmitRiskWarning.risk }}</p>
</article>
<div class="risk-override-guidance"> <div class="risk-override-guidance">
<strong>请在费用明细的异常说明列补充原因后再提交</strong> <strong>{{ riskOverrideGuidanceTitle }}</strong>
<span>如果不补充说明可直接选择按职级标准重算超出标准的部分由员工自担</span> <span>{{ riskOverrideGuidanceText }}</span>
</div> </div>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
@@ -869,6 +877,9 @@
:opinion-placeholder="approvalOpinionPlaceholder" :opinion-placeholder="approvalOpinionPlaceholder"
:opinion-hint="approvalOpinionHint" :opinion-hint="approvalOpinionHint"
:opinion-required="requiresApprovalOpinion" :opinion-required="requiresApprovalOpinion"
:risk-confirm-required="approvalRiskConfirmRequired"
v-model:risk-confirmed="approvalRiskConfirmed"
:risk-confirm-items="approvalRiskConfirmItems"
@close="closeApproveConfirmDialog" @close="closeApproveConfirmDialog"
@confirm="confirmApproveRequest" @confirm="confirmApproveRequest"
/> />

View File

@@ -31,7 +31,6 @@ import {
import { import {
canApproveBudgetExpenseApplications, canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims, canApproveLeaderExpenseClaims,
canDeleteArchivedExpenseClaims,
canManageExpenseClaims, canManageExpenseClaims,
canReturnExpenseClaims, canReturnExpenseClaims,
isCurrentDirectManagerForRequest, isCurrentDirectManagerForRequest,
@@ -97,7 +96,8 @@ import {
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel, buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel, filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel,
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel, isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel,
resolveExpenseItemsForRiskCard as resolveExpenseItemsForRiskCardModel
} from './travelRequestDetailStandardAdjustment.js' } from './travelRequestDetailStandardAdjustment.js'
import { import {
buildEmployeeProfileAdviceItems, buildEmployeeProfileAdviceItems,
@@ -626,6 +626,7 @@ export default {
const returnDialogOpen = ref(false) const returnDialogOpen = ref(false)
const approveBusy = ref(false) const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false) const approveConfirmDialogOpen = ref(false)
const approvalRiskConfirmed = ref(false)
const leaderOpinion = ref('') const leaderOpinion = ref('')
const expenseUploadInput = ref(null) const expenseUploadInput = ref(null)
const smartEntryUploadInput = ref(null) const smartEntryUploadInput = ref(null)
@@ -712,18 +713,7 @@ export default {
)) ))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value)) const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value)) const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => { const canDeleteRequest = computed(() => isPlatformAdminUser(currentUser.value))
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
}
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
if (canManageCurrentClaim.value) {
return true
}
return isEditableRequest.value && isCurrentApplicant.value
})
const isDirectManagerApprovalStage = computed(() => { const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim() const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批' return node === '直属领导审批'
@@ -828,12 +818,30 @@ export default {
isApplicationDocument.value isApplicationDocument.value
&& hasLeaderApprovalEvents.value && hasLeaderApprovalEvents.value
)) ))
const requiresApprovalOpinion = computed(() => false) const budgetApprovalOpinionRequired = computed(() => (
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见')) isBudgetApprovalStage.value
&& hasBudgetApprovalWarning(request.value)
))
const requiresApprovalOpinion = computed(() => budgetApprovalOpinionRequired.value)
const approvalOpinionTitle = computed(() => {
if (isFinanceApprovalStage.value) {
return '财务意见'
}
if (isBudgetApprovalStage.value) {
return '预算审批意见'
}
return '附加意见'
})
const approvalOpinionPlaceholder = computed(() => { const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) { if (isFinanceApprovalStage.value) {
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。' return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
} }
if (budgetApprovalOpinionRequired.value) {
return '预算已超过警戒值,请写明预算审批意见、通过依据或后续控制要求。'
}
if (isBudgetApprovalStage.value) {
return '可选填预算审批补充说明;未超过预算警戒值时不填写默认为同意。'
}
if (isApplicationDocument.value) { if (isApplicationDocument.value) {
return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。' return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
} }
@@ -844,10 +852,35 @@ export default {
return '审核通过后将进入待付款。' return '审核通过后将进入待付款。'
} }
if (isBudgetApprovalStage.value) { if (isBudgetApprovalStage.value) {
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。' return budgetApprovalOpinionRequired.value
? '预算已超过警戒值,需填写预算审批意见后才能通过。'
: '未超过预算警戒值时不填写意见将默认同意,确认后按流程继续流转。'
} }
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。' return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
}) })
const approvalRiskConfirmItems = computed(() =>
aiAdvice.value.riskCards
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.slice(0, 4)
.map((card, index) => ({
id: String(card?.id || `approval-risk-${index + 1}`),
tone: normalizeRiskTone(card?.tone),
label: normalizeRiskTone(card?.tone) === 'high' ? '高风险' : '中风险',
title: String(card?.title || card?.label || '风险提示').trim(),
description: String(
card?.relatedExplanationSummary
|| card?.risk
|| card?.summary
|| card?.suggestion
|| '请核对该风险点对应的说明和佐证材料。'
).trim()
}))
)
const approvalRiskConfirmRequired = computed(() =>
canApproveRequest.value
&& canViewApprovalRiskAdvice.value
&& approvalRiskConfirmItems.value.length > 0
)
const approvalConfirmBadge = computed(() => { const approvalConfirmBadge = computed(() => {
if (isFinanceApprovalStage.value) { if (isFinanceApprovalStage.value) {
return '财务终审' return '财务终审'
@@ -1183,6 +1216,10 @@ export default {
return resolveExpenseItemForRiskCardModel(card, expenseItems.value) return resolveExpenseItemForRiskCardModel(card, expenseItems.value)
} }
function resolveExpenseItemsForRiskCard(card) {
return resolveExpenseItemsForRiskCardModel(card, expenseItems.value)
}
function filterSubmitterResolvedRiskCards(cards, businessStage) { function filterSubmitterResolvedRiskCards(cards, businessStage) {
const viewerContext = riskViewerContext.value || {} const viewerContext = riskViewerContext.value || {}
return filterSubmitterResolvedRiskCardsModel({ return filterSubmitterResolvedRiskCardsModel({
@@ -1205,9 +1242,16 @@ export default {
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value) return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
} }
function resolveRiskWarningNotes(card) {
const notes = resolveExpenseItemsForRiskCard(card)
.map((item) => String(item?.itemNote || '').trim())
.filter(Boolean)
return [...new Set(notes)]
}
async function buildStandardAdjustmentPayload() { async function buildStandardAdjustmentPayload() {
return buildStandardAdjustmentPayloadModel({ return buildStandardAdjustmentPayloadModel({
warnings: submitRiskWarnings.value, warnings: submitRiskCards.value,
expenseItems: expenseItems.value, expenseItems: expenseItems.value,
request: request.value, request: request.value,
calculateTravelReimbursement calculateTravelReimbursement
@@ -1733,24 +1777,72 @@ export default {
})) }))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({ const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value, isApplicationDocument: isApplicationDocument.value,
hasHighRiskWarnings: submitRiskWarnings.value.length > 0 hasHighRiskWarnings: aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
})) }))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value)) const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
const submitRiskWarnings = computed(() => const submitRiskCards = computed(() =>
aiAdvice.value.riskCards aiAdvice.value.riskCards
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))) .filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.filter((card) => isRiskCardMissingExpenseNote(card))
.map((card, index) => ({ .map((card, index) => ({
...card, ...card,
id: String(card.id || `submit-risk-${index}`), id: String(card.id || `submit-risk-${index}`),
tags: resolveRiskTags(card) tags: resolveRiskTags(card)
})) }))
) )
const currentSubmitRiskWarning = computed(() => submitRiskWarnings.value[riskOverrideIndex.value] || null) const submitConfirmSecondaryText = computed(() => (
const riskOverrideIndexLabel = computed(() => !isApplicationDocument.value && submitRiskCards.value.length
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : '' ? '按职级标准报销'
: ''
))
const submitRiskWarnings = computed(() =>
submitRiskCards.value.filter((card) => isRiskCardMissingExpenseNote(card))
) )
const submitExplainedRiskWarnings = computed(() =>
submitRiskCards.value.filter((card) => !isRiskCardMissingExpenseNote(card))
)
const hasMissingSubmitRiskWarnings = computed(() => submitRiskWarnings.value.length > 0)
const submitRiskReviewWarnings = computed(() =>
hasMissingSubmitRiskWarnings.value ? submitRiskWarnings.value : submitExplainedRiskWarnings.value
)
const currentSubmitRiskWarning = computed(() => submitRiskReviewWarnings.value[riskOverrideIndex.value] || null)
const currentSubmitRiskWarningNotes = computed(() =>
currentSubmitRiskWarning.value ? resolveRiskWarningNotes(currentSubmitRiskWarning.value) : []
)
const riskOverrideIndexLabel = computed(() =>
submitRiskReviewWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskReviewWarnings.value.length}` : ''
)
const riskOverrideBadgeTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'warning')
const riskOverrideDialogTitle = computed(() => (
hasMissingSubmitRiskWarnings.value
? `当前存在 ${submitRiskWarnings.value.length} 条需说明的风险`
: `请确认 ${submitExplainedRiskWarnings.value.length} 条风险及异常说明`
))
const riskOverrideDialogDescription = computed(() => (
hasMissingSubmitRiskWarnings.value
? '请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,可选择按职级最高可报销金额重新计算。'
: '请核对风险点与已填写的异常说明,确认后进入提交确认。'
))
const riskOverrideCancelText = computed(() => (
hasMissingSubmitRiskWarnings.value ? '返回整改' : '返回核对'
))
const riskOverrideConfirmText = computed(() =>
hasMissingSubmitRiskWarnings.value ? '按职级标准重算' : '确认说明'
)
const riskOverrideConfirmTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'primary')
const riskOverrideConfirmIcon = computed(() =>
hasMissingSubmitRiskWarnings.value ? 'mdi mdi-calculator-variant-outline' : 'mdi mdi-check-circle-outline'
)
const riskOverrideGuidanceTitle = computed(() => (
hasMissingSubmitRiskWarnings.value
? '请在费用明细的“异常说明”列补充原因后再提交。'
: '已填写异常说明,请确认说明会随单据进入审批。'
))
const riskOverrideGuidanceText = computed(() => (
hasMissingSubmitRiskWarnings.value
? '如果不补充说明,可直接选择按职级标准重算,超出标准的部分由员工自担。'
: '确认后系统会继续进入提交确认,领导和财务可看到这些风险及对应说明。'
))
function resetDetailNote() { function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value detailNoteEditor.value = detailNoteSource.value
@@ -1783,7 +1875,7 @@ export default {
} }
function openRiskOverrideDialog() { function openRiskOverrideDialog() {
const warnings = submitRiskWarnings.value const warnings = submitRiskReviewWarnings.value
if (!warnings.length) { if (!warnings.length) {
return return
} }
@@ -1799,18 +1891,34 @@ export default {
} }
function goToPreviousSubmitRisk() { function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) { if (!submitRiskReviewWarnings.value.length) {
return return
} }
riskOverrideIndex.value = riskOverrideIndex.value =
(riskOverrideIndex.value - 1 + submitRiskWarnings.value.length) % submitRiskWarnings.value.length (riskOverrideIndex.value - 1 + submitRiskReviewWarnings.value.length) % submitRiskReviewWarnings.value.length
} }
function goToNextSubmitRisk() { function goToNextSubmitRisk() {
if (!submitRiskWarnings.value.length) { if (!submitRiskReviewWarnings.value.length) {
return return
} }
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskReviewWarnings.value.length
}
function confirmRiskExplanation() {
if (riskOverrideBusy.value || submitBusy.value) {
return
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
}
function confirmRiskOverrideDialog() {
if (hasMissingSubmitRiskWarnings.value) {
confirmStandardAdjustment()
return
}
confirmRiskExplanation()
} }
function confirmStandardAdjustment() { function confirmStandardAdjustment() {
@@ -1824,6 +1932,7 @@ export default {
} }
riskOverrideDialogOpen.value = false riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = false
standardAdjustmentBusy.value = true standardAdjustmentBusy.value = true
const taskSeq = ++standardAdjustmentTaskSeq const taskSeq = ++standardAdjustmentTaskSeq
toast('\u6b63\u5728\u540e\u53f0\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u65b0\u6d4b\u7b97\u8d39\u7528\u3002') toast('\u6b63\u5728\u540e\u53f0\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u65b0\u6d4b\u7b97\u8d39\u7528\u3002')
@@ -2308,7 +2417,7 @@ export default {
return return
} }
if (submitRiskWarnings.value.length) { if (submitRiskReviewWarnings.value.length) {
openRiskOverrideDialog() openRiskOverrideDialog()
return return
} }
@@ -2490,6 +2599,7 @@ export default {
return return
} }
approvalRiskConfirmed.value = !approvalRiskConfirmRequired.value
approveConfirmDialogOpen.value = true approveConfirmDialogOpen.value = true
} }
@@ -2522,6 +2632,16 @@ export default {
return return
} }
if (approvalRiskConfirmRequired.value && !approvalRiskConfirmed.value) {
toast('请先确认已核对风险说明和佐证材料,再继续审批。')
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('预算已超过警戒值,请填写预算审批意见后再通过。')
return
}
approveBusy.value = true approveBusy.value = true
try { try {
const responsePayload = await approveExpenseClaim(request.value.claimId, { const responsePayload = await approveExpenseClaim(request.value.claimId, {
@@ -2529,13 +2649,17 @@ export default {
}) })
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload) const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false approveConfirmDialogOpen.value = false
approvalRiskConfirmed.value = false
leaderOpinion.value = '' leaderOpinion.value = ''
toast( toast(
isApplicationDocument.value && generatedDraftClaimNo isApplicationDocument.value && generatedDraftClaimNo
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。` ? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
: approvalSuccessToast.value : approvalSuccessToast.value
) )
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', {
claimId: request.value.claimId,
claim: responsePayload
})
emit('backToRequests') emit('backToRequests')
} catch (error) { } catch (error) {
toast(resolveApproveErrorMessage(error)) toast(resolveApproveErrorMessage(error))
@@ -2636,6 +2760,7 @@ export default {
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge, attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalOpinionHint, approvalConfirmDescription, approvalOpinionHint,
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel, approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
approvalRiskConfirmed, approvalRiskConfirmItems, approvalRiskConfirmRequired,
applicationDetailFactItems, relatedApplicationFactItems, applicationDetailFactItems, relatedApplicationFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim, approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview, canNavigateAttachmentPreview,
@@ -2643,10 +2768,10 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog, closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog, closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest, closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmStandardAdjustment, confirmSmartEntryUpload, confirmPayRequest, confirmRiskExplanation, confirmRiskOverrideDialog, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile, chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion, currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning, currentSubmitRiskWarning, currentSubmitRiskWarningNotes,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen, canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty, deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor, detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor,
@@ -2668,7 +2793,10 @@ export default {
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard, resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBadgeTone, riskOverrideBusy,
riskOverrideCancelText, riskOverrideConfirmIcon, riskOverrideConfirmText, riskOverrideConfirmTone,
riskOverrideDialogDescription, riskOverrideDialogOpen, riskOverrideDialogTitle,
riskOverrideGuidanceText, riskOverrideGuidanceTitle, riskOverrideIndexLabel,
requiresApprovalOpinion, requiresApprovalOpinion,
saveDetailNote, savingDetailNote, savingExpenseId, saveDetailNote, savingDetailNote, savingExpenseId,
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary, smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
@@ -2678,8 +2806,56 @@ export default {
showAiAdvicePanel, showApplicationLeaderOpinion, showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice, showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy, showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings, submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmSecondaryText, submitConfirmText,
submitExplainedRiskWarnings, submitRiskReviewWarnings, submitRiskWarnings, hasMissingSubmitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
} }
} }
} }
function hasBudgetApprovalWarning(request = {}) {
const flags = Array.isArray(request?.riskFlags)
? request.riskFlags
: Array.isArray(request?.risk_flags_json)
? request.risk_flags_json
: []
return flags.some((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const routeDecision = flag.route_decision || flag.routeDecision || {}
const directBudgetResult = flag.budget_result || flag.budgetResult
const routeBudgetResult = routeDecision?.budget_result || routeDecision?.budgetResult
const budgetResult = routeBudgetResult || directBudgetResult
if (!budgetResult || typeof budgetResult !== 'object') {
return false
}
return budgetResultExceedsWarning(budgetResult)
})
}
function budgetResultExceedsWarning(budgetResult = {}) {
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
const context = budgetResult.budget_context && typeof budgetResult.budget_context === 'object'
? budgetResult.budget_context
: budgetResult.budgetContext && typeof budgetResult.budgetContext === 'object'
? budgetResult.budgetContext
: {}
const overBudgetAmount = parseBudgetNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
if (overBudgetAmount > 0) {
return true
}
const afterUsageRate = parseBudgetNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
const claimAmountRatio = parseBudgetNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
const warningThreshold = parseBudgetNumber(context.warning_threshold ?? context.warningThreshold, 80)
return Math.max(afterUsageRate, claimAmountRatio) >= warningThreshold
}
function parseBudgetNumber(value, fallback = 0) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}

View File

@@ -107,6 +107,25 @@ export function isAttachmentRequiredExpenseItem(source) {
return !isSystemGeneratedExpenseItemSource({ ...source, itemType }) && !OPTIONAL_ATTACHMENT_EXPENSE_TYPES.has(itemType) return !isSystemGeneratedExpenseItemSource({ ...source, itemType }) && !OPTIONAL_ATTACHMENT_EXPENSE_TYPES.has(itemType)
} }
export function hasUploadedReceiptReference(source) {
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
if (!isPlaceholderValue(invoiceId)) {
return true
}
return Array.isArray(source?.attachments) && source.attachments.some((item) => !isPlaceholderValue(item))
}
export function isIgnorableExpenseDraftPlaceholder(item) {
if (!item || isSystemGeneratedExpenseItemSource(item) || hasUploadedReceiptReference(item)) {
return false
}
const amount = Number(item?.itemAmount ?? item?.item_amount ?? 0)
const missingAmount = !Number.isFinite(amount) || amount <= 0
const missingReason = isPlaceholderValue(item?.itemReason ?? item?.item_reason)
const missingLocation = isPlaceholderValue(item?.itemLocation ?? item?.item_location)
return missingAmount && missingReason && missingLocation
}
export function isLocationRequiredExpenseType(value) { export function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value)) return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
} }
@@ -568,6 +587,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
export function rebuildExpenseItems(items, requestModel) { export function rebuildExpenseItems(items, requestModel) {
const sortedItems = [...items] const sortedItems = [...items]
.filter((item) => !isIgnorableExpenseDraftPlaceholder(item))
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right))) .sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel) const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap)) return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
@@ -575,29 +595,33 @@ export function rebuildExpenseItems(items, requestModel) {
export function buildExpenseDraftIssues(item) { export function buildExpenseDraftIssues(item) {
const issues = [] const issues = []
if (item.isSystemGenerated) { if (item.isSystemGenerated || isSystemGeneratedExpenseItemSource(item)) {
return issues
}
if (isIgnorableExpenseDraftPlaceholder(item)) {
return issues return issues
} }
const locationRequired = isLocationRequiredExpenseType(item.itemType) const locationRequired = isLocationRequiredExpenseType(item.itemType)
const hasUploadedReceipt = hasUploadedReceiptReference(item)
if (!isValidIsoDate(item.itemDate)) { if (!hasUploadedReceipt && !isValidIsoDate(item.itemDate)) {
issues.push('缺少日期') issues.push('缺少日期')
} }
if (isPlaceholderValue(item.itemType)) { if (isPlaceholderValue(item.itemType)) {
issues.push('缺少费用项目') issues.push('缺少费用项目')
} }
if (isPlaceholderValue(item.itemReason)) { if (!hasUploadedReceipt && isPlaceholderValue(item.itemReason)) {
issues.push('缺少说明') issues.push('缺少说明')
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) { } else if (!hasUploadedReceipt && isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
issues.push('行程说明格式错误') issues.push('行程说明格式错误')
} }
if (locationRequired && isPlaceholderValue(item.itemLocation)) { if (!hasUploadedReceipt && locationRequired && isPlaceholderValue(item.itemLocation)) {
issues.push('缺少地点') issues.push('缺少地点')
} }
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) { if (!hasUploadedReceipt && (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0)) {
issues.push('缺少金额') issues.push('缺少金额')
} }
if (isAttachmentRequiredExpenseItem(item) && isPlaceholderValue(item.invoiceId)) { if (isAttachmentRequiredExpenseItem(item) && !hasUploadedReceipt) {
issues.push('缺少票据标识') issues.push('缺少票据标识')
} }
@@ -609,14 +633,15 @@ export function buildDraftBlockingIssues(request, expenseItems) {
const isApplication = isApplicationDocumentRequest(request) const isApplication = isApplicationDocumentRequest(request)
const locationRequired = isLocationRequiredExpenseType(request.typeCode) const locationRequired = isLocationRequiredExpenseType(request.typeCode)
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : [] const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
const itemAmountTotal = normalizedItems.reduce((sum, item) => { const effectiveItems = normalizedItems.filter((item) => !isIgnorableExpenseDraftPlaceholder(item))
const itemAmountTotal = effectiveItems.reduce((sum, item) => {
const amount = Number(item?.itemAmount || 0) const amount = Number(item?.itemAmount || 0)
return Number.isFinite(amount) && amount > 0 ? sum + amount : sum return Number.isFinite(amount) && amount > 0 ? sum + amount : sum
}, 0) }, 0)
const hasValidItemDate = normalizedItems.some((item) => isValidIsoDate(item?.itemDate)) const hasValidItemDate = effectiveItems.some((item) => isValidIsoDate(item?.itemDate))
const hasValidItemType = normalizedItems.some((item) => !isPlaceholderValue(item?.itemType)) const hasValidItemType = effectiveItems.some((item) => !isPlaceholderValue(item?.itemType))
const hasValidItemReason = normalizedItems.some((item) => !isPlaceholderValue(item?.itemReason)) const hasValidItemReason = effectiveItems.some((item) => !isPlaceholderValue(item?.itemReason))
const hasValidItemLocation = normalizedItems.some((item) => !isPlaceholderValue(item?.itemLocation)) const hasValidItemLocation = effectiveItems.some((item) => !isPlaceholderValue(item?.itemLocation))
if (isPlaceholderValue(request.profileName)) { if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善') issues.push('申请人未完善')
@@ -655,7 +680,7 @@ export function buildDraftBlockingIssues(request, expenseItems) {
if ((!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) && itemAmountTotal <= 0) { if ((!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) && itemAmountTotal <= 0) {
issues.push('报销金额未完善') issues.push('报销金额未完善')
} }
if (!normalizedItems.length) { if (!effectiveItems.length) {
issues.push('费用明细不能为空') issues.push('费用明细不能为空')
} }

View File

@@ -66,6 +66,32 @@ function cardLikeText(card = {}) {
].map((item) => normalizeText(item)).join(' ') ].map((item) => normalizeText(item)).join(' ')
} }
function resolveRiskCardItemIds(card = {}) {
return normalizeIdList([
card.itemId,
card.item_id,
...(Array.isArray(card.itemIds) ? card.itemIds : []),
...(Array.isArray(card.item_ids) ? card.item_ids : [])
])
}
function resolveDuplicateRiskGroup(card = {}) {
const text = cardLikeText(card)
if (/多城市行程|中转|多地拜访|改签|多地出差|后续行程|行程终点异常|连续闭环/.test(text) && /待说明|未说明|缺少说明|原因|说明|不一致|异常/.test(text)) {
return 'route-explanation'
}
return ''
}
function riskCardsReferToSameIssue(left = {}, right = {}) {
const leftItemIds = resolveRiskCardItemIds(left)
const rightItemIds = resolveRiskCardItemIds(right)
if (!leftItemIds.length || !rightItemIds.length) {
return true
}
return leftItemIds.some((itemId) => rightItemIds.includes(itemId))
}
function normalizeTone(value) { function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase() const tone = normalizeText(value).toLowerCase()
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass' if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
@@ -95,6 +121,30 @@ function isRiskTone(tone) {
return ['medium', 'high'].includes(normalizeText(tone).toLowerCase()) return ['medium', 'high'].includes(normalizeText(tone).toLowerCase())
} }
function riskToneWeight(tone) {
const normalizedTone = normalizeTone(tone)
if (normalizedTone === 'high') return 0
if (normalizedTone === 'medium') return 1
if (normalizedTone === 'low') return 2
if (normalizedTone === 'pass') return 4
return 9
}
function dedupeLowerSeverityRiskCards(cards = []) {
return cards.filter((card, index) => {
const duplicateGroup = resolveDuplicateRiskGroup(card)
if (!duplicateGroup) {
return true
}
return !cards.some((otherCard, otherIndex) => (
otherIndex !== index
&& resolveDuplicateRiskGroup(otherCard) === duplicateGroup
&& riskToneWeight(otherCard?.tone || otherCard?.severity) < riskToneWeight(card?.tone || card?.severity)
&& riskCardsReferToSameIssue(card, otherCard)
))
})
}
function normalizeId(value) { function normalizeId(value) {
return normalizeText(value) return normalizeText(value)
} }
@@ -108,6 +158,152 @@ function normalizeIdList(value) {
return [...new Set(rawValues.map((item) => normalizeId(item)).filter(Boolean))] return [...new Set(rawValues.map((item) => normalizeId(item)).filter(Boolean))]
} }
function normalizeExpenseItemNote(item = {}) {
const note = normalizeText(item.itemNote ?? item.item_note).replace(/[。;;]+$/, '')
if (!note || ['待补充', '待补充异常说明', '暂无', '无', 'null', 'undefined'].includes(note)) {
return ''
}
return note
}
function resolveExpenseItemLabel(item = {}, fallback = '相关明细') {
return normalizeText(item.desc)
|| normalizeText(item.itemReason)
|| normalizeText(item.detail)
|| normalizeText(item.name)
|| normalizeText(item.category)
|| fallback
}
function resolveRelatedExpenseExplanations(itemIds = [], expenseItems = []) {
const relatedIds = normalizeIdList(itemIds)
if (!relatedIds.length || !Array.isArray(expenseItems)) {
return []
}
return expenseItems
.filter((item) => relatedIds.includes(normalizeId(item?.id)))
.map((item) => {
const note = normalizeExpenseItemNote(item)
if (!note) {
return null
}
return {
itemId: normalizeId(item.id),
label: resolveExpenseItemLabel(item),
note,
text: `${resolveExpenseItemLabel(item)}${note}`
}
})
.filter(Boolean)
}
function isHotelExpenseItem(item = {}) {
const text = [
item.name,
item.category,
item.desc,
item.detail,
item.itemType,
item.item_type,
item.documentType,
item.document_type
].map((value) => normalizeText(value)).join(' ')
return /住宿|酒店|宾馆|hotel/.test(text)
}
function isTrafficExpenseItem(item = {}) {
const text = [
item.name,
item.category,
item.desc,
item.detail,
item.itemType,
item.item_type,
item.documentType,
item.document_type
].map((value) => normalizeText(value)).join(' ')
if (/补贴|系统自动计算/.test(text)) {
return false
}
return /交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi/.test(text)
}
function inferRelatedExpenseItemIdsByRiskText(flag = {}, risks = [], expenseItems = []) {
const text = [
cardLikeText(flag),
...uniqueTexts(risks)
].map((value) => normalizeText(value)).join(' ')
const items = Array.isArray(expenseItems) ? expenseItems : []
if (isHotelOverStandardRiskText(text) || /住宿|酒店|宾馆|hotel/.test(text)) {
return items.filter(isHotelExpenseItem).map((item) => normalizeId(item?.id)).filter(Boolean)
}
if (/交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi/.test(text)) {
return items.filter(isTrafficExpenseItem).map((item) => normalizeId(item?.id)).filter(Boolean)
}
return []
}
function resolveRelatedItemIdsForRisk({
explicitItemIds = [],
flag = {},
risks = [],
expenseItems = []
} = {}) {
const normalizedExplicitItemIds = normalizeIdList(explicitItemIds)
const routeRelatedItemIds = resolveRouteRelatedItemIdsForRisk({
flagItemIds: normalizedExplicitItemIds,
flag,
risks,
expenseItems
})
if (routeRelatedItemIds.length) {
return routeRelatedItemIds
}
if (normalizedExplicitItemIds.length) {
return normalizedExplicitItemIds
}
return inferRelatedExpenseItemIdsByRiskText(flag, risks, expenseItems)
}
function formatRelatedExpenseExplanations(explanations = [], limit = 3) {
const texts = uniqueTexts(explanations.map((item) => item.text))
if (!texts.length) {
return ''
}
const visible = texts.slice(0, limit).join('')
return `${visible}${texts.length > limit ? `${texts.length}` : ''}`
}
function resolveRiskTextWithExplanations(risk, explanations = []) {
const text = normalizeText(risk)
if (!text || !explanations.length) {
return text
}
if (/未说明|待说明|缺少说明|未识别到.*说明|请.*补充/.test(text)) {
const cleaned = text
.replace(/?但当前[^。;;]*?(?:未说明|待说明|缺少说明|未识别到[^。;;]*说明)[^。;;]*[。;;]?/g, '')
.replace(/当前[^。;;]*?(?:未说明|待说明|缺少说明|未识别到[^。;;]*说明)[^。;;]*[。;;]?/g, '')
.replace(/请[^。;;]*?(?:补充|写清楚)[^。;;]*[。;;]?/g, '')
.trim()
const base = cleaned.replace(/[,;。]+$/, '') || '该风险已命中系统规则'
return `${base},用户已在相关费用明细补充异常说明,需审核说明是否充分。`
}
return text
}
function resolveClaimRiskSuggestion(flag = {}, { risk = '', summary = '', relatedExplanations = [] } = {}) {
const explanationSummary = formatRelatedExpenseExplanations(relatedExplanations)
if (explanationSummary) {
return `用户已在费用明细补充异常说明:${explanationSummary}。请审核说明是否充分,并结合票据、行程和制度标准决定通过、退回或要求补充佐证。`
}
return resolveClaimRiskFallbackSuggestion(flag, { risk, summary })
}
function resolveItemRiskFlag(item, claimRiskFlags) { function resolveItemRiskFlag(item, claimRiskFlags) {
const itemId = normalizeId(item?.id) const itemId = normalizeId(item?.id)
if (!itemId || !Array.isArray(claimRiskFlags)) { if (!itemId || !Array.isArray(claimRiskFlags)) {
@@ -276,7 +472,7 @@ function resolveClaimRiskRuleBasis(flag = {}, { risk = '', summary = '', tone =
return uniqueTexts(basis.length ? basis : [`系统预审根据“${label || '单据风险'}”将该项列为${tone === 'high' ? '高风险' : '中风险'}`]) return uniqueTexts(basis.length ? basis : [`系统预审根据“${label || '单据风险'}”将该项列为${tone === 'high' ? '高风险' : '中风险'}`])
} }
function resolveClaimRiskSuggestion(flag = {}, { risk = '', summary = '' } = {}) { function resolveClaimRiskFallbackSuggestion(flag = {}, { risk = '', summary = '' } = {}) {
const explicitSuggestion = normalizeText(flag.suggestion) const explicitSuggestion = normalizeText(flag.suggestion)
if (explicitSuggestion) { if (explicitSuggestion) {
return explicitSuggestion return explicitSuggestion
@@ -370,6 +566,12 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name), normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name),
'附件风险' '附件风险'
) )
const relatedExplanations = resolveRelatedExpenseExplanations([normalizeId(item?.id)], [item])
const explanationSummary = formatRelatedExpenseExplanations(relatedExplanations)
const ruleBasis = uniqueTexts([
...(insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。']),
explanationSummary ? `用户已补充异常说明:${explanationSummary}` : ''
])
return withRiskTags({ return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`, id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
@@ -380,13 +582,24 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
tone, tone,
label: resolveRiskLevelLabel(tone), label: resolveRiskLevelLabel(tone),
title, title,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。', risk: resolveRiskTextWithExplanations(
normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
relatedExplanations
),
summary: normalizeText(analysis?.summary), summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'], ruleBasis,
suggestion: buildCardSuggestion(analysis, insight), suggestion: relatedExplanations.length
? resolveClaimRiskSuggestion({}, {
risk: normalizeText(point) || normalizeText(analysis?.summary),
summary: normalizeText(analysis?.summary),
relatedExplanations
})
: buildCardSuggestion(analysis, insight),
source: 'attachment_analysis', source: 'attachment_analysis',
itemType: normalizeText(item?.itemType), itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel), documentType: normalizeText(insight?.documentTypeLabel),
relatedExplanations,
relatedExplanationSummary: explanationSummary,
visibility_scope: 'submitter', visibility_scope: 'submitter',
actionability: 'fixable_by_submitter' actionability: 'fixable_by_submitter'
}) })
@@ -599,8 +812,8 @@ export function buildAttachmentRiskCards({
const risks = flagPoints.length const risks = flagPoints.length
? flagPoints ? flagPoints
: [primaryRisk || fallbackRisk].filter(Boolean) : [primaryRisk || fallbackRisk].filter(Boolean)
const relatedItemIds = resolveRouteRelatedItemIdsForRisk({ const relatedItemIds = resolveRelatedItemIdsForRisk({
flagItemIds, explicitItemIds: [flagItemId, ...flagItemIds],
flag, flag,
risks, risks,
expenseItems expenseItems
@@ -616,6 +829,12 @@ export function buildAttachmentRiskCards({
summary, summary,
tone tone
}) })
const relatedExplanations = resolveRelatedExpenseExplanations(relatedItemIds, expenseItems)
const explanationSummary = formatRelatedExpenseExplanations(relatedExplanations)
const mergedRuleBasis = uniqueTexts([
...ruleBasis,
explanationSummary ? `用户已补充异常说明:${explanationSummary}` : ''
])
return risks.map((risk, pointIndex) => withRiskTags({ return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`, id: `claim-risk-${index}-${pointIndex}`,
@@ -627,10 +846,12 @@ export function buildAttachmentRiskCards({
tone, tone,
label: resolveRiskLevelLabel(tone), label: resolveRiskLevelLabel(tone),
title, title,
risk, risk: resolveRiskTextWithExplanations(risk, relatedExplanations),
summary, summary,
ruleBasis, ruleBasis: mergedRuleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }), suggestion: resolveClaimRiskSuggestion(flag, { risk, summary, relatedExplanations }),
relatedExplanations,
relatedExplanationSummary: explanationSummary,
source, source,
risk_domain: flag.risk_domain || flag.riskDomain, risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope, visibility_scope: flag.visibility_scope || flag.visibilityScope,
@@ -702,7 +923,7 @@ export function buildAiAdviceViewModel({
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean) const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean) const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean)
const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean) const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean) const normalizedRiskCards = dedupeLowerSeverityRiskCards(riskCards.filter(Boolean))
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high') const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards) const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)

View File

@@ -30,22 +30,59 @@ export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = [])
} }
export function resolveExpenseItemForRiskCard(card, expenseItems = []) { export function resolveExpenseItemForRiskCard(card, expenseItems = []) {
const itemId = normalizeText(card?.itemId || card?.item_id) const itemIds = [
card?.itemId,
card?.item_id,
...(Array.isArray(card?.itemIds) ? card.itemIds : []),
...(Array.isArray(card?.item_ids) ? card.item_ids : [])
].map(normalizeText).filter(Boolean)
const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id) const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id)
const itemIndex = Number(card?.itemIndex || card?.item_index || 0) const itemIndex = Number(card?.itemIndex || card?.item_index || 0)
return expenseItems.find((item) => normalizeText(item.id) === itemId) return expenseItems.find((item) => itemIds.includes(normalizeText(item.id)))
|| expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId) || expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId)
|| (itemIndex > 0 ? expenseItems[itemIndex - 1] : null) || (itemIndex > 0 ? expenseItems[itemIndex - 1] : null)
|| null || null
} }
export function resolveExpenseItemsForRiskCard(card, expenseItems = []) {
const itemIds = [
card?.itemId,
card?.item_id,
...(Array.isArray(card?.itemIds) ? card.itemIds : []),
...(Array.isArray(card?.item_ids) ? card.item_ids : [])
].map(normalizeText).filter(Boolean)
const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id)
const itemIndex = Number(card?.itemIndex || card?.item_index || 0)
const matchedItems = []
const appendItem = (item) => {
if (!item || matchedItems.some((entry) => normalizeText(entry.id) === normalizeText(item.id))) {
return
}
matchedItems.push(item)
}
if (itemIds.length) {
expenseItems
.filter((item) => itemIds.includes(normalizeText(item.id)))
.forEach(appendItem)
}
if (matchedItems.length) {
return matchedItems
}
appendItem(expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId))
appendItem(itemIndex > 0 ? expenseItems[itemIndex - 1] : null)
return matchedItems
}
export function isRiskCardMissingExpenseNote(card, expenseItems = []) { export function isRiskCardMissingExpenseNote(card, expenseItems = []) {
const item = resolveExpenseItemForRiskCard(card, expenseItems) const items = resolveExpenseItemsForRiskCard(card, expenseItems)
if (!item) { if (!items.length) {
return true return true
} }
return !normalizeText(item.itemNote) return items.some((item) => !normalizeText(item.itemNote))
} }
export function hasStandardAdjustmentForItem(item, standardAdjustmentMap = new Map()) { export function hasStandardAdjustmentForItem(item, standardAdjustmentMap = new Map()) {

View File

@@ -50,7 +50,7 @@ test('direct approvers can return claims without receiving delete permissions',
assert.equal(canManageExpenseClaims(approverUser), false) assert.equal(canManageExpenseClaims(approverUser), false)
}) })
test('finance can return and final approve, but only executives can manage delete permissions', () => { test('finance can return and final approve, executives can manage claim visibility only', () => {
assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true) assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true)
assert.equal(canApproveLeaderExpenseClaims({ roleCodes: ['finance'] }), false) assert.equal(canApproveLeaderExpenseClaims({ roleCodes: ['finance'] }), false)
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), false) assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), false)

View File

@@ -68,6 +68,18 @@ test('budget ontology context maps dialog fields to ontology payload', () => {
assert.equal(context.budget_details[0].warning_threshold, '80%') assert.equal(context.budget_details[0].warning_threshold, '80%')
}) })
test('budget ontology includes approval review execution metrics', () => {
const fieldKeys = BUDGET_ONTOLOGY_FIELDS.map((field) => field.key)
assert.ok(fieldKeys.includes('claim_amount'))
assert.ok(fieldKeys.includes('claim_amount_ratio'))
assert.ok(fieldKeys.includes('usage_rate'))
assert.ok(fieldKeys.includes('after_usage_rate'))
assert.ok(fieldKeys.includes('remaining_budget_ratio'))
assert.ok(fieldKeys.includes('available_before_amount'))
assert.ok(fieldKeys.includes('over_budget_amount'))
})
test('budget expense type options expose real expense type codes', () => { test('budget expense type options expose real expense type codes', () => {
const optionCodes = BUDGET_EXPENSE_TYPE_OPTIONS.map((item) => item.value) const optionCodes = BUDGET_EXPENSE_TYPE_OPTIONS.map((item) => item.value)

View File

@@ -25,7 +25,7 @@ const requestsComposable = readFileSync(
'utf8' 'utf8'
) )
test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => { test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/) assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
assert.doesNotMatch(documentsCenterView, /<nav class="status-tabs document-state-tabs"/) assert.doesNotMatch(documentsCenterView, /<nav class="status-tabs document-state-tabs"/)
assert.match(documentsCenterView, /class="document-status-filter"[\s\S]*class="document-filter status-dropdown-filter"/) assert.match(documentsCenterView, /class="document-status-filter"[\s\S]*class="document-filter status-dropdown-filter"/)
@@ -35,6 +35,7 @@ test('documents center keeps only the top scope tabs and renders status as a dro
) )
assert.match(documentsCenterView, /v-for="option in statusFilterOptions"/) assert.match(documentsCenterView, /v-for="option in statusFilterOptions"/)
assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/) assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/)
assert.match(documentsCenterView, /aria-label="风险等级"/)
}) })
test('documents center top tabs start from all and show document category labels', () => { test('documents center top tabs start from all and show document category labels', () => {
@@ -104,7 +105,7 @@ test('documents center category tabs map to the intended row sources', () => {
test('documents center sorts every filtered scope by latest document time before pagination', () => { test('documents center sorts every filtered scope by latest document time before pagination', () => {
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/return sortDocumentRowsByLatestTime\(activeScopeRows\.value\.filter\(\(row\) => \{[\s\S]*matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange[\s\S]*\}\)\)/ /return sortDocumentRowsByLatestTime\(activeScopeRows\.value\.filter\(\(row\) => \{[\s\S]*matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange[\s\S]*\}\)\)/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
@@ -296,23 +297,23 @@ test('documents center switches filter conditions by category tab', () => {
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/) assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '单据状态'[\s\S]*showDocumentType: true/ /\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: true/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '申请状态'[\s\S]*showDocumentType: false/ /\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: false/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: \{[\s\S]*statusTitle: '报销状态'[\s\S]*showDocumentType: false/ /\[DOCUMENT_SCOPE_REIMBURSEMENT\]: \{[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs[\s\S]*showDocumentType: false/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_REVIEW\]: \{[\s\S]*sceneFallbackLabel: '审核场景'[\s\S]*statusTitle: '审核状态'[\s\S]*statusTabs: \['全部', '审批中', '待补充', '已完成'\]/ /\[DOCUMENT_SCOPE_REVIEW\]: \{[\s\S]*sceneFallbackLabel: '审核场景'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_ARCHIVE\]: \{[\s\S]*dateLabel: '归档时间'[\s\S]*statusTitle: '归档状态'[\s\S]*statusTabs: \['全部', '已付款', '已完成'\]/ /\[DOCUMENT_SCOPE_ARCHIVE\]: \{[\s\S]*dateLabel: '归档时间'[\s\S]*statusTitle: '风险等级'[\s\S]*statusTabs: riskLevelTabs/
) )
assert.match(documentsCenterView, /v-if="showDocumentTypeFilter" class="document-filter"/) assert.match(documentsCenterView, /v-if="showDocumentTypeFilter" class="document-filter"/)
assert.match(documentsCenterView, /:placeholder="activeFilterConfig\.searchPlaceholder"/) assert.match(documentsCenterView, /:placeholder="activeFilterConfig\.searchPlaceholder"/)
@@ -326,10 +327,11 @@ test('documents center switches filter conditions by category tab', () => {
assert.doesNotMatch(documentsCenterView, /pageSizeOpen/) assert.doesNotMatch(documentsCenterView, /pageSizeOpen/)
}) })
test('documents center status dropdown derives labels and closes after selection', () => { test('documents center risk dropdown derives labels and closes after selection', () => {
assert.match(documentsCenterView, /const riskLevelTabs = \['全部', '高风险', '中风险', '低风险', '无风险'\]/)
assert.match(documentsCenterView, /const statusFilterOptions = computed\(\(\) =>/) assert.match(documentsCenterView, /const statusFilterOptions = computed\(\(\) =>/)
assert.match(documentsCenterView, /activeFilterConfig\.value\.statusTabs\.map/) assert.match(documentsCenterView, /activeFilterConfig\.value\.statusTabs\.map/)
assert.match(documentsCenterView, /label: tab === '全部' \? '全部状态' : tab/) assert.match(documentsCenterView, /label: tab === '全部' \? '全部风险' : tab/)
assert.match(documentsCenterView, /const statusFilterLabel = computed\(\(\) =>/) assert.match(documentsCenterView, /const statusFilterLabel = computed\(\(\) =>/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
@@ -337,6 +339,18 @@ test('documents center status dropdown derives labels and closes after selection
) )
}) })
test('documents center list renders risk level tags instead of status tags', () => {
assert.match(documentsCenterView, /<th>风险等级<\/th>/)
assert.match(documentsCenterView, /<td data-label="风险等级">[\s\S]*class="risk-level-tags"[\s\S]*v-for="tag in row\.riskTags"/)
assert.match(documentsCenterView, /import \{ countClaimRisks, resolveArchiveRiskTone \} from '..\/utils\/archiveCenterListFilters\.js'/)
assert.match(documentsCenterView, /function buildDocumentRiskMeta\(row\) \{[\s\S]*countClaimRisks\(riskFlags, riskSummary\)/)
assert.match(documentsCenterView, /riskTone: riskMeta\.tone,[\s\S]*riskLabel: riskMeta\.label,[\s\S]*riskCount: riskMeta\.count,[\s\S]*riskTags: riskMeta\.tags/)
assert.match(documentsCenterView, /function matchesRiskLevelTab\(row, tab\) \{[\s\S]*tab === '高风险'[\s\S]*row\.riskTone === 'high'/)
assert.match(documentListSharedStyles, /\.risk-level-tags\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentListSharedStyles, /\.risk-level-tag\.high\s*\{[\s\S]*background:\s*#fef2f2;/)
assert.doesNotMatch(documentsCenterView, /<td data-label="状态"><span class="status-tag"/)
})
test('documents center status dropdown uses compact filter styling', () => { test('documents center status dropdown uses compact filter styling', () => {
assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/) assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/)
assert.match(documentListSharedStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(documentListSharedStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)

View File

@@ -19,6 +19,10 @@ const approvalDialog = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)),
'utf8' 'utf8'
) )
const confirmDialog = readFileSync(
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
'utf8'
)
const budgetAnalysisComponent = readFileSync( const budgetAnalysisComponent = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestBudgetAnalysis.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/TravelRequestBudgetAnalysis.vue', import.meta.url)),
'utf8' 'utf8'
@@ -27,6 +31,10 @@ const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)), fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8' 'utf8'
) )
const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
function extractFunction(source, name) { function extractFunction(source, name) {
const signatureIndex = source.indexOf(`function ${name}(`) const signatureIndex = source.indexOf(`function ${name}(`)
@@ -55,6 +63,9 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailScript, /approvalMode:/) assert.match(detailScript, /approvalMode:/)
assert.match(detailScript, /const leaderOpinion = ref\(''\)/) assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/) assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const approvalRiskConfirmed = ref\(false\)/)
assert.match(detailScript, /const approvalRiskConfirmItems = computed/)
assert.match(detailScript, /const approvalRiskConfirmRequired = computed/)
assert.match(detailScript, /const canApproveRequest = computed/) assert.match(detailScript, /const canApproveRequest = computed/)
assert.match(detailScript, /canApproveLeaderExpenseClaims/) assert.match(detailScript, /canApproveLeaderExpenseClaims/)
assert.match(detailScript, /canApproveBudgetExpenseApplications/) assert.match(detailScript, /canApproveBudgetExpenseApplications/)
@@ -73,8 +84,10 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.doesNotMatch(detailScript, /approvalNextStage/) assert.doesNotMatch(detailScript, /approvalNextStage/)
assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/) assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/)
assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/) assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/)
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => false\)/) assert.match(detailScript, /const budgetApprovalOpinionRequired = computed/)
assert.match(detailScript, /approvalOpinionTitle = computed\(\(\) => \(isFinanceApprovalStage\.value \? '财务意见' : '附加意见'\)\)/) assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => budgetApprovalOpinionRequired\.value\)/)
assert.match(detailScript, /hasBudgetApprovalWarning\(request\.value\)/)
assert.match(detailScript, /return '预算审批意见'/)
assert.match(detailScript, /buildLeaderApprovalEvents/) assert.match(detailScript, /buildLeaderApprovalEvents/)
assert.match(detailScript, /buildLeaderApprovalInfo/) assert.match(detailScript, /buildLeaderApprovalInfo/)
assert.match(detailScript, /const leaderApprovalEvents = computed/) assert.match(detailScript, /const leaderApprovalEvents = computed/)
@@ -97,6 +110,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailScript, /resolveGeneratedDraftClaimNo/) assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
assert.match(detailScript, /resolveApproveErrorMessage/) assert.match(detailScript, /resolveApproveErrorMessage/)
assert.match(detailScript, /当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。/) assert.match(detailScript, /当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。/)
assert.match(detailScript, /预算已超过警戒值,请填写预算审批意见后再通过。/)
assert.match(detailScript, /approveActionLabel/) assert.match(detailScript, /approveActionLabel/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/) assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/)
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/) assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
@@ -128,6 +142,9 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailTemplate, /:description="approvalConfirmDescription"/) assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/) assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
assert.match(detailTemplate, /:busy-text="approveBusyText"/) assert.match(detailTemplate, /:busy-text="approveBusyText"/)
assert.match(detailTemplate, /:risk-confirm-required="approvalRiskConfirmRequired"/)
assert.match(detailTemplate, /v-model:risk-confirmed="approvalRiskConfirmed"/)
assert.match(detailTemplate, /:risk-confirm-items="approvalRiskConfirmItems"/)
assert.doesNotMatch(detailTemplate, /:next-stage="approvalNextStage"/) assert.doesNotMatch(detailTemplate, /:next-stage="approvalNextStage"/)
assert.doesNotMatch(approvalDialog, /submit-confirm-summary/) assert.doesNotMatch(approvalDialog, /submit-confirm-summary/)
assert.doesNotMatch(approvalDialog, /单据编号/) assert.doesNotMatch(approvalDialog, /单据编号/)
@@ -144,10 +161,17 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest') const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/) assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/) assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/)
assert.match(handleApproveRequest, /approvalRiskConfirmed\.value = !approvalRiskConfirmRequired\.value/)
assert.match(confirmApproveRequest, /approveExpenseClaim/) assert.match(confirmApproveRequest, /approveExpenseClaim/)
assert.match(confirmApproveRequest, /emit\('request-updated', \{ claimId: request\.value\.claimId \}\)[\s\S]*emit\('backToRequests'\)/) assert.match(confirmApproveRequest, /approvalRiskConfirmRequired\.value && !approvalRiskConfirmed\.value/)
assert.doesNotMatch(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/) assert.match(confirmApproveRequest, /请先确认已核对风险说明和佐证材料,再继续审批。/)
assert.match(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
assert.match(confirmApproveRequest, /预算已超过警戒值,请填写预算审批意见后再通过。/)
assert.match(confirmApproveRequest, /emit\('request-updated', \{[\s\S]*claimId: request\.value\.claimId,[\s\S]*claim: responsePayload[\s\S]*\}\)[\s\S]*emit\('backToRequests'\)/)
assert.doesNotMatch(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/) assert.doesNotMatch(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
assert.match(appShellScript, /async function handleRequestUpdated\(payload = \{\}\)/)
assert.match(appShellScript, /const mappedRequest = mapExpenseClaimToRequest\(payload\.claim\)/)
assert.match(appShellScript, /upsertRequestSnapshot\(mappedRequest\)/)
assert.match(approvalDialog, /<textarea/) assert.match(approvalDialog, /<textarea/)
assert.match(approvalDialog, /update:opinion/) assert.match(approvalDialog, /update:opinion/)
@@ -155,6 +179,13 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(approvalDialog, /opinionHint/) assert.match(approvalDialog, /opinionHint/)
assert.match(approvalDialog, /opinionRequired/) assert.match(approvalDialog, /opinionRequired/)
assert.match(approvalDialog, /\{\{ currentOpinion\.length \}\}\/500/) assert.match(approvalDialog, /\{\{ currentOpinion\.length \}\}\/500/)
assert.match(approvalDialog, /风险说明确认/)
assert.match(approvalDialog, /riskConfirmRequired/)
assert.match(approvalDialog, /update:risk-confirmed/)
assert.match(approvalDialog, /:confirm-disabled="confirmDisabled"/)
assert.match(approvalDialog, /props\.opinionRequired && !currentOpinion\.value\.trim\(\)/)
assert.match(confirmDialog, /confirmDisabled:\s*\{\s*type:\s*Boolean,\s*default:\s*false\s*\}/)
assert.match(confirmDialog, /:disabled="busy \|\| confirmDisabled"/)
assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/) assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/) assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/)

View File

@@ -15,6 +15,7 @@ import {
resolveRiskTagTone resolveRiskTagTone
} from '../src/views/scripts/travelRequestDetailInsights.js' } from '../src/views/scripts/travelRequestDetailInsights.js'
import { import {
buildExpenseDraftIssues,
buildExpenseItemViewModel, buildExpenseItemViewModel,
buildDraftBlockingIssues, buildDraftBlockingIssues,
rebuildExpenseItems, rebuildExpenseItems,
@@ -27,7 +28,8 @@ import {
} from '../src/views/scripts/travelRequestDetailAdviceModel.js' } from '../src/views/scripts/travelRequestDetailAdviceModel.js'
import { import {
buildStandardAdjustmentPayload, buildStandardAdjustmentPayload,
filterSubmitterResolvedRiskCards filterSubmitterResolvedRiskCards,
isRiskCardMissingExpenseNote
} from '../src/views/scripts/travelRequestDetailStandardAdjustment.js' } from '../src/views/scripts/travelRequestDetailStandardAdjustment.js'
const detailViewTemplate = readFileSync( const detailViewTemplate = readFileSync(
@@ -70,6 +72,10 @@ const stageRiskAdviceCard = readFileSync(
fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.vue', import.meta.url)),
'utf8' 'utf8'
) )
const stageRiskAdviceStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/stage-risk-advice-card.css', import.meta.url)),
'utf8'
)
const attachmentMeta = { const attachmentMeta = {
file_name: 'taxi-invoice.pdf', file_name: 'taxi-invoice.pdf',
@@ -291,20 +297,56 @@ test('risk cards carry structured business stage for approval advice filtering',
assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), []) assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), [])
}) })
test('stage risk advice card exposes direct reviewer action suggestion', () => { test('stage risk advice card focuses on document risks without profile or budget boards', () => {
assert.match(stageRiskAdviceCard, /class="employee-risk-action"/) assert.match(stageRiskAdviceCard, /employee-risk-decision-panel/)
assert.match(stageRiskAdviceCard, /综合审核结论/)
assert.match(stageRiskAdviceCard, /建议结论/)
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/) assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
assert.match(stageRiskAdviceCard, /compactEvidenceItems/) assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
assert.match(stageRiskAdviceCard, /compactAdviceItems/) assert.match(stageRiskAdviceCard, /stageBasisTitle/)
assert.ok( assert.match(stageRiskAdviceCard, /stageBasisHint/)
stageRiskAdviceCard.indexOf('class="employee-risk-advice-list"') assert.match(stageRiskAdviceCard, /employee-risk-profile-section/)
< stageRiskAdviceCard.indexOf('class="employee-risk-action"') assert.match(stageRiskAdviceCard, /employee-risk-profile-list/)
) assert.match(stageRiskAdviceCard, /classifyReimbursementRiskCards/)
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/) assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
assert.match(stageRiskAdviceCard, /\.employee-risk-ai-note \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/) assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(220px, 32%\);/)
assert.match(stageRiskAdviceCard, /\.employee-risk-action \{[\s\S]*align-items: center;[\s\S]*justify-content: center;[\s\S]*text-align: center;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/) assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
assert.match(stageRiskAdviceCard, /riskExplanationItems/)
assert.match(stageRiskAdviceCard, /请核对已补充说明是否覆盖风险点/)
assert.match(stageRiskAdviceCard, /已补充异常说明/)
assert.match(stageRiskAdviceCard, /可按权限继续审批/) assert.match(stageRiskAdviceCard, /可按权限继续审批/)
assert.match(stageRiskAdviceCard, /申请单风险依据/)
assert.match(stageRiskAdviceCard, /报销单风险依据/)
assert.doesNotMatch(stageRiskAdviceCard, /人员行为画像/)
assert.doesNotMatch(stageRiskAdviceCard, /部门预算执行/)
assert.doesNotMatch(stageRiskAdviceCard, /title: '说明与佐证'/)
assert.doesNotMatch(stageRiskAdviceCard, /fetchExpenseClaimBudgetAnalysis/)
assert.doesNotMatch(stageRiskAdviceCard, /reviewDimensionCards/)
assert.doesNotMatch(stageRiskAdviceCard, /documentRiskMetrics/)
assert.doesNotMatch(stageRiskAdviceCard, /profileAdviceItems/)
assert.doesNotMatch(stageRiskAdviceCard, /profileContextItems/)
assert.doesNotMatch(stageRiskAdviceCard, /画像风险/)
assert.doesNotMatch(stageRiskAdviceCard, /退单\/补正/)
assert.doesNotMatch(stageRiskAdviceCard, /材料质量/)
assert.doesNotMatch(stageRiskAdviceCard, /申请人:/)
assert.doesNotMatch(stageRiskAdviceCard, /部门\/岗位:/)
assert.doesNotMatch(stageRiskAdviceCard, /budgetContextMetrics/)
assert.doesNotMatch(stageRiskAdviceCard, /BUDGET_FIELD_KEYS/)
assert.doesNotMatch(stageRiskAdviceCard, /预算池/)
assert.doesNotMatch(stageRiskAdviceCard, /未匹配预算池/)
assert.doesNotMatch(stageRiskAdviceCard, /科目未管控/)
assert.doesNotMatch(stageRiskAdviceCard, /占用比例/)
assert.doesNotMatch(stageRiskAdviceCard, /剩余比例/)
assert.doesNotMatch(stageRiskAdviceCard, /超预算风险/)
assert.doesNotMatch(stageRiskAdviceCard, /explanationContextMetrics/)
assert.doesNotMatch(stageRiskAdviceCard, /employee-risk-context-grid/)
assert.doesNotMatch(stageRiskAdviceCard, /class="employee-risk-action"/)
assert.doesNotMatch(stageRiskAdviceStyles, /employee-risk-ai-note/)
}) })
test('AI advice ignores approval opinions and flow logs as risks', () => { test('AI advice ignores approval opinions and flow logs as risks', () => {
@@ -489,6 +531,42 @@ test('AI advice view model sorts and displays every risk card', () => {
assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3']) assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3'])
}) })
test('AI advice hides lower severity duplicate route explanation risks', () => {
const advice = buildAiAdviceViewModel({
riskCards: [
{
id: 'route-high',
tone: 'high',
label: '高风险',
title: '多城市行程待说明',
risk: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。',
itemIds: ['train-transfer', 'train-transfer-return']
},
{
id: 'route-medium',
tone: 'medium',
label: '中风险',
title: '多城市行程缺少说明中风险',
risk: '本次报销识别到多城市行程(上海、武汉、深圳),但事由中未说明中转、多地拜访或改签原因。',
itemIds: ['train-transfer', 'train-transfer-return']
},
{
id: 'hotel-high',
tone: 'high',
label: '高风险',
title: '住宿金额超出报销标准',
risk: '住宿金额超出当前职级报销标准。',
itemIds: ['hotel-item']
}
]
})
const riskSection = advice.sections.find((section) => section.kind === 'risk')
assert.deepEqual(advice.riskCards.map((item) => item.id), ['route-high', 'hotel-high'])
assert.deepEqual(riskSection.items.map((item) => item.id), ['route-high', 'hotel-high'])
assert.equal(riskSection.totalCount, 2)
})
test('AI advice view model omits empty sections', () => { test('AI advice view model omits empty sections', () => {
const readyAdvice = buildAiAdviceViewModel({ const readyAdvice = buildAiAdviceViewModel({
completionItems: [], completionItems: [],
@@ -640,6 +718,84 @@ test('route-level risk cards keep related item ids for every affected expense ro
assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/) assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/)
}) })
test('claim risk cards expose related expense explanations to reviewers', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'hotel-row',
name: '住宿票',
desc: '上海喜来登酒店',
itemNote: '时间紧,没有合适的酒店,只能住宿超过金额的酒店。'
},
{
id: 'route-extra-out',
name: '火车票',
desc: '上海-深圳',
itemNote: '中间去深圳,公司要求。'
},
{
id: 'route-extra-back',
name: '火车票',
desc: '深圳-上海',
itemNote: '中间去深圳,公司要求。'
}
],
claimRiskFlags: [
{
source: 'submission_review',
severity: 'high',
label: '多城市行程待说明',
message: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。',
item_ids: ['route-extra-out', 'route-extra-back']
}
]
})
assert.equal(riskCards.length, 1)
assert.deepEqual(riskCards[0].itemIds, ['route-extra-out', 'route-extra-back'])
assert.match(riskCards[0].risk, /用户已在相关费用明细补充异常说明/)
assert.doesNotMatch(riskCards[0].risk, /未说明/)
assert.match(riskCards[0].suggestion, /用户已在费用明细补充异常说明/)
assert.match(riskCards[0].suggestion, /上海-深圳:中间去深圳,公司要求/)
assert.match(riskCards[0].relatedExplanationSummary, /深圳-上海:中间去深圳,公司要求/)
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('用户已补充异常说明')))
})
test('claim risk cards infer hotel explanations when risk flag has no item ids', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'hotel-row',
name: '住宿票',
desc: '上海喜来登酒店',
itemType: 'hotel_ticket',
itemNote: '时间紧,没有合适的酒店,只能住宿超过金额的酒店。'
},
{
id: 'route-row',
name: '火车票',
desc: '上海-深圳',
itemType: 'train_ticket',
itemNote: '中间去深圳,公司要求。'
}
],
claimRiskFlags: [
{
source: 'submission_review',
severity: 'high',
label: '住宿金额超出报销标准',
message: '住宿标准P5在上海的住宿标准为 250.00 元/晚,票据识别金额 1086.00 元 / 3 晚,约 362.00 元/晚,超出 112.00 元/晚。'
}
]
})
assert.equal(riskCards.length, 1)
assert.deepEqual(riskCards[0].itemIds, ['hotel-row'])
assert.match(riskCards[0].relatedExplanationSummary, /上海喜来登酒店:时间紧,没有合适的酒店/)
assert.doesNotMatch(riskCards[0].relatedExplanationSummary, /上海-深圳/)
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('用户已补充异常说明')))
})
test('legacy route-level risk cards infer affected travel rows when backend has no item ids', () => { test('legacy route-level risk cards infer affected travel rows when backend has no item ids', () => {
const riskCards = buildAttachmentRiskCards({ const riskCards = buildAttachmentRiskCards({
expenseItems: [ expenseItems: [
@@ -853,6 +1009,45 @@ test('ticket item types and system allowance row are visible but read only', ()
assert.match(detailViewScript, /系统自动计算的补贴行不能删除/) assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
}) })
test('expense item rebuild hides empty placeholders but keeps generated allowance row', () => {
const items = rebuildExpenseItems(
[
{
id: 'hotel-uploaded',
itemType: 'hotel_ticket',
itemDate: '2026-02-20',
itemReason: '上海喜来登酒店',
itemLocation: '上海',
itemAmount: 1086,
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
},
{
id: 'empty-travel-placeholder',
itemType: 'travel',
itemDate: '2026-02-23',
itemReason: '',
itemLocation: '',
itemAmount: 0,
invoiceId: ''
},
{
id: 'allowance',
itemType: 'travel_allowance',
itemDate: '2026-02-23',
itemReason: '系统自动计算出差补贴上海4天100.00元/天',
itemLocation: '直辖市/特区',
itemAmount: 400,
invoiceId: ''
}
],
{ typeCode: 'travel', detailVariant: 'travel' }
)
assert.deepEqual(items.map((item) => item.id), ['hotel-uploaded', 'allowance'])
assert.equal(items[1].isSystemGenerated, true)
assert.equal(items[1].attachmentStatus, '无需附件')
})
test('travel item date caption distinguishes departure return and trip events', () => { test('travel item date caption distinguishes departure return and trip events', () => {
assert.match(detailViewTemplate, /<span>\{\{ item\.dayLabel \}\}<\/span>/) assert.match(detailViewTemplate, /<span>\{\{ item\.dayLabel \}\}<\/span>/)
assert.match(detailViewScript, /const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket'\]\)/) assert.match(detailViewScript, /const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket'\]\)/)
@@ -1114,6 +1309,26 @@ test('standard adjustment resolves submitter risk prompt only after accepted whi
) )
}) })
test('multi item risk is not missing explanation when every related row has note', () => {
const card = {
id: 'risk-multi-city',
itemIds: ['route-extra-out', 'route-extra-back'],
tone: 'high',
risk: '多城市行程待说明。'
}
const explainedItems = [
{ id: 'route-extra-out', itemNote: '中间去深圳,项目现场要求。' },
{ id: 'route-extra-back', itemNote: '从深圳返回上海继续支撑部署。' }
]
const partlyMissingItems = [
{ id: 'route-extra-out', itemNote: '中间去深圳,项目现场要求。' },
{ id: 'route-extra-back', itemNote: '' }
]
assert.equal(isRiskCardMissingExpenseNote(card, explainedItems), false)
assert.equal(isRiskCardMissingExpenseNote(card, partlyMissingItems), true)
})
test('expense item upload remains limited to one receipt per detail row', () => { test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/) assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch( assert.doesNotMatch(
@@ -1376,6 +1591,100 @@ test('draft submit validation uses expense detail date and amount when claim sum
assert.ok(!issues.some((issue) => issue.includes('报销事由未完善'))) assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
}) })
test('draft submit validation does not hard block uploaded receipt rows with OCR gaps', () => {
const issues = buildDraftBlockingIssues(
{
profileName: '张三',
typeLabel: '住宿费',
typeCode: 'hotel',
reason: '上海出差住宿',
location: '上海',
occurredDisplay: '2026-06-01',
amountValue: 1086
},
[
buildExpenseItemViewModel(
{
id: 'hotel-uploaded',
itemType: 'hotel_ticket',
itemReason: '',
itemDate: '',
itemAmount: 0,
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
},
0,
{ typeCode: 'hotel', detailVariant: 'travel' }
)
]
)
assert.ok(!issues.some((issue) => issue.includes('缺少日期')))
assert.ok(!issues.some((issue) => issue.includes('缺少说明')))
assert.ok(!issues.some((issue) => issue.includes('缺少金额')))
assert.ok(!issues.some((issue) => issue.includes('缺少票据标识')))
})
test('draft submit validation ignores trailing placeholder detail rows', () => {
const issues = buildDraftBlockingIssues(
{
profileName: '张三',
typeLabel: '差旅费',
typeCode: 'travel',
reason: '上海出差',
location: '上海',
occurredDisplay: '2026-02-20 至 2026-02-23',
amountValue: 1086
},
[
buildExpenseItemViewModel(
{
id: 'hotel-uploaded',
itemType: 'hotel_ticket',
itemReason: '上海喜来登酒店',
itemLocation: '上海',
itemDate: '2026-02-20',
itemAmount: 1086,
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
},
0,
{ typeCode: 'travel', detailVariant: 'travel' }
),
buildExpenseItemViewModel(
{
id: 'placeholder-6',
itemType: 'hotel_ticket',
itemDate: '2026-02-23',
itemReason: '',
itemLocation: '',
itemAmount: 0,
invoiceId: ''
},
1,
{ typeCode: 'travel', detailVariant: 'travel' }
)
]
)
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少说明')))
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少地点')))
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少金额')))
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少票据标识')))
})
test('draft submit validation does not require receipt fields for generated allowance rows', () => {
const issues = buildExpenseDraftIssues({
id: 'allowance',
itemType: 'travel_allowance',
itemDate: '2026-02-23',
itemReason: '',
itemLocation: '',
itemAmount: 0,
invoiceId: ''
})
assert.deepEqual(issues, [])
})
test('returned application submit validation does not require expense detail rows', () => { test('returned application submit validation does not require expense detail rows', () => {
const issues = buildDraftBlockingIssues( const issues = buildDraftBlockingIssues(
{ {

View File

@@ -48,6 +48,8 @@ function extractFunction(source, name) {
test('detail submit opens a confirmation dialog before calling submit API', () => { test('detail submit opens a confirmation dialog before calling submit API', () => {
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/) assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
assert.match(detailViewTemplate, /:secondary-text="submitConfirmSecondaryText"/)
assert.match(detailViewTemplate, /@secondary="confirmStandardAdjustment"/)
assert.match(detailViewTemplate, /:open="submitConfirmDialogOpen"[\s\S]*size="review"/) assert.match(detailViewTemplate, /:open="submitConfirmDialogOpen"[\s\S]*size="review"/)
assert.match(detailViewTemplate, /cancel-text="返回核对"/) assert.match(detailViewTemplate, /cancel-text="返回核对"/)
assert.match(detailViewTemplate, /@click="handleSubmit"/) assert.match(detailViewTemplate, /@click="handleSubmit"/)
@@ -56,6 +58,7 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/) assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/)
assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/) assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
assert.match(detailViewScript, /const submitActionLabel = computed/) assert.match(detailViewScript, /const submitActionLabel = computed/)
assert.match(detailViewScript, /const submitConfirmSecondaryText = computed/)
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/) assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = false/) assert.match(detailViewScript, /submitConfirmDialogOpen\.value = false/)
assert.match(detailViewScript, /submitConfirmDialogOpen,/) assert.match(detailViewScript, /submitConfirmDialogOpen,/)
@@ -78,23 +81,38 @@ test('detail submit warns on missing risk explanation and supports standard adju
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/) assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/) assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/)
assert.match(detailViewTemplate, /异常说明/) assert.match(detailViewTemplate, /异常说明/)
assert.match(detailViewTemplate, /按职级标准重算/) assert.match(detailViewTemplate, /:title="riskOverrideDialogTitle"/)
assert.match(detailViewTemplate, /:description="riskOverrideDialogDescription"/)
assert.match(detailViewTemplate, /:confirm-text="riskOverrideConfirmText"/)
assert.match(detailViewTemplate, /@confirm="confirmRiskOverrideDialog"/)
assert.match(detailViewTemplate, /class="risk-override-card-shell"/)
assert.match(detailViewTemplate, /class="risk-override-side-nav risk-override-side-nav--previous"/)
assert.match(detailViewTemplate, /class="risk-override-side-nav risk-override-side-nav--next"/)
assert.match(detailViewTemplate, /class="risk-override-guidance"/) assert.match(detailViewTemplate, /class="risk-override-guidance"/)
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/) assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
assert.match(detailViewTemplate, /goToNextSubmitRisk/) assert.match(detailViewTemplate, /goToNextSubmitRisk/)
assert.doesNotMatch(detailViewTemplate, /class="risk-override-nav"/)
assert.doesNotMatch(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/) assert.doesNotMatch(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
assert.doesNotMatch(detailViewTemplate, /risk-override-save-btn/) assert.doesNotMatch(detailViewTemplate, /risk-override-save-btn/)
assert.doesNotMatch(detailViewTemplate, /confirmRiskOverrideReasons/) assert.doesNotMatch(detailViewTemplate, /confirmRiskOverrideReasons/)
assert.match(detailViewScript, /const submitRiskWarnings = computed/) assert.match(detailViewScript, /const submitRiskWarnings = computed/)
assert.match(detailViewScript, /const submitExplainedRiskWarnings = computed/)
assert.match(detailViewScript, /const submitRiskReviewWarnings = computed/)
assert.match(detailViewScript, /const hasMissingSubmitRiskWarnings = computed/)
assert.match(detailViewScript, /const riskOverrideConfirmText = computed\(\(\) =>[\s\S]*确认说明/)
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit') const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest') const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.match(handleSubmit, /submitRiskWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/) assert.match(handleSubmit, /submitRiskReviewWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/)
assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/) assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/)
assert.doesNotMatch(detailViewScript, /riskOverrideReasons/) assert.doesNotMatch(detailViewScript, /riskOverrideReasons/)
assert.doesNotMatch(detailViewScript, /function confirmRiskOverrideReasons\(\)/) assert.doesNotMatch(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
assert.match(detailViewScript, /function confirmRiskOverrideDialog\(\)/)
assert.match(detailViewScript, /function confirmRiskExplanation\(\)/)
assert.match(detailViewScript, /function confirmStandardAdjustment\(\)/) assert.match(detailViewScript, /function confirmStandardAdjustment\(\)/)
assert.match(detailViewTemplate, /v-if="standardAdjustmentBusy" class="expense-recognition-banner standard-adjustment-banner"/) assert.match(detailViewTemplate, /v-if="standardAdjustmentBusy" class="expense-recognition-banner standard-adjustment-banner"/)
assert.match(detailViewScript, /const standardAdjustmentBusy = ref\(false\)/) assert.match(detailViewScript, /const standardAdjustmentBusy = ref\(false\)/)
const confirmRiskExplanation = extractFunction(detailViewScript, 'confirmRiskExplanation')
assert.match(confirmRiskExplanation, /riskOverrideDialogOpen\.value = false[\s\S]*submitConfirmDialogOpen\.value = true/)
const confirmStandardAdjustment = extractFunction(detailViewScript, 'confirmStandardAdjustment') const confirmStandardAdjustment = extractFunction(detailViewScript, 'confirmStandardAdjustment')
assert.match(confirmStandardAdjustment, /const claimId = String\(request\.value\?\.claimId/) assert.match(confirmStandardAdjustment, /const claimId = String\(request\.value\?\.claimId/)
assert.match(confirmStandardAdjustment, /riskOverrideDialogOpen\.value = false[\s\S]*standardAdjustmentBusy\.value = true[\s\S]*void runStandardAdjustmentRecalculation\(claimId, taskSeq\)/) assert.match(confirmStandardAdjustment, /riskOverrideDialogOpen\.value = false[\s\S]*standardAdjustmentBusy\.value = true[\s\S]*void runStandardAdjustmentRecalculation\(claimId, taskSeq\)/)
@@ -103,6 +121,7 @@ test('detail submit warns on missing risk explanation and supports standard adju
const runStandardAdjustmentRecalculation = extractFunction(detailViewScript, 'runStandardAdjustmentRecalculation') const runStandardAdjustmentRecalculation = extractFunction(detailViewScript, 'runStandardAdjustmentRecalculation')
assert.match(runStandardAdjustmentRecalculation, /acceptExpenseClaimStandardAdjustment\(claimId, payload\)/) assert.match(runStandardAdjustmentRecalculation, /acceptExpenseClaimStandardAdjustment\(claimId, payload\)/)
assert.doesNotMatch(runStandardAdjustmentRecalculation, /submitConfirmDialogOpen\.value = true/) assert.doesNotMatch(runStandardAdjustmentRecalculation, /submitConfirmDialogOpen\.value = true/)
assert.match(detailViewScript, /buildStandardAdjustmentPayloadModel\(\{[\s\S]*warnings:\s*submitRiskCards\.value/)
const actionBusyStart = detailViewScript.indexOf('const actionBusy = computed') const actionBusyStart = detailViewScript.indexOf('const actionBusy = computed')
const actionBusyEnd = detailViewScript.indexOf('const profile = computed', actionBusyStart) const actionBusyEnd = detailViewScript.indexOf('const profile = computed', actionBusyStart)
assert.ok(actionBusyStart > -1 && actionBusyEnd > actionBusyStart) assert.ok(actionBusyStart > -1 && actionBusyEnd > actionBusyStart)
@@ -126,20 +145,15 @@ test('detail header and fallback progress use reimbursement wording', () => {
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/) assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
}) })
test('archived detail delete action is gated by admin-only permission', () => { test('detail delete action is gated by admin-only permission', () => {
assert.match(detailViewScript, /canDeleteArchivedExpenseClaims/) assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => isPlatformAdminUser\(currentUser\.value\)\)/)
assert.match(detailViewScript, /isArchivedRequestView/)
assert.match(detailViewScript, /if \(isArchivedRequest\.value\) {\s*return canDeleteArchivedExpenseClaims\(currentUser\.value\)/)
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/) assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/) assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
}) })
test('editable detail delete action is limited to applicant or claim manager', () => { test('detail delete action does not allow applicant or claim manager fallback', () => {
assert.match(detailViewScript, /const isCurrentApplicant = computed/) assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*isCurrentApplicant[\s\S]*\}\)/)
assert.match(detailViewScript, /isPlatformAdminUser/) assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*canManageCurrentClaim[\s\S]*\}\)/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\) \|\| \(isEditableRequest\.value && isCurrentApplicant\.value\)\s*}/)
assert.match(detailViewScript, /if \(canManageCurrentClaim\.value\) {\s*return true\s*}/)
assert.match(detailViewScript, /return isEditableRequest\.value && isCurrentApplicant\.value/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/) assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/) assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
}) })

View File

@@ -415,7 +415,7 @@ function normalizeState(env) {
}, },
web: { web: {
host: env.WEB_HOST || '0.0.0.0', host: env.WEB_HOST || '0.0.0.0',
port: Number(env.WEB_PORT || 5173) port: Number(env.WEB_PORT || 5273)
}, },
server: { server: {
host: env.SERVER_HOST || '0.0.0.0', host: env.SERVER_HOST || '0.0.0.0',
@@ -488,7 +488,7 @@ function resolveRuntimePayload(payload, currentEnv) {
return { return {
...payload, ...payload,
web_host: webHost, web_host: webHost,
web_port: Number(payload.web_port || currentEnv.WEB_PORT || 5173), web_port: Number(payload.web_port || currentEnv.WEB_PORT || 5273),
server_host: server_host:
normalizedWebHost && normalizedWebHost &&
normalizedWebHost !== '127.0.0.1' && normalizedWebHost !== '127.0.0.1' &&

View File

@@ -70,7 +70,7 @@ if [ "${X_FINANCIAL_FORCE_SETUP:-false}" = "true" ]; then
fi fi
WEB_HOST="${WEB_HOST:-0.0.0.0}" WEB_HOST="${WEB_HOST:-0.0.0.0}"
WEB_PORT="${WEB_PORT:-5173}" WEB_PORT="${WEB_PORT:-5273}"
export VITE_SETUP_COMPLETED="${SETUP_COMPLETED:-false}" export VITE_SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
export VITE_COMPANY_NAME="${COMPANY_NAME:-}" export VITE_COMPANY_NAME="${COMPANY_NAME:-}"