Compare commits
7 Commits
470f343b29
...
b8915a29c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8915a29c0 | ||
|
|
4199feb681 | ||
|
|
0fac8b615f | ||
|
|
a3e5295915 | ||
|
|
1f4681f486 | ||
|
|
09a66c72cb | ||
|
|
0d525fa64c |
6
.env
@@ -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"]'
|
||||||
|
|||||||
@@ -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
@@ -8,6 +8,7 @@ web/.vite/
|
|||||||
.omx/
|
.omx/
|
||||||
.claude/
|
.claude/
|
||||||
.codex/
|
.codex/
|
||||||
|
.codex-temp/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 [])
|
||||||
|
|||||||
147
server/src/app/services/expense_claim_risk_flags.py
Normal 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
|
||||||
@@ -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 '审批人'} 审批。"
|
||||||
|
|||||||
101
server/src/app/services/expense_claim_workflow_repair.py
Normal 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()
|
||||||
@@ -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("已归档单据不能删除,只有高级管理员可以执行删除。")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 134 KiB |
@@ -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.退房时间为中午12:00,超时退房将按酒店规定收取相关费用。\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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 134 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 134 KiB |
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
47
server/tests/test_expense_claim_risk_flags.py
Normal 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"]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
302
web/src/assets/styles/components/stage-risk-advice-card.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '预警线',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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('费用明细不能为空')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;/)
|
||||||
|
|||||||
@@ -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;/)
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
@@ -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:-}"
|
||||||
|
|||||||