16 Commits

Author SHA1 Message Date
caoxiaozhu
75d5c178e1 feat(workbench): persist topbar notification state 2026-06-03 21:43:35 +08:00
caoxiaozhu
b9826a1985 fix: keep adjusted risks visible to reviewers 2026-06-03 19:14:40 +08:00
caoxiaozhu
0f8bc4071a fix: preserve reviewer risk notice after standard adjustment 2026-06-03 19:10:29 +08:00
caoxiaozhu
cb36d78fa2 fix: 优化顶部导航栏布局与工作台摘要展示并清理旧票据数据 2026-06-03 17:40:52 +08:00
caoxiaozhu
8e2477587f fix: handle risk explanation standard adjustment 2026-06-03 17:31:40 +08:00
caoxiaozhu
67b81a1bd8 fix(workbench): replay profile radar animation 2026-06-03 17:31:12 +08:00
caoxiaozhu
9c24a852e7 fix(workbench): remount expense stats chart on reopen 2026-06-03 17:22:48 +08:00
caoxiaozhu
95956afbc6 fix(notifications): refine bell notification center 2026-06-03 17:16:09 +08:00
caoxiaozhu
c73178b65d fix(documents): move unread notice into bell 2026-06-03 17:05:34 +08:00
caoxiaozhu
8c2f301d85 fix(documents): sort newest rows first 2026-06-03 16:52:49 +08:00
caoxiaozhu
4717ee6086 fix(documents): refine unread badges and mark all read 2026-06-03 16:46:13 +08:00
caoxiaozhu
513ff909f9 fix: remove manual expense detail add action 2026-06-03 16:44:06 +08:00
caoxiaozhu
92198549f6 fix: require explicit transport mode for applications 2026-06-03 16:36:02 +08:00
caoxiaozhu
59d3bf0f00 fix(auth): keep admin out of personal workbench 2026-06-03 16:31:27 +08:00
caoxiaozhu
04f0951b3d fix: restrict application linking for reimbursement drafts 2026-06-03 16:28:09 +08:00
caoxiaozhu
8887cf5a27 fix(workbench): stretch profile tag card 2026-06-03 15:53:30 +08:00
102 changed files with 4045 additions and 1716 deletions

View File

@@ -0,0 +1,111 @@
# 通知中心状态持久化概念文档
## 功能一句话
为首页小铃铛通知中心补齐服务端状态接口,让同一用户在不同电脑登录时看到一致的已读、清空和隐藏状态,并优化笔记本等小屏幕下的通知弹窗可读性。
## 背景与问题
当前小铃铛通知由前端从单据中心、个人工作台摘要等数据源即时生成,但已读与清空状态主要写入浏览器 `localStorage`。这会导致同一账号在 A 电脑清空通知后,换到 B 电脑仍然看到通知。
同时,通知条数较多或屏幕高度较小时,列表内容容易挤压头部操作区,通知标题与描述也容易在窄宽度下互相挤压。
## 目标与非目标
目标:
- 提供当前用户维度的通知状态接口。
- 支持批量同步通知状态,至少覆盖已读与隐藏。
- 前端优先使用服务端状态,接口不可用时保留本地降级能力。
- 优化小屏幕通知弹窗,列表多时使用内部滚动,标题、描述与操作按钮不互相挤压。
非目标:
- 不做独立消息投递系统。
- 不新增推送、WebSocket 或邮件通知能力。
- 不改变通知来源生成逻辑,当前仍由单据中心和工作台摘要生成。
## 用户与场景
- 普通员工:在个人工作台查看待办、单据新消息,跨电脑登录后已读状态一致。
- 审批人:处理待审批单据后,通知中心不因换电脑重新显示已清空内容。
- 管理员:仍可看到系统内已有通知入口,但 admin 是否展示工作台由现有逻辑决定。
## 功能能力
- `GET /notification-states`:读取当前登录用户的通知状态集合。
- `POST /notification-states`:批量保存当前登录用户的通知状态。
- 状态字段:
- `notification_id`:前端生成的稳定通知 ID。
- `read_at`:已读时间。
- `hidden_at`:隐藏或清空时间。
- `context_json`:保留通知来源、类型等低风险上下文,便于排查。
- 前端能力:
- 打开工作台或弹窗时读取服务端状态。
- 点击通知写入已读。
- 清空通知写入隐藏。
- 接口失败时仍写入本地缓存,避免用户操作失效。
## 方案设计
后端:
- 新增 `NotificationState` SQLAlchemy 模型。
- 新增 `NotificationStateService`,负责按 `CurrentUserContext.username` 读写状态。
- 新增 `notification_states` endpoint并挂到 API v1 router。
- 服务初始化时使用项目现有 `Base.metadata.create_all(..., tables=[...])` 模式确保表存在。
前端:
- 新增 `web/src/services/notificationStates.js` 封装接口。
- `TopBar.vue``localStorage` 状态作为初始兜底,服务端状态返回后合并覆盖。
- `markNotificationRead``clearAllNotifications` 做乐观更新,再异步同步服务端。
- 对单据通知仍调用现有 `markDocumentInboxRowRead`,同时写入通知状态接口,保证跨设备一致。
小屏幕布局:
- 弹窗宽度使用 `clamp``100vw` 约束。
- 弹窗最大高度使用 `min(..., calc(100vh - ...))`
- 列表作为唯一滚动区域,头部和 tab 固定在弹窗网格内。
- 通知描述允许两行截断,避免窄屏时横向挤压。
## 算法与公式
当前功能不涉及显式数学公式。状态合并规则为:
$$
visible = notification\_id \notin hiddenIds
$$
$$
unread = sourceUnread \land notification\_id \notin readIds \land notification\_id \notin hiddenIds
$$
服务端状态优先,前端本地状态仅作为接口失败或首次加载前的兜底。
## 测试方案
- 后端单元测试:
- 当前用户只能读取自己的通知状态。
- 批量 upsert 后可读取 `read_at``hidden_at`
- 清空通知写入 hidden 状态。
- 前端静态测试:
- `TopBar` 引用通知状态服务。
- 已读、清空操作会同步服务端。
- 小屏 CSS 使用弹窗 max-height、内部滚动和移动端约束。
- 构建验证:
- 运行前端构建确认 Vue 与服务导入无误。
- 在容器内运行后端定向 pytest。
## 指标与验收
- 同一用户跨电脑登录后,已读和清空状态由服务端保持一致。
- 接口失败时用户仍可本地清空,不阻断主要流程。
- 通知弹窗在笔记本高度下不会挤压头部按钮,列表内部滚动。
- 通知标题、描述、时间在窄屏下不横向溢出。
## 风险与开放问题
- 当前通知本身仍由前端即时生成,服务端只保存状态,不保存完整通知正文。
- 通知 ID 需要保持稳定,否则服务端状态无法命中;本次沿用现有 `document:``workbench:` 前缀。
- 历史 localStorage 状态会作为首次迁移兜底,后续服务端会逐步成为主状态源。

View File

@@ -0,0 +1,10 @@
# 通知中心状态持久化 TODO
- [x] 调研现有小铃铛通知来源、localStorage 键和单据中心已读逻辑。[CONCEPT: 背景与问题] 证据:`TopBar.vue``useDocumentCenterInbox.js``documentCenterNewState.js` 已确认。
- [x] 新增后端通知状态模型、Schema、Service 与 API endpoint。[CONCEPT: 方案设计] 证据:`notification_states` 支持按用户保存已读与隐藏状态。
- [x] 将通知状态接口挂载到 API v1 router并保持当前用户隔离。[CONCEPT: 功能能力] 证据:`GET /notification-states``POST /notification-states` 已接入。
- [x] 新增前端 `notificationStates` 服务封装读取与批量保存。[CONCEPT: 前端] 证据:服务层统一请求 `/notification-states`
- [x] 改造 `TopBar` 已读、清空逻辑,优先同步服务端,保留本地降级。[CONCEPT: 前端] 证据:小铃铛点击已读、清空通知都会写入状态接口。
- [x] 优化通知弹窗笔记本与窄屏布局,避免条数多时挤压。[CONCEPT: 小屏幕布局] 证据弹窗限制视口高度列表滚动描述两行截断420px 下隐藏行箭头。
- [x] 补充后端和前端回归测试。[CONCEPT: 测试方案] 证据:`server/tests/test_notification_states.py``web/tests/sidebar-document-unread-dot.test.mjs`
- [x] 运行容器后端定向 pytest、前端 Node 测试与前端 build。[CONCEPT: 指标与验收] 证据pytest 2 passedNode 4 passedVite build passed。

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.notification_state import NotificationStateBatchPatch, NotificationStateListRead
from app.services.notification_states import NotificationStateService
router = APIRouter(prefix="/notification-states")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"",
response_model=NotificationStateListRead,
summary="读取当前用户通知状态",
description="读取当前登录用户的小铃铛通知已读和隐藏状态,用于跨设备保持一致。",
)
def list_notification_states(db: DbSession, current_user: CurrentUser) -> NotificationStateListRead:
return NotificationStateService(db).list_states(current_user)
@router.post(
"",
response_model=NotificationStateListRead,
summary="批量保存当前用户通知状态",
description="批量保存当前登录用户的小铃铛通知已读和隐藏状态。",
)
def patch_notification_states(
payload: NotificationStateBatchPatch,
db: DbSession,
current_user: CurrentUser,
) -> NotificationStateListRead:
return NotificationStateService(db).patch_states(payload, current_user)

View File

@@ -20,6 +20,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemUpdate,
ExpenseClaimRead,
ExpenseClaimReturnPayload,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
ReimbursementCreate,
ReimbursementRead,
@@ -233,6 +234,43 @@ def update_expense_claim(
return claim
@router.post(
"/claims/{claim_id}/standard-adjustment",
response_model=ExpenseClaimRead,
summary="接受职级报销标准重算",
description="在草稿报销单存在中高风险但提交人不补充异常说明时,按职级可报销标准重算实际报销金额。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "报销单状态不允许重算或入参不合法。",
},
},
)
def accept_expense_claim_standard_adjustment(
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.accept_standard_adjustment(
claim_id=claim_id,
payload=payload,
current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.patch(
"/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead,

View File

@@ -14,6 +14,7 @@ from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router
from app.api.v1.endpoints.notification_states import router as notification_states_router
from app.api.v1.endpoints.ocr import router as ocr_router
from app.api.v1.endpoints.ontology import router as ontology_router
from app.api.v1.endpoints.orchestrator import router as orchestrator_router
@@ -36,6 +37,7 @@ router.include_router(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"])
router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"])
router.include_router(notification_states_router, tags=["notification-states"])
router.include_router(ocr_router, tags=["ocr"])
router.include_router(ontology_router, tags=["ontology"])
router.include_router(orchestrator_router, tags=["orchestrator"])

View File

@@ -23,6 +23,7 @@ from app.models.financial_record import (
)
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
@@ -60,6 +61,7 @@ __all__ = [
"HermesTaskConfig",
"HermesTaskExecutionLog",
"HermesRiskReport",
"NotificationState",
"OrganizationUnit",
"ReimbursementRequest",
"RiskObservation",

View File

@@ -16,6 +16,7 @@ from app.models.financial_record import (
)
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
@@ -51,6 +52,7 @@ __all__ = [
"HermesTaskConfig",
"HermesTaskExecutionLog",
"HermesRiskReport",
"NotificationState",
"OrganizationUnit",
"ReimbursementRequest",
"RiskObservation",

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class NotificationState(Base):
__tablename__ = "notification_states"
__table_args__ = (
UniqueConstraint("user_id", "notification_id", name="uq_notification_states_user_notification"),
Index("ix_notification_states_user_updated", "user_id", "updated_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String(100), index=True)
notification_id: Mapped[str] = mapped_column(String(180), index=True)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _normalize_text(value: Any) -> str:
return str(value or "").strip()
class NotificationStatePatch(BaseModel):
notification_id: str = Field(min_length=1, max_length=180)
read: bool = False
hidden: bool = False
context_json: dict[str, Any] = Field(default_factory=dict)
@field_validator("notification_id", mode="before")
@classmethod
def normalize_notification_id(cls, value: Any) -> str:
return _normalize_text(value)
@field_validator("context_json", mode="before")
@classmethod
def normalize_context(cls, value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
class NotificationStateBatchPatch(BaseModel):
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100)
class NotificationStateRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
notification_id: str
read_at: datetime | None
hidden_at: datetime | None
context_json: dict[str, Any]
updated_at: datetime
class NotificationStateListRead(BaseModel):
states: list[NotificationStateRead] = Field(default_factory=list)

View File

@@ -121,6 +121,19 @@ class ExpenseClaimUpdate(BaseModel):
reason: str | None = Field(default=None, max_length=500)
class ExpenseClaimStandardAdjustmentRisk(BaseModel):
risk_id: str | None = Field(default=None, max_length=120)
item_id: str | None = Field(default=None, max_length=120)
title: str | None = Field(default=None, max_length=120)
risk: str | None = Field(default=None, max_length=500)
original_amount: Decimal | None = None
reimbursable_amount: Decimal | None = None
class ExpenseClaimStandardAdjustmentPayload(BaseModel):
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
class ExpenseClaimRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -26,6 +26,7 @@ EXPENSE_TYPE_LABELS = {
}
MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
STANDARD_ADJUSTMENT_RISK_SOURCE = "reimbursement_standard_adjustment"
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
TRAVEL_DETAIL_ITEM_TYPES = {

View File

@@ -110,6 +110,10 @@ from app.services.expense_rule_runtime import (
from app.services.ocr import OcrService
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {"cancelled", "canceled", "deleted"}
class ExpenseClaimDraftFlowMixin:
def upsert_draft_from_ontology(
self,
@@ -176,6 +180,12 @@ class ExpenseClaimDraftFlowMixin:
)
is_new_claim = claim is None
before_json = self._serialize_claim(claim) if claim is not None else None
application_link_block_result = self._build_application_link_block_result(
context_json=context_json,
target_claim=claim,
)
if application_link_block_result is not None:
return application_link_block_result
if is_new_claim:
existing_draft_count = self._count_draft_claims_for_owner(
employee=employee,
@@ -517,6 +527,219 @@ class ExpenseClaimDraftFlowMixin:
return list(risk_flags or [])
return [*list(risk_flags or []), link_flag]
def _build_application_link_block_result(
self,
*,
context_json: dict[str, Any],
target_claim: ExpenseClaim | None,
) -> dict[str, Any] | None:
link_flag = self._build_application_link_flag(context_json)
if link_flag is None:
return None
application_claim = self._find_application_claim_for_link(link_flag)
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
display_no = application_claim_no or "未编号申请单"
if application_claim is None or not self._is_expense_application_claim(application_claim):
return self._build_application_link_rejected_result(
f"未找到可关联的申请单 {display_no}。请先选择已审批通过的申请单。",
)
normalized_status = str(application_claim.status or "").strip().lower()
if normalized_status not in APPROVED_APPLICATION_LINK_STATUSES:
return self._build_application_link_rejected_result(
f"申请单 {application_claim.claim_no} 当前不是已审批通过状态,不能用于快速报销关联。",
application_claim=application_claim,
)
existing_reimbursement = self._find_existing_reimbursement_for_application_link(
application_claim=application_claim,
link_flag=link_flag,
target_claim=target_claim,
)
if existing_reimbursement is not None:
return self._build_application_link_rejected_result(
(
f"申请单 {application_claim.claim_no} 已经关联报销单 {existing_reimbursement.claim_no}"
"请进入该草稿或单据继续补充,不能重复生成。"
),
application_claim=application_claim,
existing_claim=existing_reimbursement,
)
return None
def _find_application_claim_for_link(self, link_flag: dict[str, Any]) -> ExpenseClaim | None:
application_claim_id = str(link_flag.get("application_claim_id") or "").strip()
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
if application_claim_id:
claim = self.db.get(ExpenseClaim, application_claim_id)
if claim is not None and self._is_expense_application_claim(claim):
return claim
if application_claim_no:
return self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == application_claim_no)
.limit(1)
)
return None
def _find_existing_reimbursement_for_application_link(
self,
*,
application_claim: ExpenseClaim,
link_flag: dict[str, Any],
target_claim: ExpenseClaim | None,
) -> ExpenseClaim | None:
generated_draft = self._find_generated_reimbursement_from_application(
application_claim=application_claim,
target_claim=target_claim,
)
if generated_draft is not None:
return generated_draft
linked_ids, linked_nos = self._collect_application_link_reference_values(link_flag)
linked_ids.add(str(application_claim.id or "").strip())
linked_nos.add(str(application_claim.claim_no or "").strip().upper())
linked_ids.discard("")
linked_nos.discard("")
for claim in list(self.db.scalars(select(ExpenseClaim)).all()):
if self._is_same_target_claim(claim, target_claim):
continue
if self._is_expense_application_claim(claim):
continue
if self._is_inactive_application_link_reimbursement(claim):
continue
if self._claim_references_application(claim, linked_ids=linked_ids, linked_nos=linked_nos):
return claim
return None
def _find_generated_reimbursement_from_application(
self,
*,
application_claim: ExpenseClaim,
target_claim: ExpenseClaim | None,
) -> ExpenseClaim | None:
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
generated_draft_id = str(
flag.get("generated_draft_claim_id")
or flag.get("generatedDraftClaimId")
or ""
).strip()
generated_draft_no = str(
flag.get("generated_draft_claim_no")
or flag.get("generatedDraftClaimNo")
or ""
).strip()
claim = self.db.get(ExpenseClaim, generated_draft_id) if generated_draft_id else None
if claim is None and generated_draft_no:
claim = self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == generated_draft_no)
.limit(1)
)
if claim is None:
continue
if self._is_same_target_claim(claim, target_claim):
continue
if self._is_expense_application_claim(claim):
continue
if self._is_inactive_application_link_reimbursement(claim):
continue
return claim
return None
@staticmethod
def _is_same_target_claim(claim: ExpenseClaim, target_claim: ExpenseClaim | None) -> bool:
return bool(target_claim is not None and claim.id == target_claim.id)
@staticmethod
def _is_inactive_application_link_reimbursement(claim: ExpenseClaim) -> bool:
status = str(claim.status or "").strip().lower()
return status in INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES
@classmethod
def _claim_references_application(
cls,
claim: ExpenseClaim,
*,
linked_ids: set[str],
linked_nos: set[str],
) -> bool:
for flag in list(claim.risk_flags_json or []):
flag_ids, flag_nos = cls._collect_application_link_reference_values(flag)
if flag_ids.intersection(linked_ids) or flag_nos.intersection(linked_nos):
return True
return False
@classmethod
def _collect_application_link_reference_values(cls, payload: Any) -> tuple[set[str], set[str]]:
ids: set[str] = set()
claim_nos: set[str] = set()
if not isinstance(payload, dict):
return ids, claim_nos
cls._add_application_link_reference(ids, claim_nos, payload)
for key in (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
):
nested_ids, nested_nos = cls._collect_application_link_reference_values(payload.get(key))
ids.update(nested_ids)
claim_nos.update(nested_nos)
ids.discard("")
claim_nos.discard("")
return ids, claim_nos
@staticmethod
def _add_application_link_reference(
ids: set[str],
claim_nos: set[str],
payload: dict[str, Any],
) -> None:
for key in ("application_claim_id", "applicationClaimId"):
ids.add(str(payload.get(key) or "").strip())
for key in ("application_claim_no", "applicationClaimNo"):
claim_nos.add(str(payload.get(key) or "").strip().upper())
@staticmethod
def _build_application_link_rejected_result(
message: str,
*,
application_claim: ExpenseClaim | None = None,
existing_claim: ExpenseClaim | None = None,
) -> dict[str, Any]:
result: dict[str, Any] = {
"message": message,
"draft_only": False,
"status": "blocked",
"application_link_blocked": True,
"submission_blocked": True,
"submission_blocked_reasons": [message],
"missing_fields": [message],
"risk_flags": ["application_link_blocked"],
}
if application_claim is not None:
result["application_claim_id"] = application_claim.id
result["application_claim_no"] = application_claim.claim_no
result["application_status"] = application_claim.status
if existing_claim is not None:
result["existing_claim_id"] = existing_claim.id
result["existing_claim_no"] = existing_claim.claim_no
result["existing_claim_status"] = existing_claim.status
return result
@staticmethod
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from types import SimpleNamespace
from typing import Any
@@ -24,6 +24,7 @@ from app.services.expense_claim_constants import (
DOCUMENT_FACT_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES,
OPTIONAL_ATTACHMENT_ITEM_TYPES,
STANDARD_ADJUSTMENT_RISK_SOURCE,
SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
@@ -329,6 +330,45 @@ class ExpenseClaimItemSyncMixin:
return destination
return ""
@staticmethod
def _parse_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
def _collect_standard_adjusted_amounts(self, claim: ExpenseClaim) -> dict[str, Decimal]:
adjusted_amounts: dict[str, Decimal] = {}
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("source") or "").strip() != STANDARD_ADJUSTMENT_RISK_SOURCE:
continue
item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
if not item_id:
continue
amount = self._parse_standard_adjustment_amount(
flag.get("reimbursable_amount") or flag.get("reimbursableAmount")
)
if amount is None:
continue
adjusted_amounts[item_id] = amount
return adjusted_amounts
def _resolve_item_amount_for_claim_total(
self,
item: ExpenseClaimItem,
adjusted_amounts: dict[str, Decimal],
) -> Decimal:
original_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
item_id = str(item.id or "").strip()
adjusted_amount = adjusted_amounts.get(item_id)
if adjusted_amount is None:
return original_amount
return min(max(adjusted_amount, Decimal("0.00")), original_amount)
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
self._sync_travel_allowance_item(claim)
if not claim.items:
@@ -346,7 +386,11 @@ class ExpenseClaimItemSyncMixin:
),
)
primary_item = ordered_items[0]
total_amount = sum((item.item_amount for item in ordered_items), Decimal("0.00"))
adjusted_amounts = self._collect_standard_adjusted_amounts(claim)
total_amount = sum(
(self._resolve_item_amount_for_claim_total(item, adjusted_amounts) for item in ordered_items),
Decimal("0.00"),
)
claim.amount = total_amount.quantize(Decimal("0.01"))
claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())

View File

@@ -72,7 +72,11 @@ class ExpenseClaimPolicyReviewMixin:
limit_config=item_limit,
reason_text="\n".join(
part
for part in [reason_corpus, str(item.item_reason or "").strip()]
for part in [
reason_corpus,
str(item.item_reason or "").strip(),
str(item.item_note or "").strip(),
]
if part
),
)
@@ -333,7 +337,12 @@ class ExpenseClaimPolicyReviewMixin:
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
f"当前酒店识别金额约 {nightly_amount} 元/晚。"
)
item_reason = str(context["item"].item_reason or "").strip()
item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
if has_standard_exception or item_has_exception:
flags.append(
@@ -368,7 +377,12 @@ class ExpenseClaimPolicyReviewMixin:
if allowed_level is None or class_level <= allowed_level:
continue
item_reason = str(context["item"].item_reason or "").strip()
item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
message = f"{band_label} 职级当前默认不可报销 {class_label}"
if has_standard_exception or item_has_exception:
@@ -463,6 +477,7 @@ class ExpenseClaimPolicyReviewMixin:
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
for item in claim.items:
parts.append(str(item.item_reason or "").strip())
parts.append(str(item.item_note or "").strip())
parts.append(str(item.item_location or "").strip())
return "\n".join(part for part in parts if part)

View File

@@ -27,6 +27,7 @@ from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest,
)
@@ -109,6 +110,7 @@ from app.services.expense_claim_constants import (
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
STANDARD_ADJUSTMENT_RISK_SOURCE,
)
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import (
@@ -290,6 +292,126 @@ class ExpenseClaimService(
return claim
@staticmethod
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
@staticmethod
def _format_adjustment_money(value: Decimal) -> str:
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
return f"{normalized:.2f}"
def accept_standard_adjustment(
self,
*,
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
if self._is_expense_application_claim(claim):
raise ValueError("费用申请单不支持按报销标准重算。")
risk_entries = list(payload.risks or [])
if not risk_entries:
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
before_json = self._serialize_claim(claim)
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
now_text = datetime.now(UTC).isoformat()
adjustment_flags: list[dict[str, Any]] = []
for index, entry in enumerate(risk_entries, start=1):
item_id = str(entry.item_id or "").strip()
item = item_map.get(item_id)
if item is None:
continue
original_amount = (
self._normalize_standard_adjustment_amount(entry.original_amount)
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
)
reimbursable_amount = (
self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
or original_amount
)
reimbursable_amount = min(max(reimbursable_amount, Decimal("0.00")), original_amount)
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01"))
item_label = (
str(item.item_reason or "").strip()
or str(entry.title or "").strip()
or f"费用明细第 {index}"
)
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
message = (
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
)
adjustment_flags.append(
with_risk_business_stage(
{
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
"event_type": "standard_adjustment_accepted",
"severity": "medium",
"label": "接受职级标准审核",
"title": "提交人接受职级最高报销标准",
"message": message,
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
"risk_id": str(entry.risk_id or "").strip(),
"source_risk": source_risk,
"item_id": item_id,
"original_amount": self._format_adjustment_money(original_amount),
"reimbursable_amount": self._format_adjustment_money(reimbursable_amount),
"employee_absorbed_amount": self._format_adjustment_money(employee_absorbed_amount),
"risk_domain": "amount",
"actionability": "review_decision",
"visibility_scope": "leader",
"created_at": now_text,
},
"reimbursement",
)
)
if not adjustment_flags:
raise ValueError("未找到可按职级标准重算的费用明细。")
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
)
]
claim.risk_flags_json = [*preserved_flags, *adjustment_flags]
self._sync_claim_from_items(claim)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.standard_adjustment_accept",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
def update_claim_item(
self,
*,
@@ -758,6 +880,3 @@ class ExpenseClaimService(

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.notification_state import NotificationState
from app.schemas.notification_state import (
NotificationStateBatchPatch,
NotificationStateListRead,
NotificationStateRead,
)
class NotificationStateService:
def __init__(self, db: Session) -> None:
self.db = db
def ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind(), tables=[NotificationState.__table__])
def list_states(self, current_user: CurrentUserContext) -> NotificationStateListRead:
self.ensure_storage_ready()
stmt = (
select(NotificationState)
.where(NotificationState.user_id == self._user_key(current_user))
.order_by(NotificationState.updated_at.desc())
)
states = list(self.db.scalars(stmt).all())
return NotificationStateListRead(
states=[NotificationStateRead.model_validate(item) for item in states]
)
def patch_states(
self,
payload: NotificationStateBatchPatch,
current_user: CurrentUserContext,
) -> NotificationStateListRead:
self.ensure_storage_ready()
user_id = self._user_key(current_user)
patches = [item for item in payload.states if item.notification_id]
if not patches:
return self.list_states(current_user)
ids = {item.notification_id for item in patches}
existing_rows = list(
self.db.scalars(
select(NotificationState).where(
NotificationState.user_id == user_id,
NotificationState.notification_id.in_(ids),
)
).all()
)
existing_by_id = {item.notification_id: item for item in existing_rows}
now = datetime.now(UTC)
for patch in patches:
row = existing_by_id.get(patch.notification_id)
if row is None:
row = NotificationState(
user_id=user_id,
notification_id=patch.notification_id,
context_json={},
)
self.db.add(row)
existing_by_id[patch.notification_id] = row
if patch.read and row.read_at is None:
row.read_at = now
if patch.hidden and row.hidden_at is None:
row.hidden_at = now
if patch.context_json:
row.context_json = self._merge_context(row.context_json, patch.context_json)
self.db.commit()
return self.list_states(current_user)
@staticmethod
def _user_key(current_user: CurrentUserContext) -> str:
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
@staticmethod
def _merge_context(current: dict | None, patch: dict) -> dict:
base = current if isinstance(current, dict) else {}
return {**base, **patch}

View File

@@ -78,6 +78,9 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"application_policy_estimate",
"application_rule_name",
"application_rule_version",
"original_amount",
"reimbursable_amount",
"employee_absorbed_amount",
}
)

View File

@@ -1,121 +0,0 @@
{
"id": "057901b1-d38a-4e0c-9d53-44d14244317e",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:39:27.813933+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"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.9580968717734019,
"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"
}

View File

@@ -1,121 +0,0 @@
{
"id": "0abbdfaf-7952-4854-a5b2-bc34298fa1c4",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:08:52.697458+00:00",
"status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
"linked_item_id": "b1b343b0-3564-4d35-919a-0e4220a9fceb",
"linked_at": "2026-06-01T06:08:52.697458+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.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,66 +0,0 @@
{
"id": "29ec7c4c-abc0-46f0-8eae-3136c2d6fef7",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-30T07:00:12.286631+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"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.9580968717734019,
"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": "武汉-上海"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,66 +0,0 @@
{
"id": "2fd4856f-e918-4d29-a0b2-340cc1fdec03",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-30T07:00:40.560540+00:00",
"status": "linked",
"linked_claim_id": "6b8e29c9-8bd6-453b-b594-ed0e5b59b91f",
"linked_claim_no": "RE-20260530065944-M94FAPB9",
"linked_item_id": "329f477a-d926-4101-8ec8-4c8a95150f22",
"linked_at": "2026-05-30T07:00:40.560540+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.9580968717734019,
"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": "武汉-上海"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,16 +1,16 @@
{
"id": "e8d4f21f-846f-4321-a341-52cd3dfb5acc",
"id": "33a1c4b9-56e1-49d1-823e-5cb4680f5a40",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:57:44.644255+00:00",
"uploaded_at": "2026-06-03T08:39:19.288158+00:00",
"status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
"linked_item_id": "26ccabf8-7e69-4812-acc6-fa18899ec5b2",
"linked_at": "2026-06-01T06:57:44.644255+00:00",
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "eb1e9fde-b7e8-4f6e-823f-d8252489e7f9",
"linked_at": "2026-06-03T08:39:19.288158+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中国铁路祝您旅途愉快",

View File

@@ -1,121 +0,0 @@
{
"id": "64b90764-b957-4a54-b231-0646ee60d1cd",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:08:07.688668+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"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.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "67dc947b-be67-444d-9a91-897190170021",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:40:08.599943+00:00",
"status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
"linked_item_id": "c1693c4a-dcbd-4a8b-941c-bb0abc6ec65a",
"linked_at": "2026-06-01T06:40:08.599943+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.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,16 +1,16 @@
{
"id": "fa80f870-10a3-4797-a151-39e702927eb5",
"id": "67f51c17-a2bc-42bd-99a4-199ee32b18c3",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-06-01T06:40:32.249473+00:00",
"uploaded_at": "2026-06-03T08:40:26.766004+00:00",
"status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
"linked_item_id": "b0b28405-30b5-4c35-9bd5-13abe4d2c4cd",
"linked_at": "2026-06-01T06:40:32.249473+00:00",
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "977f01f8-e7ab-487b-8055-db8864464784",
"linked_at": "2026-06-03T08:40:26.766004+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中国铁路祝您旅途愉快",

View File

@@ -1,121 +0,0 @@
{
"id": "6a7335e9-b36a-415c-b2d5-26f421ec72b0",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-06-01T06:08:07.724830+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"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.9620026834309101,
"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"
}

View File

@@ -0,0 +1,55 @@
{
"id": "8678e169-d800-4846-9354-eb768a9b65f8",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "酒店3.jpg",
"source_file_name": "酒店3.jpg",
"media_type": "image/jpeg",
"size_bytes": 153582,
"uploaded_at": "2026-06-03T08:41:47.393654+00:00",
"status": "linked",
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "42250571-3e84-4f27-b3e8-aa224b5cb2f7",
"linked_at": "2026-06-03T08:41:47.393654+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号SH-SAMPLE-20260223-001\n开单期2026年223\n宾客姓名曹笑\n住期2026年220\n离店期2026年223\n住晚数3晚\n房型豪华床房\n房号1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写壹仟零捌拾陆元整\n合计¥1086\n备注\n1.如有疑问请致电前台021-28958888。\n2.退房时间为中午1200超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址上海市浦东新区银城中路88号 邮编200120\n样例票据|仅供系统测试|无效凭证",
"summary": "上海喜来登酒店样例住宿费用单单据编号SH-SAMPLE-20260223-001",
"ocr_avg_score": 0.988790222009023,
"ocr_line_count": 30,
"page_count": 1,
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.71,
"ocr_classification_evidence": [
"住宿",
"离店",
"酒店"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "1086元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-20"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.jpg",
"preview_media_type": "image/jpeg"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "87c5ddbf-15be-4d21-982d-808267902ab0",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-06-01T06:39:27.857168+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"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.9620026834309101,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "fd4f2229-bf01-48ae-99b9-b98458e2b632",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:56:59.257104+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"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.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -19,7 +19,12 @@ from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
)
from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
@@ -69,6 +74,36 @@ def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
return claim
def build_application_claim(
*,
id: str,
claim_no: str,
employee: Employee,
status: str = "approved",
amount: Decimal = Decimal("3000.00"),
) -> ExpenseClaim:
return ExpenseClaim(
id=id,
claim_no=claim_no,
employee_id=employee.id,
employee_name=employee.name,
department_id=employee.organization_unit_id,
department_name="Tech",
project_code=None,
expense_type="travel_application",
reason="support deployment",
location="Shanghai",
amount=amount,
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=datetime(2026, 2, 18, tzinfo=UTC),
status=status,
approval_stage=APPROVAL_DONE_STAGE if status in {"approved", "completed"} else "Pending",
risk_flags_json=[],
)
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
@@ -322,6 +357,12 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
email=user_id,
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
))
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -384,6 +425,12 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
grade="P5",
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
))
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -474,6 +521,11 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
))
existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id,
@@ -550,6 +602,123 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
assert claim.items == []
def test_upsert_linked_application_requires_approved_application() -> None:
user_id = "linked-application-status-block@example.com"
message = "save reimbursement draft from linked travel application"
with build_session() as db:
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
))
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(query=message, user_id=user_id)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "Linked Employee",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "travel",
"application_claim_id": "application-returned-blocked",
"application_claim_no": "AP-202606-STATUS",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-returned-blocked",
"application_claim_no": "AP-202606-STATUS",
},
},
)
assert result["status"] == "blocked"
assert result["application_link_blocked"] is True
assert result["application_claim_no"] == "AP-202606-STATUS"
assert _count_claims(db) == 1
def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> None:
user_id = "linked-application-duplicate-block@example.com"
message = "save another reimbursement draft from linked travel application"
with build_session() as db:
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
))
existing_claim = ExpenseClaim(
claim_no="RE-202606-DUP-DRAFT",
employee_id=employee.id,
employee_name=employee.name,
department_name="Tech",
project_code=None,
expense_type="travel",
reason="support deployment",
location="Shanghai",
amount=Decimal("0.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
status="draft",
approval_stage="Pending",
risk_flags_json=[
{
"source": "application_link",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
}
],
)
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(query=message, user_id=user_id)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "Linked Employee",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "travel",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
},
},
)
assert result["status"] == "blocked"
assert result["application_link_blocked"] is True
assert result["existing_claim_no"] == "RE-202606-DUP-DRAFT"
assert _count_claims(db) == 2
def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
with build_session() as db:
employee = Employee(
@@ -2451,6 +2620,77 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.submitted_at is not None
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
current_user = CurrentUserContext(
username="emp-standard@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7030",
name="李经理",
email="manager-standard@example.com",
)
employee = Employee(
employee_no="E7031",
name="张三",
email="emp-standard@example.com",
manager=manager,
)
claim = build_claim(expense_type="hotel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.amount = Decimal("880.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_amount = Decimal("880.00")
db.add_all([manager, employee, claim])
db.commit()
service = ExpenseClaimService(db)
adjusted = service.accept_standard_adjustment(
claim_id=claim.id,
payload=ExpenseClaimStandardAdjustmentPayload(
risks=[
{
"risk_id": "risk-hotel-1",
"item_id": claim.items[0].id,
"title": "住宿超标待说明",
"risk": "住宿标准为 600 元/晚,当前酒店识别金额约 880 元/晚。",
"original_amount": Decimal("880.00"),
"reimbursable_amount": Decimal("600.00"),
}
]
),
current_user=current_user,
)
assert adjusted is not None
assert adjusted.amount == Decimal("600.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "880.00"
assert standard_flag["reimbursable_amount"] == "600.00"
assert standard_flag["employee_absorbed_amount"] == "280.00"
assert standard_flag["visibility_scope"] == "leader"
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.amount == Decimal("600.00")
assert any(
isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
for flag in submitted.risk_flags_json
)
def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext(
username="emp-pre-review@example.com",

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from collections.abc import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.db.base import Base
from app.main import create_app
from app.schemas.notification_state import NotificationStateBatchPatch, NotificationStatePatch
from app.services.notification_states import NotificationStateService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def build_client() -> TestClient:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app)
def test_notification_state_service_persists_user_scoped_read_and_hidden_state() -> None:
with build_session() as db:
service = NotificationStateService(db)
user = CurrentUserContext(username="alice", name="Alice", role_codes=[], is_admin=False)
other_user = CurrentUserContext(username="bob", name="Bob", role_codes=[], is_admin=False)
saved = service.patch_states(
NotificationStateBatchPatch(
states=[
NotificationStatePatch(
notification_id="document:owned:EXP-001",
read=True,
hidden=True,
context_json={"kind": "document"},
)
]
),
user,
)
other_saved = service.patch_states(
NotificationStateBatchPatch(
states=[
NotificationStatePatch(
notification_id="document:owned:EXP-001",
read=True,
)
]
),
other_user,
)
assert len(saved.states) == 1
assert saved.states[0].notification_id == "document:owned:EXP-001"
assert saved.states[0].read_at is not None
assert saved.states[0].hidden_at is not None
assert saved.states[0].context_json["kind"] == "document"
assert other_saved.states[0].hidden_at is None
def test_notification_state_endpoint_reads_and_updates_current_user_state() -> None:
client = build_client()
headers = {"x-auth-username": "alice", "x-auth-name": "Alice"}
post_response = client.post(
"/api/v1/notification-states",
json={
"states": [
{
"notification_id": "workbench:todo:EXP-002",
"read": True,
"hidden": False,
"context_json": {"kind": "workbench"},
}
]
},
headers=headers,
)
get_response = client.get("/api/v1/notification-states", headers=headers)
other_response = client.get(
"/api/v1/notification-states",
headers={"x-auth-username": "bob", "x-auth-name": "Bob"},
)
assert post_response.status_code == 200
assert get_response.status_code == 200
payload = get_response.json()
assert payload["states"][0]["notification_id"] == "workbench:todo:EXP-002"
assert payload["states"][0]["read_at"] is not None
assert payload["states"][0]["hidden_at"] is None
assert payload["states"][0]["context_json"]["kind"] == "workbench"
assert other_response.json()["states"] == []

View File

@@ -16,6 +16,7 @@
color: #64748b;
font-size: 14px;
font-weight: 750;
overflow: visible;
}
.status-tabs button.active {
@@ -33,20 +34,33 @@
background: var(--theme-primary);
}
.scope-tab-label {
display: inline-flex;
align-items: flex-start;
gap: 4px;
line-height: 1.2;
}
.scope-tab-badge {
min-width: 18px;
height: 18px;
position: static;
flex: 0 0 auto;
min-width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
margin-top: -5px;
padding: 0 3px;
border: 1px solid #fff;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-size: 9.5px;
font-weight: 850;
line-height: 1;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
box-shadow:
0 0 0 2px rgba(239, 68, 68, 0.1),
0 6px 14px rgba(239, 68, 68, 0.22);
}
.document-toolbar {
@@ -167,6 +181,38 @@
filter: none;
}
.mark-read-btn {
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
border: 1px solid #fecaca;
border-radius: 4px;
background: #fff;
color: #dc2626;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
transition:
border-color 160ms ease,
background 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.mark-read-btn .mdi {
font-size: 16px;
}
.mark-read-btn:hover {
border-color: #fca5a5;
background: #fff5f5;
color: #b91c1c;
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.1);
}
.table-wrap {
min-height: 400px;
margin-top: 10px;
@@ -495,6 +541,7 @@ td small {
.document-actions,
.list-search,
.filter-btn,
.mark-read-btn,
.page-size-select {
width: 100%;
}

View File

@@ -73,9 +73,9 @@
.insight-metric-row,
.insight-profile-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
gap: 8px;
min-height: 0;
padding: 7px 9px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
@@ -102,6 +102,15 @@
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
}
.insight-metric-icon,
.insight-profile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--workbench-muted);
font-size: 16px;
}
.insight-metric-label,
.insight-profile-label {
min-width: 0;
@@ -136,19 +145,21 @@
font-weight: 700;
}
.insight-metric-row--amount .insight-metric-icon,
.insight-metric-row--amount .insight-metric-value {
color: var(--workbench-primary-active);
}
.insight-metric-row--warning .insight-metric-icon,
.insight-metric-row--warning .insight-metric-value {
color: var(--warning);
}
.insight-metric-row--info .insight-metric-icon,
.insight-metric-row--info .insight-metric-value {
color: var(--workbench-chart-blue);
}
.insight-profile-icon,
.insight-profile-hint {
display: none;
}

View File

@@ -505,12 +505,19 @@
border-top: 0;
}
.progress-identity,
.progress-result {
gap: 12px;
}
.progress-identity strong,
.progress-result strong {
margin-bottom: 2px;
overflow: hidden;
color: var(--workbench-ink);
font-size: 13px;
font-weight: 850;
font-weight: bold;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
@@ -573,24 +580,29 @@
}
.progress-status--warning {
background: var(--warning-soft);
color: var(--warning);
background: var(--warning-soft, #fff7ed);
color: var(--warning, #ea580c);
}
.progress-status--success {
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
background: var(--workbench-primary-soft, #eaf4fa);
color: var(--workbench-primary-active, #255b7d);
}
.progress-status--muted {
background: var(--info-soft);
color: var(--workbench-muted);
background: var(--info-soft, #f1f5f9);
color: var(--workbench-muted, #64748b);
}
.progress-status--danger {
background: var(--danger-soft, #fef2f2);
color: var(--danger, #dc2626);
}
.progress-row {
position: relative;
display: grid;
grid-template-columns: minmax(78px, 0.42fr) minmax(132px, 0.74fr) minmax(84px, 0.42fr) minmax(260px, 1.28fr) minmax(92px, auto);
grid-template-columns: minmax(118px, 0.58fr) minmax(132px, 0.74fr) minmax(84px, 0.42fr) minmax(260px, 1.28fr) minmax(92px, auto);
align-items: center;
gap: 12px;
width: 100%;
@@ -638,6 +650,54 @@
pointer-events: none;
}
.progress-time-wrapper {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.expense-type-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 20px;
flex-shrink: 0;
}
.expense-type-icon--blue {
background: color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff);
color: var(--workbench-primary, #3a7ca5);
}
.expense-type-icon--amber {
background: color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff);
color: var(--workbench-chart-amber, #b58b4c);
}
.expense-type-icon--emerald {
background: color-mix(in srgb, #10b981 12%, #ffffff);
color: #10b981;
}
.expense-type-icon--violet {
background: color-mix(in srgb, #8b5cf6 12%, #ffffff);
color: #8b5cf6;
}
.expense-type-icon--cyan {
background: color-mix(in srgb, #06b6d4 12%, #ffffff);
color: #06b6d4;
}
.expense-type-icon--muted {
background: var(--info-soft, #f1f5f9);
color: var(--workbench-muted, #64748b);
}
.progress-time,
.progress-identity,
.progress-type,
@@ -649,6 +709,7 @@
.progress-time {
color: var(--workbench-muted);
justify-items: center;
}
.progress-time time {
@@ -669,6 +730,16 @@
line-height: 1.2;
}
.progress-time .time-capsule {
margin-top: 4px;
padding: 2px 6px;
border-radius: 999px;
background: var(--danger-soft, #fef2f2);
color: var(--danger, #dc2626);
font-weight: 850;
line-height: 1.2;
}
.progress-result {
justify-items: end;
}

View File

@@ -221,6 +221,7 @@
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 1;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
@@ -250,16 +251,6 @@
will-change: min-width, max-width, padding, opacity;
}
.nav-unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border: 2px solid #fff;
border-radius: 999px;
background: #ef4444;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.26);
}
.rail-user {
position: relative;
min-width: 0;
@@ -469,14 +460,6 @@
transition-delay: 0ms;
}
.rail-collapsed .nav-unread-dot {
position: absolute;
top: 10px;
right: 11px;
width: 9px;
height: 9px;
}
.rail-collapsed {
overflow: visible;
}

View File

@@ -408,197 +408,477 @@
display: inline-flex;
}
.notification-wrap.is-open .notification-btn {
border: 1px solid var(--theme-primary-light-5);
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.notification-btn {
position: relative;
overflow: visible;
border: 1px solid transparent;
border-radius: 4px;
transition:
border-color 180ms var(--ease),
background 180ms var(--ease),
color 180ms var(--ease);
}
.notification-badge {
position: absolute;
top: 2px;
right: 1px;
min-width: 13px;
height: 13px;
right: 2px;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 3px;
padding: 0 4px;
border: 2px solid #fff;
border-radius: 999px;
background: #ef4444;
border-radius: 4px;
background: #dc2626;
color: #fff;
font-size: 8px;
font-weight: 850;
font-size: 10px;
font-weight: 800;
line-height: 1;
box-shadow: 0 5px 10px rgba(239, 68, 68, .22);
letter-spacing: -0.02em;
}
.notification-panel-enter-active,
.notification-panel-leave-active {
transition:
opacity 220ms var(--ease),
transform 220ms var(--ease);
}
.notification-panel-enter-from,
.notification-panel-leave-to {
opacity: 0;
transform: translateY(-6px);
}
@media (prefers-reduced-motion: reduce) {
.notification-panel-enter-active,
.notification-panel-leave-active {
transition-duration: 1ms;
}
.notification-panel-enter-from,
.notification-panel-leave-to {
transform: none;
}
}
.notification-popover {
position: absolute;
top: calc(100% + 10px);
right: -8px;
top: calc(100% + 8px);
right: 0;
z-index: 60;
width: min(360px, calc(100vw - 32px));
width: clamp(340px, 34vw, 420px);
max-width: calc(100vw - 24px);
max-height: min(520px, calc(100vh - 96px));
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #e5edf5;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 0;
overflow: hidden;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
}
.notification-popover::before {
content: "";
position: absolute;
top: -6px;
right: 18px;
width: 10px;
height: 10px;
border-top: 1px solid #e5edf5;
border-left: 1px solid #e5edf5;
background: #fff;
transform: rotate(45deg);
}
.notification-head,
.notification-tabs {
position: relative;
z-index: 1;
display: flex;
align-items: center;
display: block;
height: 3px;
background: linear-gradient(
90deg,
var(--theme-primary-active) 0%,
var(--theme-primary-light-3, #7eb3d4) 100%
);
}
.notification-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px 10px;
border-bottom: 1px solid #edf2f7;
background: #fafbfd;
}
.notification-head-brand {
min-width: 0;
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 10px;
}
.notification-head-icon {
width: 32px;
height: 32px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 1px solid var(--theme-primary-light-6);
border-radius: 4px;
background: #fff;
color: var(--theme-primary-active);
font-size: 17px;
}
.notification-head-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.notification-head strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
font-weight: 800;
letter-spacing: 0.01em;
}
.notification-head button {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 4px;
.notification-head small {
color: #64748b;
font-size: 12px;
font-weight: 600;
}
.notification-head button:hover {
background: #f1f5f9;
.notification-head-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.notification-clear-btn {
height: 28px;
padding: 0 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 750;
white-space: nowrap;
transition:
background 160ms var(--ease),
color 160ms var(--ease);
}
.notification-clear-btn:hover:not(:disabled) {
background: var(--theme-primary-light-9);
}
.notification-clear-btn:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
.notification-close-btn {
width: 28px;
height: 28px;
display: inline-grid;
place-items: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #64748b;
font-size: 18px;
transition:
background 160ms var(--ease),
color 160ms var(--ease);
}
.notification-close-btn:hover {
background: #eef2f7;
color: #0f172a;
}
.notification-tabs {
gap: 6px;
padding: 3px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
display: flex;
align-items: stretch;
gap: 0;
padding: 0 14px;
border-bottom: 1px solid #edf2f7;
background: #fff;
}
.notification-tabs button {
position: relative;
flex: 1 1 0;
height: 28px;
border-radius: 3px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: #64748b;
font-size: 12px;
font-size: 13px;
font-weight: 750;
transition:
color 180ms var(--ease),
border-color 180ms var(--ease);
}
.notification-tabs button em {
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
border-radius: 4px;
background: #f1f5f9;
color: #475569;
font-style: normal;
font-size: 11px;
font-weight: 800;
line-height: 1;
transition:
background 180ms var(--ease),
color 180ms var(--ease);
}
.notification-tabs button.active {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
border-bottom-color: var(--theme-primary-active);
}
.notification-tabs button.active em {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.notification-list {
position: relative;
z-index: 1;
display: grid;
max-height: 320px;
overflow: auto;
display: flex;
flex-direction: column;
min-height: 0;
max-height: min(336px, calc(100vh - 226px));
overflow-x: hidden;
overflow-y: auto;
padding: 6px 0;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f8fafc;
}
.notification-list::-webkit-scrollbar {
width: 6px;
}
.notification-list::-webkit-scrollbar-track {
background: #f8fafc;
}
.notification-list::-webkit-scrollbar-thumb {
border-radius: 4px;
background: #cbd5e1;
}
.notification-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) 16px;
grid-template-columns: 34px minmax(0, 1fr) 16px;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-top: 1px solid #edf2f7;
gap: 9px;
min-height: 0;
padding: 10px 14px;
border: 0;
border-left: 3px solid transparent;
border-radius: 0;
background: transparent;
text-align: left;
transition:
background 180ms var(--ease),
border-color 180ms var(--ease);
}
.notification-row:first-child {
border-top: 0;
.notification-row + .notification-row {
border-top: 1px solid #f1f5f9;
}
.notification-row.unread {
border-left-color: var(--theme-primary-active);
background: linear-gradient(90deg, var(--theme-primary-light-9) 0%, #fff 42%);
}
.notification-row:hover {
background: #f8fafc;
}
.notification-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--theme-primary);
.notification-row.unread:hover {
background: linear-gradient(90deg, var(--theme-primary-light-8) 0%, #f8fafc 48%);
}
.notification-dot.danger { background: #ef4444; }
.notification-dot.warning { background: #f59e0b; }
.notification-dot.success { background: var(--success); }
.notification-dot.info { background: #3b82f6; }
.notification-type-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid var(--theme-primary-light-6);
border-radius: 4px;
background: #fff;
color: var(--theme-primary-active);
font-size: 18px;
}
.notification-type-icon.danger {
border-color: #fecaca;
background: #fff5f5;
color: #dc2626;
}
.notification-type-icon.warning {
border-color: #fde68a;
background: #fffbeb;
color: #d97706;
}
.notification-type-icon.success {
border-color: #bbf7d0;
background: #f0fdf4;
color: #16a34a;
}
.notification-type-icon.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.notification-copy {
min-width: 0;
display: grid;
gap: 2px;
gap: 4px;
}
.notification-copy strong,
.notification-copy small,
.notification-copy em {
.notification-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-title-line {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.notification-copy strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
font-weight: 800;
}
.notification-title-line b {
flex: 0 0 auto;
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: 4px;
background: #dc2626;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 1;
}
.notification-copy small {
display: -webkit-box;
overflow: hidden;
color: #475569;
font-size: 12px;
line-height: 1.4;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.notification-copy em {
.notification-meta {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.notification-meta em,
.notification-meta time {
min-width: 0;
overflow: hidden;
color: #94a3b8;
font-size: 11px;
font-style: normal;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-row > .mdi {
color: #94a3b8;
font-size: 16px;
.notification-meta em {
flex: 1 1 auto;
}
.notification-meta time {
flex: 0 0 auto;
}
.notification-row-arrow {
color: #cbd5e1;
font-size: 18px;
transition: color 160ms var(--ease), transform 160ms var(--ease);
}
.notification-row:hover .notification-row-arrow {
color: var(--theme-primary-active);
transform: translateX(2px);
}
.notification-empty {
min-height: 112px;
min-height: 148px;
display: grid;
place-items: center;
gap: 8px;
color: #94a3b8;
font-size: 13px;
justify-items: center;
gap: 6px;
padding: 24px 20px;
text-align: center;
}
.notification-empty .mdi {
font-size: 24px;
.notification-empty-icon {
width: 44px;
height: 44px;
display: grid;
place-items: center;
margin-bottom: 4px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
color: #94a3b8;
font-size: 22px;
}
.notification-empty strong {
color: #334155;
font-size: 14px;
font-weight: 800;
}
.notification-empty > span:last-child {
max-width: 220px;
color: #94a3b8;
font-size: 12px;
line-height: 1.5;
}
.company-switcher {
@@ -867,6 +1147,37 @@
gap: 10px;
}
.notification-popover {
position: fixed;
top: 72px;
right: 12px;
left: 12px;
width: auto;
max-width: none;
max-height: calc(100vh - 92px);
}
.notification-head {
align-items: flex-start;
padding: 11px 12px 9px;
}
.notification-head-actions {
gap: 2px;
}
.notification-clear-btn {
padding: 0 8px;
}
.notification-list {
max-height: calc(100vh - 218px);
}
.notification-row {
padding: 9px 12px;
}
.company-switcher {
flex: 1 1 auto;
max-width: none;
@@ -911,6 +1222,41 @@
}
@media (max-width: 420px) {
.notification-popover {
top: 64px;
right: 8px;
left: 8px;
max-height: calc(100vh - 78px);
}
.notification-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
}
.notification-head-brand {
gap: 8px;
}
.notification-head-icon,
.notification-type-icon {
width: 30px;
height: 30px;
}
.notification-tabs {
padding: 0 10px;
}
.notification-row {
grid-template-columns: 30px minmax(0, 1fr);
gap: 8px;
}
.notification-row-arrow {
display: none;
}
.topbar.detail-mode {
gap: 6px;
padding: 8px 12px;

View File

@@ -291,6 +291,7 @@
.receipt-all-field-grid {
max-height: clamp(360px, 60vh, 640px);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
padding-right: 4px;
overflow-y: auto;
@@ -666,6 +667,7 @@
}
.receipt-static-grid,
.receipt-all-field-grid,
.receipt-data-list.association {
grid-template-columns: 1fr;
}

View File

@@ -1000,6 +1000,13 @@
line-height: 1.45;
}
.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
overflow-y: auto;
resize: none;
}
.currency-editor {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
@@ -1062,6 +1069,37 @@
white-space: nowrap;
}
.expense-adjusted-amount {
display: grid;
justify-items: center;
gap: 3px;
}
.expense-original-amount {
color: #b91c1c;
font-size: 12px;
font-weight: 760;
text-decoration-line: line-through;
text-decoration-thickness: 2px;
text-decoration-color: rgba(185, 28, 28, .82);
white-space: nowrap;
}
.expense-reimbursable-amount {
color: #0f172a;
font-size: 13px;
font-weight: 880;
white-space: nowrap;
}
.expense-adjusted-amount em {
color: #991b1b;
font-size: 11px;
font-style: normal;
font-weight: 760;
white-space: nowrap;
}
.expense-filled-at strong {
font-size: 12px;
white-space: nowrap;
@@ -1950,12 +1988,46 @@
color: #0f172a;
}
.risk-override-card textarea.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
resize: none;
}
.risk-override-card textarea:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, .12);
outline: none;
}
.risk-override-submit-row {
display: grid;
gap: 6px;
}
.risk-override-save-btn {
min-height: 34px;
border: 1px solid #bfdbfe;
border-radius: 4px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 850;
cursor: pointer;
}
.risk-override-save-btn:disabled {
cursor: not-allowed;
opacity: .58;
}
.risk-override-submit-row span {
color: #64748b;
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.validation-card {
border: 1px solid #e5e7eb;
background: #ffffff;

View File

@@ -6,7 +6,7 @@
width="min(1040px, calc(100vw - 48px))"
:show-close="false"
:lock-scroll="true"
:destroy-on-close="false"
destroy-on-close
class="expense-profile-dialog"
modal-class="expense-profile-dialog-overlay"
body-class="expense-profile-dialog-body"

View File

@@ -136,18 +136,19 @@ watch([() => props.visible, tagIdentity], ([visible]) => {
<style scoped>
.profile-tag-pager {
height: 100%;
min-height: 308px;
display: grid;
grid-template-rows: 272px 26px;
grid-template-rows: minmax(0, 1fr) 26px;
gap: 10px;
}
.profile-tag-list {
display: grid;
grid-template-rows: repeat(5, 48px);
align-content: start;
grid-template-rows: repeat(5, minmax(48px, 1fr));
align-content: stretch;
gap: 8px;
min-height: 272px;
min-height: 0;
animation: profileTagPageIn 180ms cubic-bezier(0.2, 0, 0, 1) both;
}
@@ -159,7 +160,7 @@ watch([() => props.visible, tagIdentity], ([visible]) => {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 48px;
min-height: 0;
padding: 7px 10px;
border: 1px solid rgba(var(--tag-accent-rgb), 0.2);
border-left: 3px solid rgb(var(--tag-accent-rgb));

View File

@@ -6,6 +6,7 @@
width="min(980px, calc(100vw - 48px))"
:show-close="false"
:lock-scroll="true"
destroy-on-close
class="expense-stats-detail-dialog"
modal-class="expense-stats-detail-overlay"
body-class="expense-stats-detail-body"
@@ -53,6 +54,7 @@
<div v-if="distributionChartItems.length" class="expense-distribution-chart">
<div class="expense-distribution-chart-layout">
<DonutChart
:key="distributionChartRenderKey"
class="expense-distribution-donut"
:items="distributionChartItems"
:center-value="distributionCenterValue"
@@ -139,7 +141,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
@@ -154,6 +156,7 @@ const props = defineProps({
})
const emit = defineEmits(['close'])
const chartRenderSeq = ref(0)
const summaryMetrics = computed(() => [
{
@@ -198,6 +201,7 @@ const distributionChartColors = [
'var(--theme-primary-active)'
]
const distributionCenterValue = computed(() => props.summary.totalAmountLabel || '¥0')
const distributionChartRenderKey = computed(() => `expense-distribution-${chartRenderSeq.value}`)
const distributionChartItems = computed(() => distributionRows.value.map((row, index) => ({
name: row.label,
value: Number(row.amount || 0),
@@ -205,6 +209,15 @@ const distributionChartItems = computed(() => distributionRows.value.map((row, i
color: distributionChartColors[index % distributionChartColors.length]
})))
watch(
() => props.visible,
(visible) => {
if (visible) {
chartRenderSeq.value += 1
}
}
)
function resolveDistributionColor(index) {
return distributionChartColors[index % distributionChartColors.length]
}

View File

@@ -205,18 +205,23 @@
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
@click="openWorkbenchTarget(item)"
>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
<small>更新</small>
<span class="progress-time-wrapper">
<span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
<small v-if="item.showTimeCapsule" class="time-capsule">更新时间</small>
<small v-if="item.showUpdateText">更新</small>
</span>
</span>
<span class="progress-identity">
<strong>{{ item.id }}</strong>
<small>{{ item.title }}</small>
<strong>{{ item.title }}</strong>
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="item.expenseTypeLabel || '其他费用'">
<small>费用类型</small>
<strong>{{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
@@ -237,8 +242,8 @@
</span>
<span class="progress-result">
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
<strong>{{ item.amount }}</strong>
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
</span>
</button>
</div>
@@ -268,6 +273,9 @@
class="insight-metric-row"
:class="`insight-metric-row--${item.tone}`"
>
<span class="insight-metric-icon" aria-hidden="true">
<i :class="item.icon"></i>
</span>
<span class="insight-metric-label">{{ item.label }}</span>
<strong class="insight-metric-value">
{{ item.value }}<small v-if="item.unit">{{ item.unit }}</small>
@@ -478,6 +486,15 @@ const currentUserProfileKey = computed(() => {
user.employee_no
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
})
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.workbenchSummary.progressItems)
? props.workbenchSummary.progressItems
@@ -488,10 +505,18 @@ const visibleProgressItems = computed(() => {
isLongDuration: isLongDurationProgress(item?.updatedAt)
}))
return progressRows.map((item, index) => ({
...item,
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
}))
return progressRows.map((item, index) => {
const isCompleted = item.statusTone === 'muted';
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel);
return {
...item,
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone,
showTimeCapsule: !item.isLongDuration,
showUpdateText: item.isLongDuration && !isCompleted,
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
}
})
})
const LONG_DURATION_DAYS = 10

View File

@@ -3,12 +3,13 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
import { computed, shallowRef } from 'vue'
import { RadarChart as EChartsRadarChart } from 'echarts/charts'
import { RadarComponent, TooltipComponent } from 'echarts/components'
import { init, use } from 'echarts/core'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([RadarComponent, EChartsRadarChart, TooltipComponent, CanvasRenderer])
@@ -32,8 +33,6 @@ const props = defineProps({
const themeColors = useThemeColors()
const chartElement = shallowRef(null)
let chartInstance = null
let resizeObserver = null
const normalizedItems = computed(() =>
props.items.map((item, index) => ({
@@ -62,8 +61,11 @@ const chartOptions = computed(() => {
return {
backgroundColor: 'transparent',
animationDuration: 760,
animation: true,
animationDuration: 980,
animationDurationUpdate: 760,
animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicOut',
color: [primary],
tooltip: {
trigger: 'item',
@@ -123,6 +125,11 @@ const chartOptions = computed(() => {
{
name: props.label,
type: 'radar',
animation: true,
animationDuration: 980,
animationDurationUpdate: 760,
animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicOut',
symbol: 'circle',
symbolSize: 7,
data: [
@@ -169,56 +176,7 @@ const chartOptions = computed(() => {
}
})
onMounted(() => {
renderChart()
bindResize()
})
onBeforeUnmount(() => {
unbindResize()
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
watch(chartOptions, () => {
nextTick(renderChart)
}, { deep: true })
function renderChart() {
if (!chartElement.value) {
return
}
if (!chartInstance) {
chartInstance = init(chartElement.value, null, { renderer: 'canvas' })
}
chartInstance.setOption(chartOptions.value, true)
chartInstance.resize()
}
function bindResize() {
if (!chartElement.value) {
return
}
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
chartInstance?.resize()
})
resizeObserver.observe(chartElement.value)
}
window.addEventListener('resize', handleResize)
}
function unbindResize() {
resizeObserver?.disconnect()
resizeObserver = null
window.removeEventListener('resize', handleResize)
}
function handleResize() {
chartInstance?.resize()
}
useEcharts(chartElement, chartOptions)
function formatTooltip() {
const rows = normalizedItems.value.map((item) => (

View File

@@ -51,7 +51,6 @@
>
<span class="nav-icon" v-html="item.icon"></span>
<span class="nav-label">{{ item.displayLabel }}</span>
<span v-if="item.hasNewMessage" class="nav-unread-dot" aria-hidden="true"></span>
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
</button>
</ElTooltip>
@@ -113,9 +112,7 @@
<script setup>
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
const props = defineProps({
navItems: { type: Array, required: true },
@@ -144,14 +141,6 @@ const props = defineProps({
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
const {
hasUnread: documentInboxHasUnread,
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling
} = useDocumentCenterInbox()
let inboxInitialRefreshTimer = null
const sidebarMeta = {
overview: { label: '分析看板' },
workbench: { label: '个人工作台' },
@@ -168,35 +157,10 @@ const decoratedNavItems = computed(() =>
props.navItems.map((item) => ({
...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
hasNewMessage: item.id === 'documents' ? documentInboxHasUnread.value : false,
badge: sidebarMeta[item.id]?.badge
}))
)
function clearInboxInitialRefreshTimer() {
if (inboxInitialRefreshTimer && typeof window !== 'undefined') {
window.clearTimeout(inboxInitialRefreshTimer)
inboxInitialRefreshTimer = null
}
}
function scheduleInboxInitialRefresh() {
if (typeof window === 'undefined') {
return
}
clearInboxInitialRefreshTimer()
inboxInitialRefreshTimer = window.setTimeout(() => {
inboxInitialRefreshTimer = null
void refreshDocumentInbox()
}, props.activeView === 'documents' ? 1200 : 6000)
}
onMounted(() => {
scheduleInboxInitialRefresh()
startDocumentInboxPolling()
})
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
@@ -290,19 +254,7 @@ watch(
}
)
watch(
() => props.activeView,
(activeView, previousView) => {
if (activeView === 'documents' && previousView !== 'documents') {
clearInboxInitialRefreshTimer()
void refreshDocumentInbox({ force: true })
}
}
)
onBeforeUnmount(() => {
clearInboxInitialRefreshTimer()
stopDocumentInboxPolling()
closeCollapsedUserMenuNow()
})
</script>

View File

@@ -1,7 +1,7 @@
<template>
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
<div class="title-group">
<div class="eyebrow">{{ eyebrowLabel }}</div>
<div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p>
</div>
@@ -40,10 +40,10 @@
</div>
</div>
<div class="custom-range-wrap">
<button
class="custom-range-btn"
type="button"
<div class="custom-range-wrap">
<button
class="custom-range-btn"
type="button"
:class="{ active: isCustomRange }"
:aria-expanded="calendarOpen"
aria-haspopup="dialog"
@@ -77,126 +77,165 @@
<button class="apply-btn" type="button" :disabled="!canApplyCustomRange" @click="applyCustomRange">
应用
</button>
</footer>
</div>
</div>
<div class="dashboard-switch-wrap">
<EnterpriseSelect
v-model="overviewDashboardValue"
class="dashboard-switch-select"
:options="overviewDashboardOptions"
aria-label="选择看板类型"
size="default"
/>
</div>
</div>
</template>
<template v-else-if="isRequestDetail">
<div class="detail-topbar-actions">
<div v-if="detailKpis.length" class="kpi-chips detail-kpi-chips">
<div
v-for="kpi in detailKpis"
:key="kpi.label"
class="kpi-chip detail-kpi-chip"
:style="{ '--chip-color': kpi.color }"
>
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
<div v-if="detailAlerts.length" class="detail-alert-strip">
<span
v-for="alert in detailAlerts"
:key="alert.label"
class="detail-alert-pill"
:class="alert.tone"
>
<i :class="alert.icon || 'mdi mdi-alert-circle-outline'"></i>
<span>{{ alert.label }}</span>
</span>
</div>
</div>
</template>
</footer>
</div>
</div>
<template v-else-if="isWorkbench">
<div class="topbar-toolset" aria-label="工作台快捷工具">
<div class="notification-wrap">
<button
class="topbar-icon-btn notification-btn"
type="button"
aria-label="通知"
:aria-expanded="notificationOpen"
aria-haspopup="dialog"
@click="notificationOpen = !notificationOpen"
>
<i class="mdi mdi-bell-outline"></i>
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
</button>
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
<header class="notification-head">
<strong>通知</strong>
<button type="button" aria-label="关闭通知" @click="notificationOpen = false">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="notification-tabs" role="tablist" aria-label="通知状态">
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'unread'"
:class="{ active: notificationTab === 'unread' }"
@click="notificationTab = 'unread'"
>
未读 {{ unreadNotifications.length }}
</button>
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'read'"
:class="{ active: notificationTab === 'read' }"
@click="notificationTab = 'read'"
>
已读 {{ readNotifications.length }}
</button>
</div>
<div v-if="activeNotifications.length" class="notification-list">
<button
v-for="item in activeNotifications"
:key="item.id"
type="button"
class="notification-row"
@click="openNotification(item)"
>
<span class="notification-dot" :class="item.tone"></span>
<span class="notification-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small>
<em>{{ item.time }}</em>
</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div v-else class="notification-empty">
<i class="mdi mdi-bell-check-outline"></i>
<span>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</span>
</div>
</div>
</div>
<button class="topbar-icon-btn" type="button" aria-label="帮助">
<i class="mdi mdi-help-circle-outline"></i>
</button>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div class="dashboard-switch-wrap">
<EnterpriseSelect
v-model="overviewDashboardValue"
class="dashboard-switch-select"
:options="overviewDashboardOptions"
aria-label="选择看板类型"
size="default"
/>
</div>
</div>
</template>
<template v-else-if="isRequestDetail">
<div class="detail-topbar-actions">
<div v-if="detailKpis.length" class="kpi-chips detail-kpi-chips">
<div
v-for="kpi in detailKpis"
:key="kpi.label"
class="kpi-chip detail-kpi-chip"
:style="{ '--chip-color': kpi.color }"
>
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
<div v-if="detailAlerts.length" class="detail-alert-strip">
<span
v-for="alert in detailAlerts"
:key="alert.label"
class="detail-alert-pill"
:class="alert.tone"
>
<i :class="alert.icon || 'mdi mdi-alert-circle-outline'"></i>
<span>{{ alert.label }}</span>
</span>
</div>
</div>
</template>
<template v-else-if="isWorkbench">
<div class="topbar-toolset" aria-label="工作台快捷工具">
<div class="notification-wrap" :class="{ 'is-open': notificationOpen }">
<button
class="topbar-icon-btn notification-btn"
type="button"
aria-label="通知"
:aria-expanded="notificationOpen"
aria-haspopup="dialog"
@click="notificationOpen = !notificationOpen"
>
<i class="mdi mdi-bell-outline"></i>
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
</button>
<Transition name="notification-panel">
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
<header class="notification-head">
<div class="notification-head-brand">
<span class="notification-head-icon" aria-hidden="true">
<i class="mdi mdi-bell-ring-outline"></i>
</span>
<span class="notification-head-copy">
<strong>通知中心</strong>
<small>{{ unreadNotifications.length ? `${unreadNotifications.length} 条待处理` : '暂无待处理通知' }}</small>
</span>
</div>
<span class="notification-head-actions">
<button
class="notification-clear-btn"
type="button"
:disabled="notificationItems.length === 0"
@click="clearAllNotifications"
>
清空通知
</button>
<button
class="notification-close-btn"
type="button"
aria-label="关闭通知"
@click="notificationOpen = false"
>
<i class="mdi mdi-close"></i>
</button>
</span>
</header>
<div class="notification-tabs" role="tablist" aria-label="通知状态">
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'unread'"
:class="{ active: notificationTab === 'unread' }"
@click="notificationTab = 'unread'"
>
<span>未读</span>
<em>{{ unreadNotifications.length }}</em>
</button>
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'read'"
:class="{ active: notificationTab === 'read' }"
@click="notificationTab = 'read'"
>
<span>已读</span>
<em>{{ readNotifications.length }}</em>
</button>
</div>
<div v-if="activeNotifications.length" class="notification-list">
<button
v-for="item in activeNotifications"
:key="item.id"
type="button"
class="notification-row"
:class="{ unread: item.unread }"
@click="openNotification(item)"
>
<span class="notification-type-icon" :class="item.tone">
<i :class="resolveNotificationIcon(item)"></i>
</span>
<span class="notification-copy">
<span class="notification-title-line">
<strong>{{ item.title }}</strong>
<b v-if="item.badge">{{ item.badge }}</b>
</span>
<small>{{ item.description }}</small>
<span class="notification-meta">
<em>{{ item.category || '系统通知' }}</em>
<time>{{ item.time }}</time>
</span>
</span>
<i class="mdi mdi-chevron-right notification-row-arrow"></i>
</button>
</div>
<div v-else class="notification-empty">
<span class="notification-empty-icon" aria-hidden="true">
<i class="mdi mdi-bell-check-outline"></i>
</span>
<strong>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</strong>
<span>新的单据与待办会在这里汇总展示</span>
</div>
</div>
</Transition>
</div>
<button class="topbar-icon-btn" type="button" aria-label="帮助">
<i class="mdi mdi-help-circle-outline"></i>
</button>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</template>
@@ -220,24 +259,24 @@
</div>
</template>
<template v-else-if="showDigitalEmployeeWorkRecordKpis">
<div class="kpi-chips">
<div
v-for="kpi in digitalEmployeeWorkRecordKpis"
:key="kpi.label"
class="kpi-chip"
:style="{ '--chip-color': kpi.color }"
>
<span class="chip-value">{{ kpi.value }}<small></small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
</div>
</div>
</template>
<template v-else-if="isApproval">
<div class="kpi-chips">
<div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<template v-else-if="showDigitalEmployeeWorkRecordKpis">
<div class="kpi-chips">
<div
v-for="kpi in digitalEmployeeWorkRecordKpis"
:key="kpi.label"
class="kpi-chip"
:style="{ '--chip-color': kpi.color }"
>
<span class="chip-value">{{ kpi.value }}<small></small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
</div>
</div>
</template>
<template v-else-if="isApproval">
<div class="kpi-chips">
<div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
@@ -273,10 +312,12 @@
</header>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
currentView: { type: Object, required: true },
@@ -292,102 +333,276 @@ const props = defineProps({
type: Object,
default: () => null
},
requestSummary: {
requestSummary: {
type: Object,
default: () => null
},
documentSummary: {
type: Object,
default: () => null
},
digitalEmployeeSummary: {
type: Object,
default: () => null
},
workbenchSummary: {
type: Object,
default: () => null
},
companyName: {
type: String,
default: ''
documentSummary: {
type: Object,
default: () => null
},
digitalEmployeeSummary: {
type: Object,
default: () => null
},
workbenchSummary: {
type: Object,
default: () => null
},
companyName: {
type: String,
default: ''
},
detailMode: {
type: Boolean,
default: false
},
detailAlerts: {
type: Array,
default: () => []
},
detailKpis: {
type: Array,
default: () => []
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
},
overviewDashboard: {
type: String,
default: 'finance'
}
})
const emit = defineEmits([
'update:search',
'update:activeRange',
'update:customRange',
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication',
'openDocument'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder', 'budget'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const eyebrowLabel = computed(() => (
String(props.currentView?.eyebrow || '').trim()
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const topbarNotificationCount = computed(() => {
const summary = props.workbenchSummary ?? {}
const count = Number(summary.unreadNotificationCount ?? 0)
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0
})
const notificationOpen = ref(false)
const notificationTab = ref('unread')
const notificationItems = computed(() => (
Array.isArray(props.workbenchSummary?.notifications)
? props.workbenchSummary.notifications
: []
))
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
function openNotification(item) {
notificationOpen.value = false
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('openDocument', {
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo
})
}
}
detailAlerts: {
type: Array,
default: () => []
},
detailKpis: {
type: Array,
default: () => []
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
},
overviewDashboard: {
type: String,
default: 'finance'
}
})
const emit = defineEmits([
'update:search',
'update:activeRange',
'update:customRange',
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication',
'openDocument',
'navigate'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder', 'budget'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const eyebrowLabel = computed(() => (
String(props.currentView?.eyebrow || '').trim()
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const MAX_NOTIFICATION_ITEMS = 30
const {
markDocumentInboxRowRead,
markDocumentInboxRowsRead,
notificationRows: documentInboxNotificationRows,
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling
} = useDocumentCenterInbox()
let documentInboxInitialRefreshTimer = null
const notificationOpen = ref(false)
const {
readNotificationIds,
hideNotificationStates,
isNotificationHidden,
isNotificationRead,
loadNotificationStates,
markNotificationStateRead
} = useTopBarNotificationStates()
const notificationTab = ref('unread')
function normalizeNotificationId(value) {
return String(value || '').trim()
}
function formatNotificationTime(value) {
const date = new Date(value)
if (!Number.isFinite(date.getTime())) {
return '最近更新'
}
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
function resolveDocumentNotificationTone(row) {
if (row?.source === 'approval') {
return 'warning'
}
return row?.isUnread ? 'info' : 'success'
}
function resolveDocumentNotificationDescription(row) {
return [
row?.title,
row?.initiatorName ? `发起人 ${row.initiatorName}` : '',
row?.statusLabel ? `状态 ${row.statusLabel}` : ''
].filter(Boolean).join(' · ') || '单据中心有新的单据状态'
}
function resolveWorkbenchNotificationId(item, index) {
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
}
const documentNotificationItems = computed(() =>
documentInboxNotificationRows.value
.map((row) => {
const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`)
if (!id || isNotificationHidden(id)) {
return null
}
const unread = Boolean(row.isUnread) && !isNotificationRead(id)
return {
id,
kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row),
time: formatNotificationTime(row.updatedAt || row.createdAt),
category: row.sourceLabel || '单据中心',
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
unread,
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
badge: unread ? '新' : '',
target: {
type: 'document',
id: row.claimId,
claimNo: row.documentNo
},
documentRow: row
}
})
.filter(Boolean)
)
const workbenchNotificationItems = computed(() => (
Array.isArray(props.workbenchSummary?.notifications)
? props.workbenchSummary.notifications.map((item, index) => {
const id = resolveWorkbenchNotificationId(item, index)
if (!id || isNotificationHidden(id)) {
return null
}
return {
...item,
id,
kind: 'workbench',
category: item.category || '个人工作台',
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
icon: item.icon || resolveNotificationIcon(item)
}
}).filter(Boolean)
: []
))
const notificationItems = computed(() =>
[...documentNotificationItems.value, ...workbenchNotificationItems.value].slice(0, MAX_NOTIFICATION_ITEMS)
)
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0
})
function clearDocumentInboxInitialRefreshTimer() {
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
window.clearTimeout(documentInboxInitialRefreshTimer)
documentInboxInitialRefreshTimer = null
}
}
function scheduleDocumentInboxInitialRefresh() {
if (typeof window === 'undefined') {
return
}
clearDocumentInboxInitialRefreshTimer()
documentInboxInitialRefreshTimer = window.setTimeout(() => {
documentInboxInitialRefreshTimer = null
void refreshDocumentInbox()
}, props.activeView === 'workbench' ? 1200 : 6000)
}
function resolveNotificationIcon(item) {
if (item?.icon) {
return item.icon
}
if (item?.tone === 'danger') {
return 'mdi mdi-alert-circle-outline'
}
if (item?.tone === 'warning') {
return 'mdi mdi-alert-outline'
}
if (item?.tone === 'success') {
return 'mdi mdi-check-circle-outline'
}
return 'mdi mdi-bell-outline'
}
function markNotificationRead(item) {
if (!item?.id || !item.unread) {
return
}
if (item.kind === 'document' && item.documentRow) {
markDocumentInboxRowRead(item.documentRow)
}
void markNotificationStateRead(item)
}
function clearAllNotifications() {
const currentItems = notificationItems.value
if (!currentItems.length) {
return
}
const documentRows = currentItems
.filter((item) => item.kind === 'document' && item.documentRow)
.map((item) => item.documentRow)
if (documentRows.length) {
markDocumentInboxRowsRead(documentRows)
}
void hideNotificationStates(currentItems)
notificationTab.value = 'unread'
}
function openNotification(item) {
markNotificationRead(item)
notificationOpen.value = false
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('openDocument', {
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo
})
}
}
const requestKpis = computed(() => {
const summary = props.requestSummary ?? {}
@@ -397,10 +612,10 @@ const requestKpis = computed(() => {
const completed = Number(summary.completed ?? 0)
return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '草稿', value: draft, delta: '待提交', trend: draft > 0 ? 'down' : 'up', arrow: draft > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '审批中', value: inProgress, delta: '处理中', trend: inProgress > 0 ? 'up' : 'down', arrow: inProgress > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' }
{ label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' }
]
})
@@ -412,64 +627,64 @@ const documentKpis = computed(() => {
const archived = Number(summary.archived ?? 0)
return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' }
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' }
]
})
const showDigitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
return isDigitalEmployees.value && summary.section === 'workRecords'
})
const digitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
const total = Number(summary.total ?? 0)
const succeeded = Number(summary.succeeded ?? 0)
const failed = Number(summary.failed ?? 0)
return [
{
label: '日志总数',
value: total,
delta: '当前',
trend: 'up',
arrow: 'mdi mdi-minus',
color: 'var(--theme-primary)'
},
{
label: '成功数量',
value: succeeded,
delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据',
trend: 'up',
arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus',
color: 'var(--success)'
},
{
label: '失败数量',
value: failed,
delta: failed > 0 ? '需要关注' : '暂无失败',
trend: failed > 0 ? 'down' : 'up',
arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus',
color: '#ef4444'
}
]
})
const chatKpis = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
const showDigitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
return isDigitalEmployees.value && summary.section === 'workRecords'
})
const digitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
const total = Number(summary.total ?? 0)
const succeeded = Number(summary.succeeded ?? 0)
const failed = Number(summary.failed ?? 0)
return [
{
label: '日志总数',
value: total,
delta: '当前',
trend: 'up',
arrow: 'mdi mdi-minus',
color: 'var(--theme-primary)'
},
{
label: '成功数量',
value: succeeded,
delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据',
trend: 'up',
arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus',
color: 'var(--success)'
},
{
label: '失败数量',
value: failed,
delta: failed > 0 ? '需要关注' : '暂无失败',
trend: failed > 0 ? 'down' : 'up',
arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus',
color: '#ef4444'
}
]
})
const chatKpis = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
{ label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
]
const approvalKpis = [
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
]
const knowledgeKpis = computed(() => {
@@ -482,7 +697,7 @@ const knowledgeKpis = computed(() => {
value: String(totalDocuments),
meta: '',
trend: 'up',
color: 'var(--theme-primary)'
color: 'var(--theme-primary)'
}
]
})
@@ -503,7 +718,7 @@ const employeeKpis = computed(() => {
unit: '人',
meta: `覆盖 ${departments} 个部门`,
trend: 'up',
color: 'var(--theme-primary)'
color: 'var(--theme-primary)'
},
{
label: '在职账号',
@@ -531,21 +746,21 @@ const employeeKpis = computed(() => {
}
]
})
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)
const overviewDashboardOptions = [
{ label: '财务看板', value: 'finance' },
{ label: '风险看板', value: 'risk' },
{ label: '数字员工看板', value: 'digitalEmployee' },
{ label: '系统看板', value: 'system' }
]
const overviewDashboardValue = computed({
get: () => props.overviewDashboard,
set: (value) => emit('update:overviewDashboard', value)
})
const rangeOptions = computed(() =>
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)
const overviewDashboardOptions = [
{ label: '财务看板', value: 'finance' },
{ label: '风险看板', value: 'risk' },
{ label: '数字员工看板', value: 'digitalEmployee' },
{ label: '系统看板', value: 'system' }
]
const overviewDashboardValue = computed({
get: () => props.overviewDashboard,
set: (value) => emit('update:overviewDashboard', value)
})
const rangeOptions = computed(() =>
props.ranges.map((range, index) => ({
value: range,
label: String(range)
@@ -575,6 +790,34 @@ watch(
{ deep: true }
)
watch(
() => props.activeView,
(activeView, previousView) => {
if (activeView === 'workbench' && previousView !== 'workbench') {
clearDocumentInboxInitialRefreshTimer()
void loadNotificationStates()
void refreshDocumentInbox({ force: true })
}
}
)
watch(notificationOpen, (open) => {
if (open) {
void loadNotificationStates()
}
})
onMounted(() => {
void loadNotificationStates()
scheduleDocumentInboxInitialRefresh()
startDocumentInboxPolling()
})
onBeforeUnmount(() => {
clearDocumentInboxInitialRefreshTimer()
stopDocumentInboxPolling()
})
function setRange(range) {
emit('update:activeRange', range)
calendarOpen.value = false
@@ -631,4 +874,4 @@ function buildPresetRangeLabel(label) {
}
</script>
<style scoped src="../../assets/styles/components/top-bar.css"></style>
<style scoped src="../../assets/styles/components/top-bar.css"></style>

View File

@@ -4,6 +4,9 @@ import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseCla
import {
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
countNewDocuments,
isNewDocument,
markDocumentViewed,
markDocumentsViewed,
readViewedDocumentKeys,
resolveDocumentNewKey
} from '../utils/documentCenterNewState.js'
@@ -24,6 +27,12 @@ let refreshPromise = null
let lastRefreshAt = 0
let viewedKeysListenerAttached = false
const SOURCE_LABELS = {
owned: '我的单据',
approval: '待我处理',
archive: '归档单据'
}
function normalizeClaimText(...values) {
for (const value of values) {
const normalized = String(value || '').trim()
@@ -35,18 +44,41 @@ function normalizeClaimText(...values) {
return ''
}
function resolveSortTime(...values) {
for (const value of values) {
const time = Date.parse(String(value || '').trim())
if (Number.isFinite(time)) {
return time
}
}
return 0
}
function buildDocumentInboxRow(claim, source) {
const request = mapExpenseClaimToRequest(claim)
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
const documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
const documentKey = normalizeClaimText(claimId, documentNo)
const createdAt = normalizeClaimText(request.createdAt, request.submittedAt, claim?.created_at, claim?.submitted_at)
const updatedAt = normalizeClaimText(request.updatedAt, request.approvedAt, claim?.updated_at, claim?.approved_at)
const documentTypeLabel = normalizeClaimText(request.documentTypeLabel, claim?.document_type_label) || '报销单'
return documentKey
? {
source,
claimId: claimId || documentKey,
documentNo,
documentKey: `${source}:${documentKey}`
documentKey: `${source}:${documentKey}`,
documentTypeLabel,
sourceLabel: SOURCE_LABELS[source] || '单据中心',
title: normalizeClaimText(claim?.title, request.title, request.sceneLabel, request.note) || documentTypeLabel,
initiatorName: normalizeClaimText(request.initiatorName, request.person, claim?.employee_name, claim?.applicant_name),
statusLabel: normalizeClaimText(request.approvalStatus, request.status, claim?.approval_status, claim?.status),
createdAt,
updatedAt,
sortTime: resolveSortTime(updatedAt, createdAt),
rawRequest: request
}
: null
}
@@ -127,6 +159,25 @@ export function useDocumentCenterInbox() {
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
const hasUnread = computed(() => unreadCount.value > 0)
const notificationRows = computed(() =>
documentRows.value
.filter((row) => String(row?.source || '').trim() !== 'archive')
.map((row) => ({
...row,
isUnread: isNewDocument(row, viewedDocumentKeys.value)
}))
.sort((left, right) => Number(right.sortTime || 0) - Number(left.sortTime || 0))
)
function markDocumentInboxRowRead(row) {
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
return viewedDocumentKeys.value
}
function markDocumentInboxRowsRead(rows = documentRows.value) {
viewedDocumentKeys.value = markDocumentsViewed(rows, viewedDocumentKeys.value)
return viewedDocumentKeys.value
}
async function refreshDocumentInbox(options = {}) {
const force = Boolean(options.force)
@@ -191,6 +242,9 @@ export function useDocumentCenterInbox() {
return {
hasUnread,
loading,
markDocumentInboxRowRead,
markDocumentInboxRowsRead,
notificationRows,
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling,

View File

@@ -39,6 +39,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
])
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
@@ -83,6 +84,45 @@ function parseNumber(value) {
return Number.isFinite(nextValue) ? nextValue : 0
}
function parseOptionalAmount(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null
}
const amount = Number(value)
return Number.isFinite(amount) && amount >= 0 ? amount : null
}
function buildStandardAdjustmentMapFromClaim(claim = {}) {
const flags = Array.isArray(claim?.risk_flags_json)
? claim.risk_flags_json
: Array.isArray(claim?.riskFlags)
? claim.riskFlags
: []
const adjustmentMap = new Map()
flags.forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
return
}
const itemId = String(flag.item_id || flag.itemId || '').trim()
const reimbursableAmount = parseOptionalAmount(flag.reimbursable_amount ?? flag.reimbursableAmount)
if (!itemId || reimbursableAmount === null) {
return
}
adjustmentMap.set(itemId, {
originalAmount: parseOptionalAmount(flag.original_amount ?? flag.originalAmount),
reimbursableAmount,
employeeAbsorbedAmount: parseOptionalAmount(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0,
message: String(flag.message || flag.summary || '').trim()
})
})
return adjustmentMap
}
function toDate(value) {
if (!value) {
return null
@@ -1272,6 +1312,7 @@ function buildExpenseItems(claim, riskMeta) {
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
})
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
return sortedItems.map((item, index) => {
const invoiceId = String(item?.invoice_id || '').trim()
@@ -1286,6 +1327,10 @@ function buildExpenseItems(claim, riskMeta) {
const itemNote = String(item?.item_note || item?.itemNote || '').trim()
const itemAmount = parseNumber(item?.item_amount)
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
const standardAdjustment = standardAdjustmentMap.get(id) || null
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
return {
id,
@@ -1297,6 +1342,15 @@ function buildExpenseItems(claim, riskMeta) {
itemLocation,
itemNote,
itemAmount,
originalItemAmount,
originalAmountDisplay: originalItemAmount > 0 ? formatAmount(originalItemAmount) : itemAmountDisplay,
reimbursableAmount,
reimbursableAmountDisplay: reimbursableAmount > 0 ? formatAmount(reimbursableAmount) : '待补充',
employeeAbsorbedAmount,
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatAmount(employeeAbsorbedAmount) : '',
hasStandardAdjustment: reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount,
standardAdjustmentAccepted: Boolean(standardAdjustment),
standardAdjustmentMessage: standardAdjustment?.message || '',
invoiceId,
isSystemGenerated,
dayLabel: resolveExpenseTimeLabel({
@@ -1336,7 +1390,10 @@ export function mapExpenseClaimToRequest(claim) {
const riskSummary = riskMeta.summary
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
const expenseItems = buildExpenseItems(claim, riskMeta)
const visibleExpenseAmount = expenseItems.reduce((sum, item) => sum + parseNumber(item.itemAmount), 0)
const visibleExpenseAmount = expenseItems.reduce((sum, item) => {
const amount = parseOptionalAmount(item.reimbursableAmount) ?? parseNumber(item.itemAmount)
return sum + amount
}, 0)
const amountValue = relatedApplication
? expenseItems.length
? visibleExpenseAmount

View File

@@ -0,0 +1,163 @@
import { ref } from 'vue'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
const NOTIFICATION_READ_STORAGE_KEY = 'x-financial.topbar.notifications.read'
const NOTIFICATION_HIDDEN_STORAGE_KEY = 'x-financial.topbar.notifications.hidden'
function normalizeNotificationId(value) {
return String(value || '').trim()
}
function readNotificationIdSet(storageKey) {
if (typeof window === 'undefined') {
return new Set()
}
try {
const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]')
return new Set(Array.isArray(parsed) ? parsed.map(normalizeNotificationId).filter(Boolean) : [])
} catch {
return new Set()
}
}
function writeNotificationIdSet(storageKey, values) {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(storageKey, JSON.stringify(Array.from(values).filter(Boolean)))
}
function mergeRemoteStateIntoSets(states, readIds, hiddenIds) {
for (const item of Array.isArray(states) ? states : []) {
const id = normalizeNotificationId(item?.notification_id || item?.notificationId)
if (!id) {
continue
}
if (item.read_at || item.readAt) {
readIds.add(id)
}
if (item.hidden_at || item.hiddenAt) {
hiddenIds.add(id)
}
}
}
function buildContext(item) {
const target = item?.target || {}
return {
kind: String(item?.kind || '').trim(),
category: String(item?.category || '').trim(),
target_type: String(target?.type || '').trim()
}
}
function buildPatch(item, flags) {
const notificationId = normalizeNotificationId(item?.id || item?.notification_id || item?.notificationId)
if (!notificationId) {
return null
}
return {
notification_id: notificationId,
read: Boolean(flags?.read),
hidden: Boolean(flags?.hidden),
context_json: buildContext(item)
}
}
export function useTopBarNotificationStates() {
const readNotificationIds = ref(readNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY))
const hiddenNotificationIds = ref(readNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY))
const notificationStateSyncing = ref(false)
const notificationStateError = ref('')
function persistLocalSets() {
writeNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY, readNotificationIds.value)
writeNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY, hiddenNotificationIds.value)
}
function applyLocalPatch(patch) {
if (!patch?.notification_id) {
return
}
if (patch.read) {
readNotificationIds.value.add(patch.notification_id)
}
if (patch.hidden) {
hiddenNotificationIds.value.add(patch.notification_id)
}
readNotificationIds.value = new Set(readNotificationIds.value)
hiddenNotificationIds.value = new Set(hiddenNotificationIds.value)
persistLocalSets()
}
function applyRemoteStates(states) {
const nextReadIds = new Set(readNotificationIds.value)
const nextHiddenIds = new Set(hiddenNotificationIds.value)
mergeRemoteStateIntoSets(states, nextReadIds, nextHiddenIds)
readNotificationIds.value = nextReadIds
hiddenNotificationIds.value = nextHiddenIds
persistLocalSets()
}
async function loadNotificationStates() {
notificationStateSyncing.value = true
notificationStateError.value = ''
try {
applyRemoteStates(await fetchNotificationStates())
} catch (error) {
notificationStateError.value = error?.message || '通知状态同步失败'
} finally {
notificationStateSyncing.value = false
}
}
async function syncNotificationPatches(patches) {
const normalizedPatches = (Array.isArray(patches) ? patches : []).filter(Boolean)
if (!normalizedPatches.length) {
return
}
normalizedPatches.forEach(applyLocalPatch)
notificationStateError.value = ''
try {
applyRemoteStates(await patchNotificationStates(normalizedPatches))
} catch (error) {
notificationStateError.value = error?.message || '通知状态同步失败'
}
}
function isNotificationHidden(id) {
return hiddenNotificationIds.value.has(normalizeNotificationId(id))
}
function isNotificationRead(id) {
return readNotificationIds.value.has(normalizeNotificationId(id))
}
function markNotificationStateRead(item) {
return syncNotificationPatches([buildPatch(item, { read: true })])
}
function hideNotificationStates(items) {
const patches = (Array.isArray(items) ? items : [])
.map((item) => buildPatch(item, { read: true, hidden: true }))
.filter(Boolean)
return syncNotificationPatches(patches)
}
return {
hiddenNotificationIds,
readNotificationIds,
notificationStateSyncing,
notificationStateError,
hideNotificationStates,
isNotificationHidden,
isNotificationRead,
loadNotificationStates,
markNotificationStateRead
}
}

View File

@@ -196,42 +196,48 @@ export function buildExpenseStatItems(summary = {}) {
label: '本月报销笔数',
value: summary.monthlyCount ?? 0,
unit: '笔',
tone: 'primary'
tone: 'primary',
icon: 'mdi mdi-text-box-check-outline'
},
{
key: 'monthly-amount',
label: '本月报销金额',
value: summary.monthlyAmountLabel || '¥0',
unit: '',
tone: 'amount'
tone: 'amount',
icon: 'mdi mdi-cash-multiple'
},
{
key: 'total-count',
label: '累计报销笔数',
value: summary.totalCount ?? 0,
unit: '笔',
tone: 'muted'
tone: 'muted',
icon: 'mdi mdi-clipboard-text-outline'
},
{
key: 'total-amount',
label: '累计报销金额',
value: summary.totalAmountLabel || '¥0',
unit: '',
tone: 'amount'
tone: 'amount',
icon: 'mdi mdi-bank-outline'
},
{
key: 'in-review',
label: '审批中单据',
value: summary.inReviewCount ?? 0,
unit: '笔',
tone: 'warning'
tone: 'warning',
icon: 'mdi mdi-timer-sand'
},
{
key: 'pending-payment',
label: '待付款单据',
value: summary.pendingPaymentCount ?? 0,
unit: '笔',
tone: 'info'
tone: 'info',
icon: 'mdi mdi-credit-card-outline'
}
]
}

View File

@@ -0,0 +1,38 @@
import { apiRequest } from './api.js'
function normalizeStateItem(item) {
const notificationId = String(item?.notification_id || item?.notificationId || '').trim()
if (!notificationId) {
return null
}
return {
notification_id: notificationId,
read: Boolean(item?.read),
hidden: Boolean(item?.hidden),
context_json: item?.context_json && typeof item.context_json === 'object'
? item.context_json
: {}
}
}
export async function fetchNotificationStates() {
const payload = await apiRequest('/notification-states')
return Array.isArray(payload?.states) ? payload.states : []
}
export async function patchNotificationStates(states = []) {
const normalizedStates = (Array.isArray(states) ? states : [])
.map(normalizeStateItem)
.filter(Boolean)
if (!normalizedStates.length) {
return []
}
const payload = await apiRequest('/notification-states', {
method: 'POST',
body: JSON.stringify({ states: normalizedStates })
})
return Array.isArray(payload?.states) ? payload.states : []
}

View File

@@ -68,6 +68,13 @@ export function updateExpenseClaim(claimId, payload = {}) {
})
}
export function acceptExpenseClaimStandardAdjustment(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/standard-adjustment`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function calculateTravelReimbursement(payload = {}) {
return apiRequest('/reimbursements/travel-calculator', {
method: 'POST',

View File

@@ -230,18 +230,22 @@ export function isCurrentDirectManagerForRequest(request, user) {
return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
return false
}
if (viewId === 'budget') {
if (isPlatformAdminUser(user)) {
return true
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
return false
}
if (viewId === 'workbench' && isPlatformAdminUser(user)) {
return false
}
if (viewId === 'budget') {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
@@ -268,7 +272,11 @@ export function filterNavItemsByAccess(navItems, user) {
return navItems.filter((item) => canAccessAppView(user, item.id))
}
export function resolveDefaultAuthorizedRoute(user) {
const firstVisibleView = getAccessibleViewIds(user)[0]
return { name: `app-${firstVisibleView || 'workbench'}` }
}
export function resolveDefaultAuthorizedRoute(user) {
if (isPlatformAdminUser(user) && canAccessAppView(user, 'overview')) {
return { name: 'app-overview' }
}
const firstVisibleView = getAccessibleViewIds(user)[0]
return { name: `app-${firstVisibleView || 'workbench'}` }
}

View File

@@ -77,3 +77,26 @@ export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
writeViewedDocumentKeys(nextKeys, storage)
return nextKeys
}
export function markDocumentsViewed(rows, viewedKeys, storage = getStorage()) {
const nextKeys = new Set(viewedKeys)
let changed = false
;(Array.isArray(rows) ? rows : []).forEach((row) => {
if (!isNewDocument(row, nextKeys)) {
return
}
const key = resolveDocumentNewKey(row)
if (key) {
nextKeys.add(key)
changed = true
}
})
if (changed) {
writeViewedDocumentKeys(nextKeys, storage)
}
return nextKeys
}

View File

@@ -0,0 +1,24 @@
function normalizeSortTime(value) {
const time = Number(value)
return Number.isFinite(time) ? time : 0
}
export function compareDocumentRowsByLatestTime(left, right) {
const latestDiff = normalizeSortTime(right?.sortTime) - normalizeSortTime(left?.sortTime)
if (latestDiff !== 0) {
return latestDiff
}
const createdDiff = normalizeSortTime(right?.createdSortTime) - normalizeSortTime(left?.createdSortTime)
if (createdDiff !== 0) {
return createdDiff
}
const rightKey = String(right?.documentKey || right?.documentNo || '').trim()
const leftKey = String(left?.documentKey || left?.documentNo || '').trim()
return rightKey.localeCompare(leftKey, 'zh-CN')
}
export function sortDocumentRowsByLatestTime(rows) {
return (Array.isArray(rows) ? [...rows] : []).sort(compareDocumentRowsByLatestTime)
}

View File

@@ -400,6 +400,22 @@ function normalizeTransportModeOption(value, fallback = '') {
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
}
function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', currentFields = {}) {
const currentTransportMode = isApplicationPreviewValueProvided(currentFields.transportMode)
? String(currentFields.transportMode).trim()
: ''
const explicitTransportMode = resolveApplicationTransportMode(rawText)
if (!explicitTransportMode) {
return currentTransportMode
}
const ontologyTransportMode = normalizeTransportModeOption(ontologyFields.transportMode, '')
if (ontologyTransportMode && ontologyTransportMode === explicitTransportMode) {
return ontologyTransportMode
}
return currentTransportMode || explicitTransportMode
}
function normalizeAmountFromOntology(fields = {}, fallback = '') {
const numericAmount = Number(fields.amount || 0)
if (Number.isFinite(numericAmount) && numericAmount > 0) {
@@ -640,10 +656,7 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
location: resolveProvidedValue(ontologyFields.location, currentFields.location),
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
transportMode: normalizeTransportModeOption(
ontologyFields.transportMode,
currentFields.transportMode
),
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),

View File

@@ -194,10 +194,11 @@ function buildTodoItems(ownedRequests) {
.sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due)))
}
function resolveProgressStatusTone(approvalKey) {
if (approvalKey === 'completed') return 'muted'
if (approvalKey === 'pending_payment') return 'warning'
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
function resolveProgressStatusTone(approvalKey, statusText = '') {
const status = String(statusText || '').trim()
if (approvalKey === 'completed' || /完成|结束|通过/i.test(status)) return 'muted'
if (approvalKey === 'pending_payment' || /付款|支付/i.test(status)) return 'warning'
if (approvalKey === 'supplement' || approvalKey === 'rejected' || /退回|驳回|修改/i.test(status)) return 'danger'
return 'success'
}
@@ -243,14 +244,15 @@ function buildProgressItems(ownedRequests) {
const currentStep = steps.find((step) => step.current)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
return {
id: requestId,
requestId,
title,
expenseTypeLabel: resolveExpenseCategory(request),
amount: formatCurrency(request?.amount),
status: normalizeText(request?.approvalStatus || currentStep?.label) || '处理中',
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
status,
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
steps,
target: resolveRequestTarget(request),
@@ -391,12 +393,14 @@ function buildExpenseProcessingRows(ownedRequests) {
const latestAt = dates[dates.length - 1] || toDate(request?.updatedAt || request?.submittedAt || request?.createdAt)
const stepCount = Array.isArray(request?.progressSteps) ? request.progressSteps.length : 0
const status = normalizeText(request?.approvalStatus || request?.status) || '处理中'
return {
id: requestId || title,
requestId,
title,
status: normalizeText(request?.approvalStatus || request?.status) || '处理中',
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
status,
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
startedAt: startedAt ? formatDateTimeLabel(startedAt) : '暂无开始时间',
updatedAt: latestAt ? formatDateTimeLabel(latestAt) : '暂无更新时间',
durationLabel: startedAt && latestAt ? formatDurationLabel(latestAt.getTime() - startedAt.getTime()) : '暂无耗时',

View File

@@ -86,6 +86,7 @@
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument"
@navigate="handleNavigate"
/>
<FilterBar

View File

@@ -9,9 +9,11 @@
:class="{ active: activeScopeTab === tab.value }"
@click="activeScopeTab = tab.value"
>
<span>{{ tab.label }}</span>
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
<span class="scope-tab-label">
{{ tab.label }}
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
</span>
</span>
</button>
</nav>
@@ -122,7 +124,16 @@
</div>
</div>
<div v-if="[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT].includes(activeScopeTab)" class="document-actions">
<div v-if="showToolbarActions" class="document-actions">
<button
v-if="totalNewDocumentCount > 0"
class="mark-read-btn"
type="button"
@click="markAllDocumentsRead"
>
<i class="mdi mdi-check-all"></i>
<span>一键已读</span>
</button>
<button v-if="activeScopeTab === DOCUMENT_SCOPE_APPLICATION" class="create-request-btn" type="button" @click="emit('create-application')">
<i class="mdi mdi-file-plus-outline"></i>
<span>发起申请</span>
@@ -238,7 +249,8 @@ import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, markDocumentsViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import { sortDocumentRowsByLatestTime } from '../utils/documentCenterSort.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
@@ -468,6 +480,17 @@ const scopeTabItems = computed(() =>
badgeCount: scopeNewCountMap.value[tab] || 0
}))
)
const allReadableDocumentRows = computed(() => [
...nonArchivedRows.value,
...filterApplicationScopeNewRows(applicationScopeRows.value),
...ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT),
...approvalRows.value
])
const totalNewDocumentCount = computed(() => countNewDocuments(allReadableDocumentRows.value, viewedDocumentKeys.value))
const showCreateDocumentActions = computed(() =>
[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT].includes(activeScopeTab.value)
)
const showToolbarActions = computed(() => showCreateDocumentActions.value || totalNewDocumentCount.value > 0)
const activeScopeRows = computed(() => {
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
@@ -513,7 +536,7 @@ const statusFilterLabel = computed(() =>
const filteredRows = computed(() => {
const keyword = listKeyword.value.trim().toLowerCase()
return activeScopeRows.value.filter((row) => {
return sortDocumentRowsByLatestTime(activeScopeRows.value.filter((row) => {
const matchesKeyword = !keyword || [
row.documentNo,
row.documentTypeLabel,
@@ -534,7 +557,7 @@ const filteredRows = computed(() => {
const matchesDateRange = matchesAppliedDateRange(row)
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange
})
}))
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
@@ -631,6 +654,8 @@ function buildDocumentRow(request, options = {}) {
const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
const createdSortTime = resolveDocumentSortTime(createdAtSource)
const updatedSortTime = resolveDocumentSortTime(updatedAtSource)
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
const documentTypeLabel =
normalized.documentTypeLabel
@@ -667,7 +692,9 @@ function buildDocumentRow(request, options = {}) {
? false
: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
sortTime: resolveDocumentSortTime(updatedAtSource)
createdSortTime,
updatedSortTime,
sortTime: Math.max(createdSortTime, updatedSortTime)
}
}
@@ -729,7 +756,7 @@ function mergeDocumentRows(rows) {
}
})
return Array.from(rowMap.values()).sort((left, right) => right.sortTime - left.sortTime)
return sortDocumentRowsByLatestTime(Array.from(rowMap.values()))
}
function resolveSourcePriority(row) {
@@ -831,6 +858,14 @@ function openDocument(row) {
emit('open-document', row.rawRequest || row)
}
function markAllDocumentsRead() {
if (!totalNewDocumentCount.value) {
return
}
viewedDocumentKeys.value = markDocumentsViewed(allReadableDocumentRows.value, viewedDocumentKeys.value)
}
async function loadSupportingRows() {
supportingLoading.value = true
supportingError.value = ''

View File

@@ -130,16 +130,6 @@
<i class="mdi mdi-robot-outline"></i>
<span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span>
</button>
<button
v-if="isEditableRequest"
class="smart-entry-btn secondary"
type="button"
:disabled="actionBusy"
@click="handleAddExpenseItem"
>
<i class="mdi mdi-plus-circle-outline"></i>
<span>{{ creatingExpense ? '新增中' : '增加明细' }}</span>
</button>
</div>
</div>
<div v-if="isApplicationDocument" class="application-detail-facts">
@@ -283,7 +273,12 @@
</div>
</template>
<template v-else>
<strong>{{ item.amount }}</strong>
<div v-if="item.hasStandardAdjustment" class="expense-adjusted-amount">
<span class="expense-original-amount">{{ item.originalAmountDisplay || item.amount }}</span>
<strong class="expense-reimbursable-amount">{{ item.reimbursableAmountDisplay }}</strong>
<em v-if="item.employeeAbsorbedAmountDisplay">自担 {{ item.employeeAbsorbedAmountDisplay }}</em>
</div>
<strong v-else>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</template>
</td>
@@ -380,9 +375,11 @@
<div class="cell-editor">
<textarea
v-model="expenseEditor.itemNote"
class="editor-textarea"
rows="3"
class="editor-textarea risk-note-editor-textarea"
rows="1"
placeholder="如票据存在异常或风险,请补充原因"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea>
<span>用于说明改签绕行超标票据异常等情况</span>
</div>
@@ -439,7 +436,7 @@
</template>
<tr v-if="!expenseItems.length" class="empty-row">
<td :colspan="expenseTableColumnCount" class="empty-row-cell">
当前还没有费用明细点击右上角增加明细继续补充
当前还没有费用明细请通过智能录入上传票据后由系统自动归集
</td>
</tr>
</tbody>
@@ -774,7 +771,7 @@
</div>
<div class="submit-confirm-row">
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
<strong>{{ submitConfirmAmountDisplay }}</strong>
</div>
<div v-if="!isApplicationDocument" class="submit-confirm-row">
<span>费用明细</span>
@@ -784,20 +781,20 @@
</ConfirmDialog>
<ConfirmDialog
:open="riskOverrideDialogOpen"
badge="重大风险"
badge="异常说明"
badge-tone="danger"
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
description="如仍需提交审批,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。"
:title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`"
description="请先补充异常说明后提交领导审批;也可以不填写说明,选择按职级最高可报销金额重新计算。"
cancel-text="返回整改"
confirm-text="保存原因并继续"
busy-text="保存中..."
confirm-text="按职级标准重算"
busy-text="处理中..."
confirm-tone="danger"
confirm-icon="mdi mdi-alert-circle-outline"
confirm-icon="mdi mdi-calculator-variant-outline"
:busy="riskOverrideBusy"
@close="closeRiskOverrideDialog"
@confirm="confirmRiskOverrideReasons"
@confirm="confirmStandardAdjustment"
>
<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">
<button
type="button"
@@ -827,11 +824,26 @@
<p>{{ currentSubmitRiskWarning.risk }}</p>
<textarea
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
class="risk-note-editor-textarea"
rows="1"
maxlength="160"
placeholder="请说明为什么仍需提交,例如客户指定酒店、会议高峰、协议酒店满房等"
aria-label="违规提交原因"
placeholder="请说明原因,例如客户指定酒店、会议高峰、协议酒店满房等"
aria-label="异常说明"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea>
</article>
<div class="risk-override-submit-row">
<button
class="risk-override-save-btn"
type="button"
:disabled="riskOverrideBusy"
@click="confirmRiskOverrideReasons"
>
保存说明并继续提交
</button>
<span>不填写说明时系统会按职级最高报销标准重算金额</span>
</div>
</div>
</ConfirmDialog>
<TravelRequestDeleteDialog :open="deleteDialogOpen" :badge="deleteActionLabel" :title="deleteDialogTitle" :description="deleteDialogDescription" :busy="deleteBusy" @close="closeDeleteDialog" @confirm="confirmDeleteRequest" />

View File

@@ -10,7 +10,9 @@ import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDele
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
acceptExpenseClaimStandardAdjustment,
approveExpenseClaim,
calculateTravelReimbursement,
createExpenseClaimItem,
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
@@ -88,6 +90,13 @@ import {
resolveSubmitConfirmDescription,
resolveSubmitConfirmText
} from './travelRequestDetailSubmitModel.js'
import {
buildCurrentStandardAdjustmentMap,
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel,
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel
} from './travelRequestDetailStandardAdjustment.js'
import {
buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts
@@ -590,7 +599,6 @@ export default {
const { currentUser } = useSystemState()
const editingExpenseId = ref('')
const savingExpenseId = ref('')
const creatingExpense = ref(false)
const uploadingExpenseId = ref('')
const deletingAttachmentId = ref('')
const deletingExpenseId = ref('')
@@ -898,7 +906,6 @@ export default {
|| returnBusy.value
|| approveBusy.value
|| payBusy.value
|| creatingExpense.value
|| smartEntryRecognitionBusy.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
@@ -996,9 +1003,16 @@ export default {
}
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
const total = expenseItems.value.reduce((sum, item) => {
const adjustedAmount = Number(item.reimbursableAmount)
const originalAmount = Number(item.itemAmount || 0)
return sum + (Number.isFinite(adjustedAmount) ? adjustedAmount : originalAmount)
}, 0)
return formatCurrency(total)
})
const submitConfirmAmountDisplay = computed(() =>
isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value
)
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
@@ -1086,7 +1100,7 @@ export default {
return `已选择 ${names.length} 张附件`
})
const smartEntryUploadBusy = computed(() =>
smartEntryUploadDialogOpen.value && (creatingExpense.value || Boolean(uploadingExpenseId.value))
smartEntryUploadDialogOpen.value && Boolean(uploadingExpenseId.value)
)
const attachmentPreviewEntries = computed(() =>
expenseItems.value
@@ -1157,6 +1171,65 @@ export default {
return requestFlags
}
function resolveCurrentStandardAdjustmentMap() {
return buildCurrentStandardAdjustmentMap(request.value, resolveClaimRiskFlags())
}
function resolveExpenseItemForRiskCard(card) {
return resolveExpenseItemForRiskCardModel(card, expenseItems.value)
}
function filterSubmitterResolvedRiskCards(cards, businessStage) {
const viewerContext = riskViewerContext.value || {}
return filterSubmitterResolvedRiskCardsModel({
cards,
businessStage,
isCurrentApplicant: isCurrentApplicant.value,
isPrivilegedRiskViewer: Boolean(
viewerContext.isAdminViewer
|| viewerContext.isBudgetReviewer
|| viewerContext.isDirectManagerReviewer
|| viewerContext.isFinanceReviewer
|| viewerContext.canViewApprovalRiskAdvice
),
expenseItems: expenseItems.value,
standardAdjustmentMap: resolveCurrentStandardAdjustmentMap()
})
}
function isRiskCardMissingExpenseNote(card) {
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
}
async function buildStandardAdjustmentPayload() {
return buildStandardAdjustmentPayloadModel({
warnings: submitRiskWarnings.value,
expenseItems: expenseItems.value,
request: request.value,
calculateTravelReimbursement
})
}
function applyStandardAdjustmentResponse(payload = {}) {
const flags = Array.isArray(payload?.risk_flags_json)
? payload.risk_flags_json
: Array.isArray(payload?.riskFlags)
? payload.riskFlags
: resolveClaimRiskFlags()
riskFlagPreviewSnapshot.value = {
claimId: request.value.claimId,
riskFlags: flags
}
const sourceItems = Array.isArray(payload?.items) && payload.items.length
? payload.items
: expenseItems.value
expenseItems.value = rebuildExpenseItems(sourceItems, {
...request.value,
riskFlags: flags,
risk_flags_json: flags
})
}
function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim()
@@ -1532,7 +1605,7 @@ export default {
: []
const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards
...filterSubmitterResolvedRiskCards(directRiskCards, currentBusinessStage)
]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
@@ -1654,7 +1727,8 @@ export default {
const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.filter((card) => isRiskCardMissingExpenseNote(card))
.map((card, index) => ({
...card,
id: String(card.id || `submit-risk-${index}`),
@@ -1665,7 +1739,6 @@ export default {
const riskOverrideIndexLabel = computed(() =>
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
)
const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk'))
function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value
@@ -1724,6 +1797,18 @@ export default {
riskOverrideDialogOpen.value = false
}
function resizeExpenseNoteInput(event) {
const target = event?.target
if (!target || typeof window === 'undefined') {
return
}
const style = window.getComputedStyle(target)
const lineHeight = Number.parseFloat(style.lineHeight) || 18
const maxHeight = lineHeight * 3 + 18
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
}
function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) {
return
@@ -1739,17 +1824,6 @@ export default {
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
}
function buildRiskOverrideAppendix() {
return submitRiskWarnings.value
.map((risk, index) => {
const reason = String(riskOverrideReasons[risk.id] || '').trim()
const tags = resolveRiskTags(risk).join(' ')
const title = String(risk.title || risk.label || '重大风险').trim()
return `超标说明:${tags}${index + 1}${title}${reason}`
})
.join('\n')
}
function mergeDetailNoteWithRiskOverride(appendix) {
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
@@ -1762,28 +1836,91 @@ export default {
const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
if (missingIndex >= 0) {
riskOverrideIndex.value = missingIndex
toast('请为每一条重大风险填写违规提交原因。')
toast('请为每一条风险填写异常说明。')
return
}
const appendix = buildRiskOverrideAppendix()
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
if (nextNote.length > 500) {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
return
}
const itemNoteGroups = new Map()
const claimLevelRisks = []
submitRiskWarnings.value.forEach((risk, index) => {
const reason = String(riskOverrideReasons[risk.id] || '').trim()
const item = resolveExpenseItemForRiskCard(risk)
if (item?.id) {
const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] }
currentGroup.reasons.push(reason)
itemNoteGroups.set(item.id, currentGroup)
} else {
const title = String(risk.title || risk.label || '风险').trim()
claimLevelRisks.push(`异常说明:第${index + 1}${title}${reason}`)
}
})
riskOverrideBusy.value = true
try {
await updateExpenseClaim(request.value.claimId, {
reason: nextNote
await Promise.all(
[...itemNoteGroups.entries()].map(([itemId, group]) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
return updateExpenseClaimItem(request.value.claimId, itemId, {
item_note: nextNote
})
})
)
itemNoteGroups.forEach((group, itemId) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
applyLocalExpenseItemPatch(itemId, {
itemNote: nextNote
})
})
detailNoteEditor.value = nextNote
if (claimLevelRisks.length) {
const appendix = claimLevelRisks.join('\n')
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
if (nextNote.length > 500) {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
return
}
await updateExpenseClaim(request.value.claimId, {
reason: nextNote
})
detailNoteEditor.value = nextNote
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
toast('违规提交原因已写入附加说明。')
toast('异常说明已保存,可继续提交审批。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '风险原因保存失败,请稍后重试。')
toast(error?.message || '异常说明保存失败,请稍后重试。')
} finally {
riskOverrideBusy.value = false
}
}
async function confirmStandardAdjustment() {
if (riskOverrideBusy.value) {
return
}
riskOverrideBusy.value = true
try {
const payload = await buildStandardAdjustmentPayload()
if (!payload.risks.length) {
toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。')
return
}
const response = await acceptExpenseClaimStandardAdjustment(request.value.claimId, payload)
applyStandardAdjustmentResponse(response)
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
toast('已按职级最高报销标准重算实际报销金额。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '按职级标准重算失败,请稍后重试。')
} finally {
riskOverrideBusy.value = false
}
@@ -1811,6 +1948,10 @@ export default {
}
populateExpenseEditor(item)
void nextTick(() => {
const textarea = document.querySelector('.risk-note-editor-textarea')
resizeExpenseNoteInput({ target: textarea })
})
}
function validateExpenseEditor() {
@@ -1839,48 +1980,6 @@ export default {
return ''
}
async function createDraftExpenseItem({ openEditor = true } = {}) {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法新增费用明细。')
return null
}
creatingExpense.value = true
try {
const existingIds = new Set(expenseItems.value.map((item) => item.id))
const claim = await createExpenseClaimItem(request.value.claimId, {})
const createdItem = Array.isArray(claim?.items)
? claim.items.find((entry) => !existingIds.has(String(entry?.id || '')))
: null
if (!createdItem) {
throw new Error('新增费用明细失败,请稍后重试。')
}
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
creatingExpense.value = false
if (openEditor) {
startExpenseEdit(nextItem)
toast('已新增一条费用明细,请继续填写。')
}
return nextItem
} catch (error) {
toast(error?.message || '新增费用明细失败,请稍后重试。')
return null
} finally {
creatingExpense.value = false
}
}
async function handleAddExpenseItem() {
if (!isEditableRequest.value || actionBusy.value) {
return
}
await createDraftExpenseItem({ openEditor: true })
}
function triggerSmartEntryUpload() {
if (!isEditableRequest.value || actionBusy.value) {
return
@@ -2281,6 +2380,11 @@ export default {
return
}
if (submitRiskWarnings.value.length) {
openRiskOverrideDialog()
return
}
submitConfirmDialogOpen.value = true
}
@@ -2584,18 +2688,18 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload,
confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
focusExpenseRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
handleModifyApplication,
handlePayRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
@@ -2606,7 +2710,7 @@ export default {
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
@@ -2618,7 +2722,7 @@ export default {
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}
}

View File

@@ -13,7 +13,10 @@ const APPLICATION_TYPE_ALIASES = {
}
const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application'])
const BLOCKED_APPLICATION_STATUSES = new Set(['draft', 'returned', 'rejected', 'cancelled', 'canceled', 'deleted'])
const APPROVED_APPLICATION_STATUSES = new Set(['approved', 'completed'])
const APPROVED_APPLICATION_APPROVAL_KEYS = new Set(['completed'])
const BLOCKED_APPLICATION_LINK_STATUSES = new Set(['draft', 'returned', 'rejected', 'archived', 'cancelled', 'canceled', 'deleted'])
const INACTIVE_REIMBURSEMENT_LINK_STATUSES = new Set(['cancelled', 'canceled', 'deleted'])
const STATUS_LABELS = {
submitted: '审批中',
@@ -53,6 +56,10 @@ function normalizeApplicationStatus(claim) {
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
}
function normalizeApprovalKey(claim) {
return normalizeLower(claim?.approvalKey || claim?.approval_key)
}
function normalizeDocumentType(claim) {
return normalizeLower(
claim?.document_type_code
@@ -141,6 +148,99 @@ function resolveApplicationDetailPayload(claim) {
return detail && typeof detail === 'object' ? detail : {}
}
function resolveRiskFlags(claim) {
const flags = claim?.risk_flags_json || claim?.riskFlags || []
return Array.isArray(flags) ? flags : []
}
function createReferenceIndex() {
return {
ids: new Set(),
claimNos: new Set()
}
}
function addApplicationReference(index, idValue, claimNoValue) {
const id = normalizeText(idValue)
if (id) {
index.ids.add(id)
}
const claimNo = normalizeText(claimNoValue).toUpperCase()
if (claimNo) {
index.claimNos.add(claimNo)
}
}
function addApplicationReferencesFromPayload(index, payload) {
if (!payload || typeof payload !== 'object') {
return
}
addApplicationReference(
index,
payload.application_claim_id || payload.applicationClaimId || payload.id,
payload.application_claim_no || payload.applicationClaimNo || payload.claim_no || payload.claimNo
)
}
function collectLinkedApplicationReferences(claim) {
const index = createReferenceIndex()
addApplicationReferencesFromPayload(index, claim?.relatedApplication)
addApplicationReferencesFromPayload(index, claim?.related_application)
resolveRiskFlags(claim).forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
addApplicationReferencesFromPayload(index, flag)
addApplicationReferencesFromPayload(index, flag.application_detail || flag.applicationDetail)
addApplicationReferencesFromPayload(index, flag.review_form_values || flag.reviewFormValues)
addApplicationReferencesFromPayload(index, flag.expense_scene_selection || flag.expenseSceneSelection)
})
return index
}
function hasAnyApplicationReference(index) {
return Boolean(index?.ids?.size || index?.claimNos?.size)
}
function buildLinkedApplicationReferenceIndex(claims) {
const index = createReferenceIndex()
;(Array.isArray(claims) ? claims : []).forEach((claim) => {
if (isExpenseApplicationClaim(claim)) {
return
}
const status = normalizeApplicationStatus(claim)
if (INACTIVE_REIMBURSEMENT_LINK_STATUSES.has(status)) {
return
}
const claimReferences = collectLinkedApplicationReferences(claim)
claimReferences.ids.forEach((id) => index.ids.add(id))
claimReferences.claimNos.forEach((claimNo) => index.claimNos.add(claimNo))
})
return index
}
function isApplicationAlreadyLinked(claim, linkedApplicationReferences) {
if (!linkedApplicationReferences) {
return false
}
const ownReferences = createReferenceIndex()
addApplicationReference(
ownReferences,
claim?.id || claim?.claim_id || claim?.claimId,
claim?.claim_no || claim?.claimNo
)
if (!hasAnyApplicationReference(ownReferences)) {
return false
}
return Array.from(ownReferences.ids).some((id) => linkedApplicationReferences.ids.has(id))
|| Array.from(ownReferences.claimNos).some((claimNo) => linkedApplicationReferences.claimNos.has(claimNo))
}
function toTimestamp(value) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
@@ -265,9 +365,14 @@ export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
return true
}
export function isUsableRequiredApplicationClaim(claim) {
export function isUsableRequiredApplicationClaim(claim, linkedApplicationReferences = null) {
const status = normalizeApplicationStatus(claim)
return !BLOCKED_APPLICATION_STATUSES.has(status)
const approvalKey = normalizeApprovalKey(claim)
if (BLOCKED_APPLICATION_LINK_STATUSES.has(status)) {
return false
}
return (APPROVED_APPLICATION_STATUSES.has(status) || APPROVED_APPLICATION_APPROVAL_KEYS.has(approvalKey))
&& !isApplicationAlreadyLinked(claim, linkedApplicationReferences)
}
export function normalizeRequiredApplicationCandidate(claim) {
@@ -331,10 +436,12 @@ export function filterRequiredApplicationCandidates(claimsPayload, expenseType,
? claimsPayload.claims
: []
const linkedApplicationReferences = buildLinkedApplicationReferenceIndex(claims)
return claims
.filter((claim) => (
isExpenseApplicationClaim(claim)
&& isUsableRequiredApplicationClaim(claim)
&& isUsableRequiredApplicationClaim(claim, linkedApplicationReferences)
&& isClaimOwnedByCurrentUser(claim, currentUser)
&& matchesRequiredApplicationExpenseType(claim, expenseType)
))

View File

@@ -32,11 +32,24 @@ export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flig
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/
export const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
export function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function parseOptionalCurrency(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null
}
const normalized = String(value).replace(/[^\d.]/g, '')
if (!normalized) {
return null
}
const amount = Number.parseFloat(normalized)
return Number.isFinite(amount) && amount >= 0 ? amount : null
}
export function formatCurrency(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
@@ -395,6 +408,60 @@ export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, reque
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
}
export function buildStandardAdjustmentMap(requestModel = {}) {
const flags = Array.isArray(requestModel?.riskFlags)
? requestModel.riskFlags
: Array.isArray(requestModel?.risk_flags_json)
? requestModel.risk_flags_json
: []
const adjustmentMap = new Map()
flags.forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
return
}
const itemId = String(flag.item_id || flag.itemId || '').trim()
if (!itemId) {
return
}
const originalAmount = parseOptionalCurrency(flag.original_amount ?? flag.originalAmount)
const reimbursableAmount = parseOptionalCurrency(flag.reimbursable_amount ?? flag.reimbursableAmount)
if (reimbursableAmount === null) {
return
}
const employeeAbsorbedAmount = parseOptionalCurrency(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0
adjustmentMap.set(itemId, {
originalAmount,
reimbursableAmount,
employeeAbsorbedAmount,
message: String(flag.message || flag.summary || '').trim()
})
})
return adjustmentMap
}
function resolveSourceStandardAdjustment(source, id, requestModel) {
const requestAdjustment = buildStandardAdjustmentMap(requestModel).get(id)
if (requestAdjustment) {
return requestAdjustment
}
const reimbursableAmount = parseOptionalCurrency(source?.reimbursableAmount ?? source?.reimbursable_amount)
if (reimbursableAmount === null) {
return null
}
return {
originalAmount: parseOptionalCurrency(source?.originalItemAmount ?? source?.original_item_amount ?? source?.originalAmount ?? source?.original_amount),
reimbursableAmount,
employeeAbsorbedAmount: parseOptionalCurrency(source?.employeeAbsorbedAmount ?? source?.employee_absorbed_amount) || 0,
message: String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim()
}
}
export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
@@ -407,7 +474,13 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
const attachments = invoiceId ? [attachmentName || invoiceId] : []
const standardAdjustment = resolveSourceStandardAdjustment(source, id, requestModel)
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
const hasStandardAdjustment = reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const reimbursableAmountDisplay = reimbursableAmount > 0 ? formatCurrency(reimbursableAmount) : '待补充'
const riskText = String(source?.riskText || '').trim()
const filledAt = formatExpenseFilledTime(
source?.filledAt
@@ -424,6 +497,15 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
itemLocation,
itemNote,
itemAmount,
originalItemAmount,
originalAmountDisplay: originalItemAmount > 0 ? formatCurrency(originalItemAmount) : amountDisplay,
reimbursableAmount,
reimbursableAmountDisplay,
employeeAbsorbedAmount,
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatCurrency(employeeAbsorbedAmount) : '',
hasStandardAdjustment,
standardAdjustmentAccepted: Boolean(standardAdjustment),
standardAdjustmentMessage: standardAdjustment?.message || '',
invoiceId,
isSystemGenerated,
time: itemDate || '待补充',

View File

@@ -453,6 +453,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight),
source: 'attachment_analysis',
itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel),
visibility_scope: 'submitter',
@@ -645,6 +646,7 @@ export function buildAttachmentRiskCards({
summary,
ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
source,
risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope,
actionability: flag.actionability

View File

@@ -0,0 +1,175 @@
import {
STANDARD_ADJUSTMENT_RISK_SOURCE,
buildStandardAdjustmentMap
} from './travelRequestDetailExpenseModel.js'
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeAmount(value) {
const amount = Number(value)
return Number.isFinite(amount) && amount > 0 ? amount : 0
}
export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = []) {
return buildStandardAdjustmentMap({
...request,
riskFlags,
risk_flags_json: riskFlags
})
}
export function resolveExpenseItemForRiskCard(card, expenseItems = []) {
const itemId = normalizeText(card?.itemId || card?.item_id)
const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id)
const itemIndex = Number(card?.itemIndex || card?.item_index || 0)
return expenseItems.find((item) => normalizeText(item.id) === itemId)
|| expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId)
|| (itemIndex > 0 ? expenseItems[itemIndex - 1] : null)
|| null
}
export function isRiskCardMissingExpenseNote(card, expenseItems = []) {
const item = resolveExpenseItemForRiskCard(card, expenseItems)
if (!item) {
return true
}
return !normalizeText(item.itemNote)
}
export function hasStandardAdjustmentForItem(item, standardAdjustmentMap = new Map()) {
const itemId = normalizeText(item?.id)
if (!itemId) {
return false
}
return Boolean(item?.standardAdjustmentAccepted || standardAdjustmentMap.has(itemId))
}
function isRiskCardResolvedForSubmitter(card, expenseItems, standardAdjustmentMap) {
if (normalizeText(card?.source) === STANDARD_ADJUSTMENT_RISK_SOURCE) {
return true
}
return hasStandardAdjustmentForItem(
resolveExpenseItemForRiskCard(card, expenseItems),
standardAdjustmentMap
)
}
export function filterSubmitterResolvedRiskCards({
cards = [],
businessStage = 'reimbursement',
isCurrentApplicant = false,
isPrivilegedRiskViewer = false,
expenseItems = [],
standardAdjustmentMap = new Map()
} = {}) {
if (businessStage !== 'reimbursement' || !isCurrentApplicant || isPrivilegedRiskViewer) {
return cards
}
return cards.filter((card) => !isRiskCardResolvedForSubmitter(card, expenseItems, standardAdjustmentMap))
}
function extractRiskCardMoneyValues(card) {
const corpus = [
card?.risk,
card?.summary,
card?.suggestion,
card?.title,
...(Array.isArray(card?.ruleBasis) ? card.ruleBasis : [])
].map(normalizeText).filter(Boolean).join(' ')
return [...corpus.matchAll(/(?:¥|¥)?\s*(\d+(?:,\d{3})*(?:\.\d+)?)\s*元/g)]
.map((match) => Number(String(match[1] || '').replace(/,/g, '')))
.filter((amount) => Number.isFinite(amount) && amount > 0)
}
function resolveParsedStandardAmount(card, item) {
const originalAmount = normalizeAmount(item?.itemAmount)
if (originalAmount <= 0) {
return null
}
const candidates = extractRiskCardMoneyValues(card)
.filter((amount) => amount > 0 && amount < originalAmount)
return candidates.length ? Math.max(...candidates) : null
}
function extractRiskCardNightCount(card) {
const corpus = [card?.risk, card?.summary, card?.suggestion, card?.title]
.map(normalizeText)
.join(' ')
const match = corpus.match(/(\d+)\s*(?:晚|夜|间夜)/)
return match ? Math.max(1, Number(match[1]) || 1) : 1
}
async function resolveTravelStandardAmount({ card, item, request, calculateTravelReimbursement }) {
const itemType = normalizeText(item?.itemType)
if (!['hotel_ticket', 'hotel'].includes(itemType)) {
return null
}
const location = normalizeText(item?.itemLocation || request?.location || request?.sceneTarget)
if (!location || typeof calculateTravelReimbursement !== 'function') {
return null
}
const grade = normalizeText(request?.employeeGrade || request?.profileGrade)
const days = extractRiskCardNightCount(card)
try {
const result = await calculateTravelReimbursement({ days, location, grade })
const hotelAmount = Number(result?.hotel_amount ?? result?.hotelAmount)
return Number.isFinite(hotelAmount) && hotelAmount > 0 ? hotelAmount : null
} catch (error) {
return null
}
}
async function resolveRiskStandardReimbursableAmount({ card, item, request, calculateTravelReimbursement }) {
const originalAmount = normalizeAmount(item?.itemAmount)
if (originalAmount <= 0) {
return 0
}
const parsedAmount = resolveParsedStandardAmount(card, item)
if (parsedAmount !== null) {
return Math.min(originalAmount, parsedAmount)
}
const travelStandardAmount = await resolveTravelStandardAmount({
card,
item,
request,
calculateTravelReimbursement
})
if (travelStandardAmount !== null) {
return Math.min(originalAmount, travelStandardAmount)
}
return originalAmount
}
export async function buildStandardAdjustmentPayload({
warnings = [],
expenseItems = [],
request = {},
calculateTravelReimbursement
} = {}) {
const risks = []
for (const warning of warnings) {
const item = resolveExpenseItemForRiskCard(warning, expenseItems)
if (!item) {
continue
}
const originalAmount = normalizeAmount(item.itemAmount)
const reimbursableAmount = await resolveRiskStandardReimbursableAmount({
card: warning,
item,
request,
calculateTravelReimbursement
})
risks.push({
risk_id: warning.id,
item_id: item.id,
title: warning.title,
risk: warning.risk || warning.summary,
original_amount: originalAmount,
reimbursable_amount: reimbursableAmount
})
}
return { risks }
}

View File

@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (hasHighRiskWarnings) {
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
return '系统自动检测存在风险提示,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
}
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
}

View File

@@ -7,10 +7,13 @@ import {
canAccessAppView,
canDeleteArchivedExpenseClaims,
canEditBudgetCenter,
filterNavItemsByAccess,
getAccessibleViewIds,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
canManageExpenseClaims,
canReturnExpenseClaims,
resolveDefaultAuthorizedRoute,
canSwitchBudgetDepartments
} from '../src/utils/accessControl.js'
import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js'
@@ -71,6 +74,26 @@ test('legacy reimbursement approval and archive centers are no longer accessible
assert.equal(canAccessAppView(adminUser, 'documents'), true)
})
test('platform admin users do not enter the personal workbench', () => {
const adminUser = { username: 'admin', isAdmin: true, roleCodes: ['manager', 'finance'] }
const employeeUser = { username: 'employee@example.com', roleCodes: [] }
const navItems = [
{ id: 'workbench', label: '个人工作台' },
{ id: 'documents', label: '单据中心' },
{ id: 'overview', label: '分析看板' },
{ id: 'settings', label: '系统设置' }
]
assert.equal(canAccessAppView(adminUser, 'workbench'), false)
assert.equal(canAccessAppView(employeeUser, 'workbench'), true)
assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false)
assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-overview' })
assert.deepEqual(
filterNavItemsByAccess(navItems, adminUser).map((item) => item.id),
['documents', 'overview', 'settings']
)
})
test('budget center is visible to platform admin, budget monitor, and executive roles only', () => {
assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'budget'), true)
assert.equal(canAccessAppView({ username: 'admin', roleCodes: ['manager'] }, 'budget'), true)

View File

@@ -5,6 +5,7 @@ import {
countNewDocuments,
isNewDocument,
markDocumentViewed,
markDocumentsViewed,
readDocumentScope,
readViewedDocumentKeys,
resolveDocumentNewKey,
@@ -47,6 +48,19 @@ test('document center new state counts unseen documents and persists viewed rows
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1'])
})
test('document center new state can mark all unread rows as viewed at once', () => {
const storage = createMemoryStorage()
const rows = [
{ source: 'owned', claimId: 'claim-1' },
{ source: 'approval', claimId: 'claim-2' },
{ source: 'archive', claimId: 'claim-3' }
]
const viewedKeys = markDocumentsViewed(rows, readViewedDocumentKeys(storage), storage)
assert.equal(countNewDocuments(rows, viewedKeys), 0)
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1', 'approval:claim-2'])
})
test('document center archive rows are never marked as new', () => {
const viewedKeys = readViewedDocumentKeys(createMemoryStorage())
const rows = [
@@ -71,6 +85,24 @@ test('document center sidebar inbox shares source scoped document keys', () => {
assert.deepEqual(rows.map((row) => resolveDocumentNewKey(row)), ['approval:claim-1', 'archive:claim-2'])
})
test('document center inbox rows expose real notification metadata', () => {
const rows = buildDocumentInboxRows({
ownedClaims: [{
id: 'claim-1',
claim_no: 'EXP-1',
title: '差旅报销',
status: 'draft',
created_at: '2026-06-03T09:10:00+08:00'
}]
})
assert.equal(rows[0].documentNo, 'EXP-1')
assert.equal(rows[0].sourceLabel, '我的单据')
assert.equal(rows[0].title, '差旅报销')
assert.equal(rows[0].createdAt, '2026-06-03T09:10:00+08:00')
assert.equal(Number.isFinite(rows[0].sortTime), true)
})
test('document center scope state restores only allowed tabs', () => {
const storage = createMemoryStorage()
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
compareDocumentRowsByLatestTime,
sortDocumentRowsByLatestTime
} from '../src/utils/documentCenterSort.js'
test('document center sorts newest document rows first without mutating input', () => {
const rows = [
{ documentNo: 'AP-001', sortTime: 1000 },
{ documentNo: 'AP-003', sortTime: 3000 },
{ documentNo: 'AP-002', sortTime: 2000 }
]
const sortedRows = sortDocumentRowsByLatestTime(rows)
assert.deepEqual(sortedRows.map((row) => row.documentNo), ['AP-003', 'AP-002', 'AP-001'])
assert.deepEqual(rows.map((row) => row.documentNo), ['AP-001', 'AP-003', 'AP-002'])
})
test('document center sort falls back to created time and stable document keys', () => {
const rows = [
{ documentKey: 'owned:AP-001', documentNo: 'AP-001', sortTime: 1000, createdSortTime: 1000 },
{ documentKey: 'owned:AP-002', documentNo: 'AP-002', sortTime: 1000, createdSortTime: 2000 },
{ documentKey: 'owned:AP-003', documentNo: 'AP-003', sortTime: 1000, createdSortTime: 2000 }
]
const sortedRows = sortDocumentRowsByLatestTime(rows)
assert.deepEqual(sortedRows.map((row) => row.documentNo), ['AP-003', 'AP-002', 'AP-001'])
assert.equal(compareDocumentRowsByLatestTime(rows[1], rows[2]) > 0, true)
})

View File

@@ -68,6 +68,7 @@ test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
assert.match(documentsCenterView, /import \{ sortDocumentRowsByLatestTime \} from '..\/utils\/documentCenterSort\.js'/)
assert.match(documentsCenterView, /activeScopeTab\.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
assert.match(
documentsCenterView,
@@ -92,6 +93,23 @@ test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /return nonArchivedRows\.value/)
})
test('documents center sorts every filtered scope by latest document time before pagination', () => {
assert.match(
documentsCenterView,
/return sortDocumentRowsByLatestTime\(activeScopeRows\.value\.filter\(\(row\) => \{[\s\S]*matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange[\s\S]*\}\)\)/
)
assert.match(
documentsCenterView,
/const createdSortTime = resolveDocumentSortTime\(createdAtSource\)[\s\S]*const updatedSortTime = resolveDocumentSortTime\(updatedAtSource\)/
)
assert.match(
documentsCenterView,
/createdSortTime,[\s\S]*updatedSortTime,[\s\S]*sortTime: Math\.max\(createdSortTime, updatedSortTime\)/
)
assert.match(documentsCenterView, /return sortDocumentRowsByLatestTime\(Array\.from\(rowMap\.values\(\)\)\)/)
assert.doesNotMatch(documentsCenterView, /right\.sortTime - left\.sortTime/)
})
test('documents center preserves application document type from mapped requests', () => {
assert.match(
documentsCenterView,
@@ -137,9 +155,10 @@ test('documents center list shows created time and conditional stay time columns
})
test('documents center action buttons are scoped to application and reimbursement tabs', () => {
assert.match(documentsCenterView, /v-if="showToolbarActions" class="document-actions"/)
assert.match(
documentsCenterView,
/v-if="\[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT\]\.includes\(activeScopeTab\)"[\s\S]*class="document-actions"/
/const showCreateDocumentActions = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT/
)
assert.match(
documentsCenterView,
@@ -156,6 +175,7 @@ test('documents center category tabs render bubble counts for new documents', ()
assert.match(documentsCenterView, /readViewedDocumentKeys/)
assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/)
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
assert.match(documentsCenterView, /<span class="scope-tab-label">/)
assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
@@ -178,6 +198,31 @@ test('documents center category tabs render bubble counts for new documents', ()
documentsCenterView,
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
)
const scopeTabBadgeBlock = documentListSharedStyles.match(/\.scope-tab-badge\s*\{[^}]*\}/)?.[0] || ''
assert.match(documentListSharedStyles, /\.scope-tab-label\s*\{[\s\S]*align-items:\s*flex-start;[\s\S]*gap:\s*4px;/)
assert.match(scopeTabBadgeBlock, /position:\s*static;/)
assert.match(scopeTabBadgeBlock, /height:\s*14px;/)
assert.match(scopeTabBadgeBlock, /margin-top:\s*-5px;/)
assert.doesNotMatch(scopeTabBadgeBlock, /position:\s*absolute;/)
})
test('documents center can mark all unread documents as read from toolbar', () => {
assert.match(documentsCenterView, /markDocumentsViewed/)
assert.match(
documentsCenterView,
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/
)
assert.match(documentsCenterView, /const totalNewDocumentCount = computed\(\(\) => countNewDocuments\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)\)/)
assert.match(documentsCenterView, /const showToolbarActions = computed\(\(\) => showCreateDocumentActions\.value \|\| totalNewDocumentCount\.value > 0\)/)
assert.match(
documentsCenterView,
/<button[\s\S]*v-if="totalNewDocumentCount > 0"[\s\S]*class="mark-read-btn"[\s\S]*@click="markAllDocumentsRead"[\s\S]*一键已读/
)
assert.match(
documentsCenterView,
/function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/
)
assert.match(documentListSharedStyles, /\.mark-read-btn\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
})
test('documents center rows show NEW marker until the row is opened', () => {

View File

@@ -366,6 +366,39 @@ test('application preview can be refined by ontology model extraction', () => {
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview ignores model-only transport mode guesses', () => {
const rawText = '\u7533\u8bf7 2026-05-25 \u81f3 2026-05-27 \u53bb\u4e0a\u6d77\u51fa\u5dee3\u5929\uff0c\u670d\u52a1\u9879\u76ee\u90e8\u7f72\uff0c\u9884\u8ba1\u8d39\u75281800\u5143'
const localPreview = buildLocalApplicationPreview(rawText, {
name: '\u674e\u6587\u9759',
grade: 'P5'
})
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '\u5dee\u65c5\u8d39', normalized_value: 'travel' },
{ type: 'location', value: '\u4e0a\u6d77', normalized_value: '\u4e0a\u6d77' },
{ type: 'reason', value: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', normalized_value: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72' },
{ type: 'transport_mode', value: '\u706b\u8f66', normalized_value: '\u706b\u8f66' },
{ type: 'amount', value: '1800\u5143', normalized_value: '1800' }
],
time_range: {
start: '2026-05-25',
end: '2026-05-27'
},
missing_slots: []
},
rawText,
{ name: '\u674e\u6587\u9759', grade: 'P5' }
)
assert.equal(localPreview.fields.transportMode, '')
assert.equal(refinedPreview.fields.transportMode, '')
assert.ok(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'))
assert.equal(refinedPreview.readyToSubmit, false)
})
test('application preview precomputes a date range from today when only days are provided', () => {
const preview = buildLocalApplicationPreview(
'去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元',

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const modal = readFileSync(
fileURLToPath(new URL('../src/components/business/ExpenseProfileDetailModal.vue', import.meta.url)),
'utf8'
)
const radarChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/RadarChart.vue', import.meta.url)),
'utf8'
)
test('expense profile modal remounts the behavior radar when opened', () => {
assert.match(modal, /destroy-on-close/)
assert.match(modal, /<RadarChart/)
assert.match(modal, /:key="radarRenderKey"/)
assert.match(modal, /const radarRenderKey = ref\(0\)/)
assert.match(modal, /watch\([\s\S]*\(\) => props\.visible[\s\S]*radarRenderKey\.value \+= 1/)
assert.match(modal, /scheduleRadarFrame/)
})
test('radar chart uses the shared echarts lifecycle and enables entrance animation', () => {
assert.match(radarChart, /import \{ useEcharts \} from '\.\.\/\.\.\/composables\/useEcharts\.js'/)
assert.match(radarChart, /useEcharts\(chartElement, chartOptions\)/)
assert.match(radarChart, /animation: true/)
assert.match(radarChart, /animationDuration: 980/)
assert.match(radarChart, /animationDurationUpdate: 760/)
assert.match(radarChart, /animationEasing: 'cubicOut'/)
assert.doesNotMatch(radarChart, /import \{[^}]*init[^}]*\} from 'echarts\/core'/)
})

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const pager = readFileSync(
fileURLToPath(new URL('../src/components/business/ExpenseProfileTagPager.vue', import.meta.url)),
'utf8'
)
test('profile tag pager stretches tag rows to the bottom of the card', () => {
assert.match(pager, /\.profile-tag-pager\s*\{[\s\S]*height:\s*100%;/)
assert.match(pager, /\.profile-tag-pager\s*\{[\s\S]*grid-template-rows:\s*minmax\(0,\s*1fr\) 26px;/)
assert.match(pager, /\.profile-tag-list\s*\{[\s\S]*grid-template-rows:\s*repeat\(5,\s*minmax\(48px,\s*1fr\)\);/)
assert.match(pager, /\.profile-tag-list\s*\{[\s\S]*align-content:\s*stretch;/)
})

View File

@@ -14,12 +14,17 @@ test('expense stats detail modal exposes distribution, processing time and opera
assert.match(modal, /单据处理时间/)
assert.match(modal, /系统操作详情/)
assert.match(modal, /ElDialog/)
assert.match(modal, /destroy-on-close/)
assert.match(modal, /ElTag/)
assert.match(modal, /import DonutChart from '\.\.\/charts\/DonutChart\.vue'/)
assert.match(modal, /<DonutChart/)
assert.match(modal, /:key="distributionChartRenderKey"/)
assert.match(modal, /:show-legend="false"/)
assert.match(modal, /distributionChartItems/)
assert.match(modal, /distributionCenterValue/)
assert.match(modal, /const chartRenderSeq = ref\(0\)/)
assert.match(modal, /const distributionChartRenderKey = computed\(\(\) => `expense-distribution-\$\{chartRenderSeq\.value\}`\)/)
assert.match(modal, /watch\([\s\S]*\(\) => props\.visible[\s\S]*chartRenderSeq\.value \+= 1/)
assert.match(modal, /distributionRows/)
assert.match(modal, /expense-distribution-summary-list/)
assert.match(modal, /resolveDistributionColor/)

View File

@@ -127,6 +127,7 @@ function testReceiptFolderDetailLayoutAdjustments() {
assert.match(receiptStyles, /\.receipt-association-panel/)
assert.match(receiptStyles, /\.receipt-preview-box[\s\S]*height: clamp\(380px, 56vh, 640px\)/)
assert.match(receiptStyles, /\.receipt-all-field-grid/)
assert.match(receiptStyles, /\.receipt-all-field-grid[\s\S]*grid-template-columns: repeat\(auto-fit, minmax\(260px, 1fr\)\)/)
assert.match(receiptStyles, /\.receipt-edit-log-list/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/)
assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/)

View File

@@ -8,6 +8,26 @@ const sidebar = readFileSync(
'utf8'
)
const sidebarStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/sidebar-rail.css', import.meta.url)),
'utf8'
)
const topbar = readFileSync(
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
'utf8'
)
const topbarStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/top-bar.css', import.meta.url)),
'utf8'
)
const appShellRouteView = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const documentInbox = readFileSync(
fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)),
'utf8'
@@ -18,22 +38,83 @@ const documentNewState = readFileSync(
'utf8'
)
test('sidebar renders a red dot for unread document center rows', () => {
assert.match(sidebar, /useDocumentCenterInbox/)
assert.match(sidebar, /hasUnread: documentInboxHasUnread/)
assert.match(sidebar, /<span v-if="item\.hasNewMessage" class="nav-unread-dot" aria-hidden="true"><\/span>/)
assert.match(sidebar, /hasNewMessage: item\.id === 'documents' \? documentInboxHasUnread\.value : false/)
assert.match(sidebar, /void refreshDocumentInbox\(\)/)
assert.match(sidebar, /startDocumentInboxPolling\(\)/)
assert.match(sidebar, /stopDocumentInboxPolling\(\)/)
assert.match(sidebar, /\.nav-unread-dot\s*\{[\s\S]*background:\s*#ef4444;/)
assert.match(sidebar, /\.rail-collapsed \.nav-unread-dot\s*\{[\s\S]*position:\s*absolute;/)
const notificationStatesService = readFileSync(
fileURLToPath(new URL('../src/services/notificationStates.js', import.meta.url)),
'utf8'
)
const topbarNotificationStates = readFileSync(
fileURLToPath(new URL('../src/composables/useTopBarNotificationStates.js', import.meta.url)),
'utf8'
)
test('sidebar no longer renders document center unread indicators', () => {
assert.doesNotMatch(sidebar, /useDocumentCenterInbox/)
assert.doesNotMatch(sidebar, /hasUnread: documentInboxHasUnread/)
assert.doesNotMatch(sidebar, /hasNewMessage/)
assert.doesNotMatch(sidebar, /nav-label-text/)
assert.doesNotMatch(sidebar, /nav-unread-dot/)
assert.match(sidebar, /<span class="nav-label">\{\{ item\.displayLabel \}\}<\/span>/)
assert.doesNotMatch(sidebarStyles, /\.nav-label-text\s*\{/)
assert.doesNotMatch(sidebarStyles, /\.nav-unread-dot/)
assert.match(sidebarStyles, /\.nav-label\s*\{[\s\S]*overflow:\s*hidden;[\s\S]*text-overflow:\s*ellipsis;/)
})
test('topbar bell owns document center unread notifications', () => {
assert.match(topbar, /useDocumentCenterInbox/)
assert.match(topbar, /useTopBarNotificationStates/)
assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
assert.match(topbar, /const documentNotificationItems = computed/)
assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
assert.match(topbar, /description: resolveDocumentNotificationDescription\(row\)/)
assert.match(topbar, /const unread = Boolean\(row\.isUnread\) && !isNotificationRead\(id\)/)
assert.match(topbar, /markDocumentInboxRowRead\(item\.documentRow\)/)
assert.match(topbar, /markNotificationStateRead\(item\)/)
assert.match(topbar, /markDocumentInboxRowsRead\(documentRows\)/)
assert.match(topbar, /hideNotificationStates\(currentItems\)/)
assert.match(topbar, /loadNotificationStates\(\)/)
assert.match(topbar, /const topbarNotificationCount = computed\(\(\) => \{[\s\S]*const count = unreadNotifications\.value\.length/)
assert.doesNotMatch(topbar, /document-center-unread/)
assert.doesNotMatch(topbar, /target: \{ type: 'documents-center' \}/)
assert.doesNotMatch(topbar, /emit\('navigate', 'documents'\)/)
assert.match(appShellRouteView, /@navigate="handleNavigate"/)
assert.match(topbar, /startDocumentInboxPolling\(\)/)
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
assert.match(topbar, /class="notification-clear-btn"/)
assert.match(topbar, /function clearAllNotifications\(\)/)
assert.match(topbar, /function markNotificationRead\(item\)/)
assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/)
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/)
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/)
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/)
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/)
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
})
test('topbar notification state is persisted through backend API with local fallback', () => {
assert.match(notificationStatesService, /apiRequest\('\/notification-states'\)/)
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'/)
assert.match(topbarNotificationStates, /fetchNotificationStates/)
assert.match(topbarNotificationStates, /patchNotificationStates/)
assert.match(topbarNotificationStates, /NOTIFICATION_READ_STORAGE_KEY/)
assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/)
assert.match(topbarNotificationStates, /applyRemoteStates/)
assert.match(topbarNotificationStates, /markNotificationStateRead/)
assert.match(topbarNotificationStates, /hideNotificationStates/)
})
test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /readViewedDocumentKeys/)
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
assert.match(documentInbox, /const notificationRows = computed/)
assert.match(documentInbox, /isUnread: isNewDocument\(row, viewedDocumentKeys\.value\)/)
assert.match(documentInbox, /function markDocumentInboxRowRead\(row\)/)
assert.match(documentInbox, /function markDocumentInboxRowsRead\(rows = documentRows\.value\)/)
assert.match(documentInbox, /fetchExpenseClaims/)
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
assert.match(documentInbox, /fetchArchivedExpenseClaims/)

View File

@@ -254,7 +254,7 @@ test('guided reimbursement requires application selection for travel and enterta
reason: '客户招待沟通项目',
location: '武汉',
amount: 600,
status: 'submitted',
status: 'approved',
created_at: '2026-05-21T08:00:00Z'
},
{
@@ -265,6 +265,44 @@ test('guided reimbursement requires application selection for travel and enterta
reason: '草稿出差申请',
status: 'draft'
},
{
id: 'app-submitted',
claim_no: 'AP-202605-005',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '审批中的出差申请',
status: 'submitted'
},
{
id: 'app-archived-stale-key',
claim_no: 'AP-202605-007',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '已归档申请单',
status: 'archived',
approvalKey: 'completed'
},
{
id: 'app-linked',
claim_no: 'AP-202605-006',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '已生成报销草稿的出差申请',
status: 'approved'
},
{
id: 're-linked-draft',
claim_no: 'RE-202605-006',
employee_name: '张小青',
expense_type: 'travel',
reason: '已关联申请单的报销草稿',
status: 'draft',
risk_flags_json: [{
source: 'application_link',
application_claim_id: 'app-linked',
application_claim_no: 'AP-202605-006'
}]
},
{
id: 'app-other-user',
claim_no: 'AP-202605-004',

Some files were not shown because too many files have changed in this diff Show More