Compare commits
16 Commits
34457f9c3e
...
codex/risk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75d5c178e1 | ||
|
|
b9826a1985 | ||
|
|
0f8bc4071a | ||
|
|
cb36d78fa2 | ||
|
|
8e2477587f | ||
|
|
67b81a1bd8 | ||
|
|
9c24a852e7 | ||
|
|
95956afbc6 | ||
|
|
c73178b65d | ||
|
|
8c2f301d85 | ||
|
|
4717ee6086 | ||
|
|
513ff909f9 | ||
|
|
92198549f6 | ||
|
|
59d3bf0f00 | ||
|
|
04f0951b3d | ||
|
|
8887cf5a27 |
111
document/development/通知中心状态持久化/CONCEPT.md
Normal 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 状态会作为首次迁移兜底,后续服务端会逐步成为主状态源。
|
||||||
10
document/development/通知中心状态持久化/TODO.md
Normal 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 passed;Node 4 passed;Vite build passed。
|
||||||
38
server/src/app/api/v1/endpoints/notification_states.py
Normal 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)
|
||||||
@@ -20,6 +20,7 @@ from app.schemas.reimbursement import (
|
|||||||
ExpenseClaimItemUpdate,
|
ExpenseClaimItemUpdate,
|
||||||
ExpenseClaimRead,
|
ExpenseClaimRead,
|
||||||
ExpenseClaimReturnPayload,
|
ExpenseClaimReturnPayload,
|
||||||
|
ExpenseClaimStandardAdjustmentPayload,
|
||||||
ExpenseClaimUpdate,
|
ExpenseClaimUpdate,
|
||||||
ReimbursementCreate,
|
ReimbursementCreate,
|
||||||
ReimbursementRead,
|
ReimbursementRead,
|
||||||
@@ -233,6 +234,43 @@ def update_expense_claim(
|
|||||||
return 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(
|
@router.patch(
|
||||||
"/claims/{claim_id}/items/{item_id}",
|
"/claims/{claim_id}/items/{item_id}",
|
||||||
response_model=ExpenseClaimRead,
|
response_model=ExpenseClaimRead,
|
||||||
|
|||||||
@@ -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.employee_profiles import router as employee_profiles_router
|
||||||
from app.api.v1.endpoints.health import router as health_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.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.ocr import router as ocr_router
|
||||||
from app.api.v1.endpoints.ontology import router as ontology_router
|
from app.api.v1.endpoints.ontology import router as ontology_router
|
||||||
from app.api.v1.endpoints.orchestrator import router as orchestrator_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(analytics_router, tags=["analytics"])
|
||||||
router.include_router(audit_logs_router, tags=["audit-logs"])
|
router.include_router(audit_logs_router, tags=["audit-logs"])
|
||||||
router.include_router(knowledge_router, tags=["knowledge"])
|
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(ocr_router, tags=["ocr"])
|
||||||
router.include_router(ontology_router, tags=["ontology"])
|
router.include_router(ontology_router, tags=["ontology"])
|
||||||
router.include_router(orchestrator_router, tags=["orchestrator"])
|
router.include_router(orchestrator_router, tags=["orchestrator"])
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.models.financial_record import (
|
|||||||
)
|
)
|
||||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||||
from app.models.hermes_report import HermesRiskReport
|
from app.models.hermes_report import HermesRiskReport
|
||||||
|
from app.models.notification_state import NotificationState
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||||
@@ -60,6 +61,7 @@ __all__ = [
|
|||||||
"HermesTaskConfig",
|
"HermesTaskConfig",
|
||||||
"HermesTaskExecutionLog",
|
"HermesTaskExecutionLog",
|
||||||
"HermesRiskReport",
|
"HermesRiskReport",
|
||||||
|
"NotificationState",
|
||||||
"OrganizationUnit",
|
"OrganizationUnit",
|
||||||
"ReimbursementRequest",
|
"ReimbursementRequest",
|
||||||
"RiskObservation",
|
"RiskObservation",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.models.financial_record import (
|
|||||||
)
|
)
|
||||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||||
from app.models.hermes_report import HermesRiskReport
|
from app.models.hermes_report import HermesRiskReport
|
||||||
|
from app.models.notification_state import NotificationState
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||||
@@ -51,6 +52,7 @@ __all__ = [
|
|||||||
"HermesTaskConfig",
|
"HermesTaskConfig",
|
||||||
"HermesTaskExecutionLog",
|
"HermesTaskExecutionLog",
|
||||||
"HermesRiskReport",
|
"HermesRiskReport",
|
||||||
|
"NotificationState",
|
||||||
"OrganizationUnit",
|
"OrganizationUnit",
|
||||||
"ReimbursementRequest",
|
"ReimbursementRequest",
|
||||||
"RiskObservation",
|
"RiskObservation",
|
||||||
|
|||||||
32
server/src/app/models/notification_state.py
Normal 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(),
|
||||||
|
)
|
||||||
45
server/src/app/schemas/notification_state.py
Normal 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)
|
||||||
@@ -121,6 +121,19 @@ class ExpenseClaimUpdate(BaseModel):
|
|||||||
reason: str | None = Field(default=None, max_length=500)
|
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):
|
class ExpenseClaimRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ EXPENSE_TYPE_LABELS = {
|
|||||||
}
|
}
|
||||||
MAX_DRAFT_CLAIMS_PER_USER = 3
|
MAX_DRAFT_CLAIMS_PER_USER = 3
|
||||||
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
|
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
|
||||||
|
STANDARD_ADJUSTMENT_RISK_SOURCE = "reimbursement_standard_adjustment"
|
||||||
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
|
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
|
||||||
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
|
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
|
||||||
TRAVEL_DETAIL_ITEM_TYPES = {
|
TRAVEL_DETAIL_ITEM_TYPES = {
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ from app.services.expense_rule_runtime import (
|
|||||||
from app.services.ocr import OcrService
|
from app.services.ocr import OcrService
|
||||||
|
|
||||||
|
|
||||||
|
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
|
||||||
|
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {"cancelled", "canceled", "deleted"}
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimDraftFlowMixin:
|
class ExpenseClaimDraftFlowMixin:
|
||||||
def upsert_draft_from_ontology(
|
def upsert_draft_from_ontology(
|
||||||
self,
|
self,
|
||||||
@@ -176,6 +180,12 @@ class ExpenseClaimDraftFlowMixin:
|
|||||||
)
|
)
|
||||||
is_new_claim = claim is None
|
is_new_claim = claim is None
|
||||||
before_json = self._serialize_claim(claim) if claim is not None else 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:
|
if is_new_claim:
|
||||||
existing_draft_count = self._count_draft_claims_for_owner(
|
existing_draft_count = self._count_draft_claims_for_owner(
|
||||||
employee=employee,
|
employee=employee,
|
||||||
@@ -517,6 +527,219 @@ class ExpenseClaimDraftFlowMixin:
|
|||||||
return list(risk_flags or [])
|
return list(risk_flags or [])
|
||||||
return [*list(risk_flags or []), link_flag]
|
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
|
@staticmethod
|
||||||
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
|
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ from app.services.expense_claim_constants import (
|
|||||||
DOCUMENT_FACT_ITEM_TYPES,
|
DOCUMENT_FACT_ITEM_TYPES,
|
||||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||||
OPTIONAL_ATTACHMENT_ITEM_TYPES,
|
OPTIONAL_ATTACHMENT_ITEM_TYPES,
|
||||||
|
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||||
SYSTEM_GENERATED_ITEM_TYPES,
|
SYSTEM_GENERATED_ITEM_TYPES,
|
||||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||||
@@ -329,6 +330,45 @@ class ExpenseClaimItemSyncMixin:
|
|||||||
return destination
|
return destination
|
||||||
return ""
|
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:
|
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
|
||||||
self._sync_travel_allowance_item(claim)
|
self._sync_travel_allowance_item(claim)
|
||||||
if not claim.items:
|
if not claim.items:
|
||||||
@@ -346,7 +386,11 @@ class ExpenseClaimItemSyncMixin:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
primary_item = ordered_items[0]
|
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.amount = total_amount.quantize(Decimal("0.01"))
|
||||||
claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())
|
claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
limit_config=item_limit,
|
limit_config=item_limit,
|
||||||
reason_text="\n".join(
|
reason_text="\n".join(
|
||||||
part
|
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
|
if part
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -333,7 +337,12 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
|
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
|
||||||
f"当前酒店识别金额约 {nightly_amount} 元/晚。"
|
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)
|
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
|
||||||
if has_standard_exception or item_has_exception:
|
if has_standard_exception or item_has_exception:
|
||||||
flags.append(
|
flags.append(
|
||||||
@@ -368,7 +377,12 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
if allowed_level is None or class_level <= allowed_level:
|
if allowed_level is None or class_level <= allowed_level:
|
||||||
continue
|
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)
|
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
|
||||||
message = f"{band_label} 职级当前默认不可报销 {class_label}。"
|
message = f"{band_label} 职级当前默认不可报销 {class_label}。"
|
||||||
if has_standard_exception or item_has_exception:
|
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()]
|
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
|
||||||
for item in claim.items:
|
for item in claim.items:
|
||||||
parts.append(str(item.item_reason or "").strip())
|
parts.append(str(item.item_reason or "").strip())
|
||||||
|
parts.append(str(item.item_note or "").strip())
|
||||||
parts.append(str(item.item_location or "").strip())
|
parts.append(str(item.item_location or "").strip())
|
||||||
return "\n".join(part for part in parts if part)
|
return "\n".join(part for part in parts if part)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
|||||||
from app.schemas.reimbursement import (
|
from app.schemas.reimbursement import (
|
||||||
ExpenseClaimItemCreate,
|
ExpenseClaimItemCreate,
|
||||||
ExpenseClaimItemUpdate,
|
ExpenseClaimItemUpdate,
|
||||||
|
ExpenseClaimStandardAdjustmentPayload,
|
||||||
ExpenseClaimUpdate,
|
ExpenseClaimUpdate,
|
||||||
TravelReimbursementCalculatorRequest,
|
TravelReimbursementCalculatorRequest,
|
||||||
)
|
)
|
||||||
@@ -109,6 +110,7 @@ from app.services.expense_claim_constants import (
|
|||||||
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
|
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
|
||||||
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
||||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||||
|
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||||
)
|
)
|
||||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||||
from app.services.expense_amounts import (
|
from app.services.expense_amounts import (
|
||||||
@@ -290,6 +292,126 @@ class ExpenseClaimService(
|
|||||||
|
|
||||||
return claim
|
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(
|
def update_claim_item(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -758,6 +880,3 @@ class ExpenseClaimService(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
88
server/src/app/services/notification_states.py
Normal 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}
|
||||||
@@ -78,6 +78,9 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
|
|||||||
"application_policy_estimate",
|
"application_policy_estimate",
|
||||||
"application_rule_name",
|
"application_rule_name",
|
||||||
"application_rule_version",
|
"application_rule_version",
|
||||||
|
"original_amount",
|
||||||
|
"reimbursable_amount",
|
||||||
|
"employee_absorbed_amount",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"id": "e8d4f21f-846f-4321-a341-52cd3dfb5acc",
|
"id": "33a1c4b9-56e1-49d1-823e-5cb4680f5a40",
|
||||||
"owner_key": "caoxiaozhu_xf.com",
|
"owner_key": "caoxiaozhu_xf.com",
|
||||||
"file_name": "2月20_武汉-上海.pdf",
|
"file_name": "2月20_武汉-上海.pdf",
|
||||||
"source_file_name": "2月20_武汉-上海.pdf",
|
"source_file_name": "2月20_武汉-上海.pdf",
|
||||||
"media_type": "application/pdf",
|
"media_type": "application/pdf",
|
||||||
"size_bytes": 24995,
|
"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",
|
"status": "linked",
|
||||||
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
|
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
|
||||||
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
|
"linked_claim_no": "RE-20260603083825-876B85XW",
|
||||||
"linked_item_id": "26ccabf8-7e69-4812-acc6-fa18899ec5b2",
|
"linked_item_id": "eb1e9fde-b7e8-4f6e-823f-d8252489e7f9",
|
||||||
"linked_at": "2026-06-01T06:57:44.644255+00:00",
|
"linked_at": "2026-06-03T08:39:19.288158+00:00",
|
||||||
"engine": "paddleocr_mobile",
|
"engine": "paddleocr_mobile",
|
||||||
"model": "PP-OCRv5_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中国铁路祝您旅途愉快",
|
"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中国铁路祝您旅途愉快",
|
||||||
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"id": "fa80f870-10a3-4797-a151-39e702927eb5",
|
"id": "67f51c17-a2bc-42bd-99a4-199ee32b18c3",
|
||||||
"owner_key": "caoxiaozhu_xf.com",
|
"owner_key": "caoxiaozhu_xf.com",
|
||||||
"file_name": "2月23_上海-武汉.pdf",
|
"file_name": "2月23_上海-武汉.pdf",
|
||||||
"source_file_name": "2月23_上海-武汉.pdf",
|
"source_file_name": "2月23_上海-武汉.pdf",
|
||||||
"media_type": "application/pdf",
|
"media_type": "application/pdf",
|
||||||
"size_bytes": 24940,
|
"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",
|
"status": "linked",
|
||||||
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
|
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
|
||||||
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
|
"linked_claim_no": "RE-20260603083825-876B85XW",
|
||||||
"linked_item_id": "b0b28405-30b5-4c35-9bd5-13abe4d2c4cd",
|
"linked_item_id": "977f01f8-e7ab-487b-8055-db8864464784",
|
||||||
"linked_at": "2026-06-01T06:40:32.249473+00:00",
|
"linked_at": "2026-06-03T08:40:26.766004+00:00",
|
||||||
"engine": "paddleocr_mobile",
|
"engine": "paddleocr_mobile",
|
||||||
"model": "PP-OCRv5_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中国铁路祝您旅途愉快",
|
"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中国铁路祝您旅途愉快",
|
||||||
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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.退房时间为中午12:00,超时退房将按酒店规定收取相关费用。\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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 134 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -19,7 +19,12 @@ from app.models.organization import OrganizationUnit
|
|||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
from app.schemas.ontology import OntologyParseRequest
|
from app.schemas.ontology import OntologyParseRequest
|
||||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
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.agent_conversations import AgentConversationService
|
||||||
from app.services.budget import BudgetService
|
from app.services.budget import BudgetService
|
||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
@@ -69,6 +74,36 @@ def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
|
|||||||
return claim
|
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:
|
def build_session() -> Session:
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite+pysqlite:///:memory:",
|
"sqlite+pysqlite:///:memory:",
|
||||||
@@ -322,6 +357,12 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
|
|||||||
email=user_id,
|
email=user_id,
|
||||||
)
|
)
|
||||||
db.add(employee)
|
db.add(employee)
|
||||||
|
db.flush()
|
||||||
|
db.add(build_application_claim(
|
||||||
|
id="application-linked-1",
|
||||||
|
claim_no="AP-202605-001",
|
||||||
|
employee=employee,
|
||||||
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
ontology = SemanticOntologyService(db).parse(
|
ontology = SemanticOntologyService(db).parse(
|
||||||
OntologyParseRequest(
|
OntologyParseRequest(
|
||||||
@@ -384,6 +425,12 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
|||||||
grade="P5",
|
grade="P5",
|
||||||
)
|
)
|
||||||
db.add(employee)
|
db.add(employee)
|
||||||
|
db.flush()
|
||||||
|
db.add(build_application_claim(
|
||||||
|
id="application-linked-no-receipt",
|
||||||
|
claim_no="AP-202606-001",
|
||||||
|
employee=employee,
|
||||||
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
ontology = SemanticOntologyService(db).parse(
|
ontology = SemanticOntologyService(db).parse(
|
||||||
OntologyParseRequest(
|
OntologyParseRequest(
|
||||||
@@ -474,6 +521,11 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
|
|||||||
)
|
)
|
||||||
db.add(employee)
|
db.add(employee)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
db.add(build_application_claim(
|
||||||
|
id="application-linked-existing-placeholder",
|
||||||
|
claim_no="AP-202606-002",
|
||||||
|
employee=employee,
|
||||||
|
))
|
||||||
existing_claim = ExpenseClaim(
|
existing_claim = ExpenseClaim(
|
||||||
claim_no="RE-202606020001-PLACEHOLDER",
|
claim_no="RE-202606020001-PLACEHOLDER",
|
||||||
employee_id=employee.id,
|
employee_id=employee.id,
|
||||||
@@ -550,6 +602,123 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
|
|||||||
assert claim.items == []
|
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:
|
def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
employee = Employee(
|
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
|
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:
|
def test_pre_review_claim_records_ai_result_without_submitting() -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="emp-pre-review@example.com",
|
username="emp-pre-review@example.com",
|
||||||
|
|||||||
119
server/tests/test_notification_states.py
Normal 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"] == []
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tabs button.active {
|
.status-tabs button.active {
|
||||||
@@ -33,20 +34,33 @@
|
|||||||
background: var(--theme-primary);
|
background: var(--theme-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scope-tab-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.scope-tab-badge {
|
.scope-tab-badge {
|
||||||
min-width: 18px;
|
position: static;
|
||||||
height: 18px;
|
flex: 0 0 auto;
|
||||||
|
min-width: 14px;
|
||||||
|
height: 14px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 5px;
|
margin-top: -5px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border: 1px solid #fff;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 9.5px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
line-height: 1;
|
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 {
|
.document-toolbar {
|
||||||
@@ -167,6 +181,38 @@
|
|||||||
filter: none;
|
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 {
|
.table-wrap {
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -495,6 +541,7 @@ td small {
|
|||||||
.document-actions,
|
.document-actions,
|
||||||
.list-search,
|
.list-search,
|
||||||
.filter-btn,
|
.filter-btn,
|
||||||
|
.mark-read-btn,
|
||||||
.page-size-select {
|
.page-size-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,9 @@
|
|||||||
.insight-metric-row,
|
.insight-metric-row,
|
||||||
.insight-profile-card {
|
.insight-profile-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
|
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);
|
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-metric-label,
|
||||||
.insight-profile-label {
|
.insight-profile-label {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -136,19 +145,21 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insight-metric-row--amount .insight-metric-icon,
|
||||||
.insight-metric-row--amount .insight-metric-value {
|
.insight-metric-row--amount .insight-metric-value {
|
||||||
color: var(--workbench-primary-active);
|
color: var(--workbench-primary-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insight-metric-row--warning .insight-metric-icon,
|
||||||
.insight-metric-row--warning .insight-metric-value {
|
.insight-metric-row--warning .insight-metric-value {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insight-metric-row--info .insight-metric-icon,
|
||||||
.insight-metric-row--info .insight-metric-value {
|
.insight-metric-row--info .insight-metric-value {
|
||||||
color: var(--workbench-chart-blue);
|
color: var(--workbench-chart-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.insight-profile-icon,
|
|
||||||
.insight-profile-hint {
|
.insight-profile-hint {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,12 +505,19 @@
|
|||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-identity,
|
||||||
|
.progress-result {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.progress-identity strong,
|
.progress-identity strong,
|
||||||
.progress-result strong {
|
.progress-result strong {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--workbench-ink);
|
color: var(--workbench-ink);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 850;
|
font-weight: bold;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -573,24 +580,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-status--warning {
|
.progress-status--warning {
|
||||||
background: var(--warning-soft);
|
background: var(--warning-soft, #fff7ed);
|
||||||
color: var(--warning);
|
color: var(--warning, #ea580c);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-status--success {
|
.progress-status--success {
|
||||||
background: var(--workbench-primary-soft);
|
background: var(--workbench-primary-soft, #eaf4fa);
|
||||||
color: var(--workbench-primary-active);
|
color: var(--workbench-primary-active, #255b7d);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-status--muted {
|
.progress-status--muted {
|
||||||
background: var(--info-soft);
|
background: var(--info-soft, #f1f5f9);
|
||||||
color: var(--workbench-muted);
|
color: var(--workbench-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-status--danger {
|
||||||
|
background: var(--danger-soft, #fef2f2);
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-row {
|
.progress-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
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;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -638,6 +650,54 @@
|
|||||||
pointer-events: none;
|
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-time,
|
||||||
.progress-identity,
|
.progress-identity,
|
||||||
.progress-type,
|
.progress-type,
|
||||||
@@ -649,6 +709,7 @@
|
|||||||
|
|
||||||
.progress-time {
|
.progress-time {
|
||||||
color: var(--workbench-muted);
|
color: var(--workbench-muted);
|
||||||
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-time time {
|
.progress-time time {
|
||||||
@@ -669,6 +730,16 @@
|
|||||||
line-height: 1.2;
|
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 {
|
.progress-result {
|
||||||
justify-items: end;
|
justify-items: end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition:
|
transition:
|
||||||
max-width var(--rail-motion-duration) var(--rail-motion-ease),
|
max-width var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
@@ -250,16 +251,6 @@
|
|||||||
will-change: min-width, max-width, padding, opacity;
|
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 {
|
.rail-user {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -469,14 +460,6 @@
|
|||||||
transition-delay: 0ms;
|
transition-delay: 0ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-collapsed .nav-unread-dot {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 11px;
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-collapsed {
|
.rail-collapsed {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,197 +408,477 @@
|
|||||||
display: inline-flex;
|
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 {
|
.notification-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 1px;
|
right: 2px;
|
||||||
min-width: 13px;
|
min-width: 16px;
|
||||||
height: 13px;
|
height: 16px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 3px;
|
padding: 0 4px;
|
||||||
border: 2px solid #fff;
|
border: 2px solid #fff;
|
||||||
border-radius: 999px;
|
border-radius: 4px;
|
||||||
background: #ef4444;
|
background: #dc2626;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 8px;
|
font-size: 10px;
|
||||||
font-weight: 850;
|
font-weight: 800;
|
||||||
line-height: 1;
|
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 {
|
.notification-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 10px);
|
top: calc(100% + 8px);
|
||||||
right: -8px;
|
right: 0;
|
||||||
z-index: 60;
|
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;
|
display: grid;
|
||||||
gap: 10px;
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
padding: 12px;
|
gap: 0;
|
||||||
border: 1px solid #e5edf5;
|
overflow: hidden;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: #fff;
|
||||||
box-shadow:
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
|
||||||
0 18px 42px rgba(15, 23, 42, 0.14),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-popover::before {
|
.notification-popover::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
display: block;
|
||||||
top: -6px;
|
height: 3px;
|
||||||
right: 18px;
|
background: linear-gradient(
|
||||||
width: 10px;
|
90deg,
|
||||||
height: 10px;
|
var(--theme-primary-active) 0%,
|
||||||
border-top: 1px solid #e5edf5;
|
var(--theme-primary-light-3, #7eb3d4) 100%
|
||||||
border-left: 1px solid #e5edf5;
|
);
|
||||||
background: #fff;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-head,
|
|
||||||
.notification-tabs {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-head {
|
.notification-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
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;
|
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 {
|
.notification-head strong {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 850;
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-head button {
|
.notification-head small {
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-head button:hover {
|
.notification-head-actions {
|
||||||
background: #f1f5f9;
|
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;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-tabs {
|
.notification-tabs {
|
||||||
gap: 6px;
|
display: flex;
|
||||||
padding: 3px;
|
align-items: stretch;
|
||||||
border: 1px solid #edf2f7;
|
gap: 0;
|
||||||
border-radius: 4px;
|
padding: 0 14px;
|
||||||
background: #f8fafc;
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-tabs button {
|
.notification-tabs button {
|
||||||
|
position: relative;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
height: 28px;
|
height: 38px;
|
||||||
border-radius: 3px;
|
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;
|
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;
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
transition:
|
||||||
|
background 180ms var(--ease),
|
||||||
|
color 180ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-tabs button.active {
|
.notification-tabs button.active {
|
||||||
background: #fff;
|
|
||||||
color: var(--theme-primary-active);
|
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 {
|
.notification-list {
|
||||||
position: relative;
|
display: flex;
|
||||||
z-index: 1;
|
flex-direction: column;
|
||||||
display: grid;
|
min-height: 0;
|
||||||
max-height: 320px;
|
max-height: min(336px, calc(100vh - 226px));
|
||||||
overflow: auto;
|
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 {
|
.notification-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 8px minmax(0, 1fr) 16px;
|
grid-template-columns: 34px minmax(0, 1fr) 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 9px;
|
||||||
padding: 10px 4px;
|
min-height: 0;
|
||||||
border-top: 1px solid #edf2f7;
|
padding: 10px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background 180ms var(--ease),
|
||||||
|
border-color 180ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-row:first-child {
|
.notification-row + .notification-row {
|
||||||
border-top: 0;
|
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 {
|
.notification-row:hover {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-dot {
|
.notification-row.unread:hover {
|
||||||
width: 7px;
|
background: linear-gradient(90deg, var(--theme-primary-light-8) 0%, #f8fafc 48%);
|
||||||
height: 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--theme-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-dot.danger { background: #ef4444; }
|
.notification-type-icon {
|
||||||
.notification-dot.warning { background: #f59e0b; }
|
width: 34px;
|
||||||
.notification-dot.success { background: var(--success); }
|
height: 34px;
|
||||||
.notification-dot.info { background: #3b82f6; }
|
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 {
|
.notification-copy {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-copy strong,
|
.notification-copy strong {
|
||||||
.notification-copy small,
|
|
||||||
.notification-copy em {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-title-line {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-copy strong {
|
.notification-copy strong {
|
||||||
|
min-width: 0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 13px;
|
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 {
|
.notification-copy small {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 12px;
|
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;
|
color: #94a3b8;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-row > .mdi {
|
.notification-meta em {
|
||||||
color: #94a3b8;
|
flex: 1 1 auto;
|
||||||
font-size: 16px;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.notification-empty {
|
||||||
min-height: 112px;
|
min-height: 148px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
gap: 8px;
|
justify-items: center;
|
||||||
color: #94a3b8;
|
gap: 6px;
|
||||||
font-size: 13px;
|
padding: 24px 20px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-empty .mdi {
|
.notification-empty-icon {
|
||||||
font-size: 24px;
|
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 {
|
.company-switcher {
|
||||||
@@ -867,6 +1147,37 @@
|
|||||||
gap: 10px;
|
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 {
|
.company-switcher {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -911,6 +1222,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@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 {
|
.topbar.detail-mode {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
|||||||
@@ -291,6 +291,7 @@
|
|||||||
.receipt-all-field-grid {
|
.receipt-all-field-grid {
|
||||||
max-height: clamp(360px, 60vh, 640px);
|
max-height: clamp(360px, 60vh, 640px);
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -666,6 +667,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.receipt-static-grid,
|
.receipt-static-grid,
|
||||||
|
.receipt-all-field-grid,
|
||||||
.receipt-data-list.association {
|
.receipt-data-list.association {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1000,6 +1000,13 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.risk-note-editor-textarea {
|
||||||
|
min-height: 34px;
|
||||||
|
max-height: 78px;
|
||||||
|
overflow-y: auto;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
.currency-editor {
|
.currency-editor {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34px minmax(0, 1fr);
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
@@ -1062,6 +1069,37 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.expense-filled-at strong {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1950,12 +1988,46 @@
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.risk-override-card textarea.risk-note-editor-textarea {
|
||||||
|
min-height: 34px;
|
||||||
|
max-height: 78px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
.risk-override-card textarea:focus {
|
.risk-override-card textarea:focus {
|
||||||
border-color: #ef4444;
|
border-color: #ef4444;
|
||||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, .12);
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, .12);
|
||||||
outline: none;
|
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 {
|
.validation-card {
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
width="min(1040px, calc(100vw - 48px))"
|
width="min(1040px, calc(100vw - 48px))"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
:lock-scroll="true"
|
:lock-scroll="true"
|
||||||
:destroy-on-close="false"
|
destroy-on-close
|
||||||
class="expense-profile-dialog"
|
class="expense-profile-dialog"
|
||||||
modal-class="expense-profile-dialog-overlay"
|
modal-class="expense-profile-dialog-overlay"
|
||||||
body-class="expense-profile-dialog-body"
|
body-class="expense-profile-dialog-body"
|
||||||
|
|||||||
@@ -136,18 +136,19 @@ watch([() => props.visible, tagIdentity], ([visible]) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.profile-tag-pager {
|
.profile-tag-pager {
|
||||||
|
height: 100%;
|
||||||
min-height: 308px;
|
min-height: 308px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 272px 26px;
|
grid-template-rows: minmax(0, 1fr) 26px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tag-list {
|
.profile-tag-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: repeat(5, 48px);
|
grid-template-rows: repeat(5, minmax(48px, 1fr));
|
||||||
align-content: start;
|
align-content: stretch;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 272px;
|
min-height: 0;
|
||||||
animation: profileTagPageIn 180ms cubic-bezier(0.2, 0, 0, 1) both;
|
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;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 48px;
|
min-height: 0;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border: 1px solid rgba(var(--tag-accent-rgb), 0.2);
|
border: 1px solid rgba(var(--tag-accent-rgb), 0.2);
|
||||||
border-left: 3px solid rgb(var(--tag-accent-rgb));
|
border-left: 3px solid rgb(var(--tag-accent-rgb));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
width="min(980px, calc(100vw - 48px))"
|
width="min(980px, calc(100vw - 48px))"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
:lock-scroll="true"
|
:lock-scroll="true"
|
||||||
|
destroy-on-close
|
||||||
class="expense-stats-detail-dialog"
|
class="expense-stats-detail-dialog"
|
||||||
modal-class="expense-stats-detail-overlay"
|
modal-class="expense-stats-detail-overlay"
|
||||||
body-class="expense-stats-detail-body"
|
body-class="expense-stats-detail-body"
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
<div v-if="distributionChartItems.length" class="expense-distribution-chart">
|
<div v-if="distributionChartItems.length" class="expense-distribution-chart">
|
||||||
<div class="expense-distribution-chart-layout">
|
<div class="expense-distribution-chart-layout">
|
||||||
<DonutChart
|
<DonutChart
|
||||||
|
:key="distributionChartRenderKey"
|
||||||
class="expense-distribution-donut"
|
class="expense-distribution-donut"
|
||||||
:items="distributionChartItems"
|
:items="distributionChartItems"
|
||||||
:center-value="distributionCenterValue"
|
:center-value="distributionCenterValue"
|
||||||
@@ -139,7 +141,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||||
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||||
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
|
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
|
||||||
@@ -154,6 +156,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
const chartRenderSeq = ref(0)
|
||||||
|
|
||||||
const summaryMetrics = computed(() => [
|
const summaryMetrics = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -198,6 +201,7 @@ const distributionChartColors = [
|
|||||||
'var(--theme-primary-active)'
|
'var(--theme-primary-active)'
|
||||||
]
|
]
|
||||||
const distributionCenterValue = computed(() => props.summary.totalAmountLabel || '¥0')
|
const distributionCenterValue = computed(() => props.summary.totalAmountLabel || '¥0')
|
||||||
|
const distributionChartRenderKey = computed(() => `expense-distribution-${chartRenderSeq.value}`)
|
||||||
const distributionChartItems = computed(() => distributionRows.value.map((row, index) => ({
|
const distributionChartItems = computed(() => distributionRows.value.map((row, index) => ({
|
||||||
name: row.label,
|
name: row.label,
|
||||||
value: Number(row.amount || 0),
|
value: Number(row.amount || 0),
|
||||||
@@ -205,6 +209,15 @@ const distributionChartItems = computed(() => distributionRows.value.map((row, i
|
|||||||
color: distributionChartColors[index % distributionChartColors.length]
|
color: distributionChartColors[index % distributionChartColors.length]
|
||||||
})))
|
})))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
chartRenderSeq.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
function resolveDistributionColor(index) {
|
function resolveDistributionColor(index) {
|
||||||
return distributionChartColors[index % distributionChartColors.length]
|
return distributionChartColors[index % distributionChartColors.length]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,18 +205,23 @@
|
|||||||
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
|
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
|
||||||
@click="openWorkbenchTarget(item)"
|
@click="openWorkbenchTarget(item)"
|
||||||
>
|
>
|
||||||
|
<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">
|
<span class="progress-time">
|
||||||
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
|
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
|
||||||
<small>更新</small>
|
<small v-if="item.showTimeCapsule" class="time-capsule">更新时间</small>
|
||||||
|
<small v-if="item.showUpdateText">更新</small>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="progress-identity">
|
<span class="progress-identity">
|
||||||
<strong>{{ item.id }}</strong>
|
<strong>{{ item.title }}</strong>
|
||||||
<small>{{ item.title }}</small>
|
<small>{{ item.id }}</small>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="progress-type" :title="item.expenseTypeLabel || '其他费用'">
|
<span class="progress-type" :title="item.expenseTypeLabel || '其他费用'">
|
||||||
<small>费用类型</small>
|
|
||||||
<strong>{{ item.expenseTypeLabel || '其他费用' }}</strong>
|
<strong>{{ item.expenseTypeLabel || '其他费用' }}</strong>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -237,8 +242,8 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="progress-result">
|
<span class="progress-result">
|
||||||
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
|
|
||||||
<strong>{{ item.amount }}</strong>
|
<strong>{{ item.amount }}</strong>
|
||||||
|
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,6 +273,9 @@
|
|||||||
class="insight-metric-row"
|
class="insight-metric-row"
|
||||||
:class="`insight-metric-row--${item.tone}`"
|
: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>
|
<span class="insight-metric-label">{{ item.label }}</span>
|
||||||
<strong class="insight-metric-value">
|
<strong class="insight-metric-value">
|
||||||
{{ item.value }}<small v-if="item.unit">{{ item.unit }}</small>
|
{{ item.value }}<small v-if="item.unit">{{ item.unit }}</small>
|
||||||
@@ -478,6 +486,15 @@ const currentUserProfileKey = computed(() => {
|
|||||||
user.employee_no
|
user.employee_no
|
||||||
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
|
].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 visibleProgressItems = computed(() => {
|
||||||
const rows = Array.isArray(props.workbenchSummary.progressItems)
|
const rows = Array.isArray(props.workbenchSummary.progressItems)
|
||||||
? props.workbenchSummary.progressItems
|
? props.workbenchSummary.progressItems
|
||||||
@@ -488,10 +505,18 @@ const visibleProgressItems = computed(() => {
|
|||||||
isLongDuration: isLongDurationProgress(item?.updatedAt)
|
isLongDuration: isLongDurationProgress(item?.updatedAt)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return progressRows.map((item, index) => ({
|
return progressRows.map((item, index) => {
|
||||||
|
const isCompleted = item.statusTone === 'muted';
|
||||||
|
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel);
|
||||||
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
expenseTypeIcon: expenseStyle.icon,
|
||||||
|
expenseTypeTone: expenseStyle.tone,
|
||||||
|
showTimeCapsule: !item.isLongDuration,
|
||||||
|
showUpdateText: item.isLongDuration && !isCompleted,
|
||||||
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
|
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const LONG_DURATION_DAYS = 10
|
const LONG_DURATION_DAYS = 10
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
import { computed, shallowRef } from 'vue'
|
||||||
import { RadarChart as EChartsRadarChart } from 'echarts/charts'
|
import { RadarChart as EChartsRadarChart } from 'echarts/charts'
|
||||||
import { RadarComponent, TooltipComponent } from 'echarts/components'
|
import { RadarComponent, TooltipComponent } from 'echarts/components'
|
||||||
import { init, use } from 'echarts/core'
|
import { use } from 'echarts/core'
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
|
||||||
|
import { useEcharts } from '../../composables/useEcharts.js'
|
||||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||||
|
|
||||||
use([RadarComponent, EChartsRadarChart, TooltipComponent, CanvasRenderer])
|
use([RadarComponent, EChartsRadarChart, TooltipComponent, CanvasRenderer])
|
||||||
@@ -32,8 +33,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const themeColors = useThemeColors()
|
const themeColors = useThemeColors()
|
||||||
const chartElement = shallowRef(null)
|
const chartElement = shallowRef(null)
|
||||||
let chartInstance = null
|
|
||||||
let resizeObserver = null
|
|
||||||
|
|
||||||
const normalizedItems = computed(() =>
|
const normalizedItems = computed(() =>
|
||||||
props.items.map((item, index) => ({
|
props.items.map((item, index) => ({
|
||||||
@@ -62,8 +61,11 @@ const chartOptions = computed(() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
animationDuration: 760,
|
animation: true,
|
||||||
|
animationDuration: 980,
|
||||||
|
animationDurationUpdate: 760,
|
||||||
animationEasing: 'cubicOut',
|
animationEasing: 'cubicOut',
|
||||||
|
animationEasingUpdate: 'cubicOut',
|
||||||
color: [primary],
|
color: [primary],
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
@@ -123,6 +125,11 @@ const chartOptions = computed(() => {
|
|||||||
{
|
{
|
||||||
name: props.label,
|
name: props.label,
|
||||||
type: 'radar',
|
type: 'radar',
|
||||||
|
animation: true,
|
||||||
|
animationDuration: 980,
|
||||||
|
animationDurationUpdate: 760,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
animationEasingUpdate: 'cubicOut',
|
||||||
symbol: 'circle',
|
symbol: 'circle',
|
||||||
symbolSize: 7,
|
symbolSize: 7,
|
||||||
data: [
|
data: [
|
||||||
@@ -169,56 +176,7 @@ const chartOptions = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
useEcharts(chartElement, chartOptions)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTooltip() {
|
function formatTooltip() {
|
||||||
const rows = normalizedItems.value.map((item) => (
|
const rows = normalizedItems.value.map((item) => (
|
||||||
|
|||||||
@@ -51,7 +51,6 @@
|
|||||||
>
|
>
|
||||||
<span class="nav-icon" v-html="item.icon"></span>
|
<span class="nav-icon" v-html="item.icon"></span>
|
||||||
<span class="nav-label">{{ item.displayLabel }}</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>
|
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
|
||||||
</button>
|
</button>
|
||||||
</ElTooltip>
|
</ElTooltip>
|
||||||
@@ -113,9 +112,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
|
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
navItems: { type: Array, required: true },
|
navItems: { type: Array, required: true },
|
||||||
@@ -144,14 +141,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
|
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
|
||||||
|
|
||||||
const {
|
|
||||||
hasUnread: documentInboxHasUnread,
|
|
||||||
refreshDocumentInbox,
|
|
||||||
startDocumentInboxPolling,
|
|
||||||
stopDocumentInboxPolling
|
|
||||||
} = useDocumentCenterInbox()
|
|
||||||
let inboxInitialRefreshTimer = null
|
|
||||||
|
|
||||||
const sidebarMeta = {
|
const sidebarMeta = {
|
||||||
overview: { label: '分析看板' },
|
overview: { label: '分析看板' },
|
||||||
workbench: { label: '个人工作台' },
|
workbench: { label: '个人工作台' },
|
||||||
@@ -168,35 +157,10 @@ const decoratedNavItems = computed(() =>
|
|||||||
props.navItems.map((item) => ({
|
props.navItems.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
||||||
hasNewMessage: item.id === 'documents' ? documentInboxHasUnread.value : false,
|
|
||||||
badge: sidebarMeta[item.id]?.badge
|
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(() => ({
|
const displayUser = computed(() => ({
|
||||||
name: props.currentUser?.name || '系统管理员',
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
clearInboxInitialRefreshTimer()
|
|
||||||
stopDocumentInboxPolling()
|
|
||||||
closeCollapsedUserMenuNow()
|
closeCollapsedUserMenuNow()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
|
|
||||||
<template v-else-if="isWorkbench">
|
<template v-else-if="isWorkbench">
|
||||||
<div class="topbar-toolset" aria-label="工作台快捷工具">
|
<div class="topbar-toolset" aria-label="工作台快捷工具">
|
||||||
<div class="notification-wrap">
|
<div class="notification-wrap" :class="{ 'is-open': notificationOpen }">
|
||||||
<button
|
<button
|
||||||
class="topbar-icon-btn notification-btn"
|
class="topbar-icon-btn notification-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -136,12 +136,36 @@
|
|||||||
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<Transition name="notification-panel">
|
||||||
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
|
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
|
||||||
<header class="notification-head">
|
<header class="notification-head">
|
||||||
<strong>通知</strong>
|
<div class="notification-head-brand">
|
||||||
<button type="button" aria-label="关闭通知" @click="notificationOpen = false">
|
<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>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="notification-tabs" role="tablist" aria-label="通知状态">
|
<div class="notification-tabs" role="tablist" aria-label="通知状态">
|
||||||
@@ -152,7 +176,8 @@
|
|||||||
:class="{ active: notificationTab === 'unread' }"
|
:class="{ active: notificationTab === 'unread' }"
|
||||||
@click="notificationTab = 'unread'"
|
@click="notificationTab = 'unread'"
|
||||||
>
|
>
|
||||||
未读 {{ unreadNotifications.length }}
|
<span>未读</span>
|
||||||
|
<em>{{ unreadNotifications.length }}</em>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,7 +186,8 @@
|
|||||||
:class="{ active: notificationTab === 'read' }"
|
:class="{ active: notificationTab === 'read' }"
|
||||||
@click="notificationTab = 'read'"
|
@click="notificationTab = 'read'"
|
||||||
>
|
>
|
||||||
已读 {{ readNotifications.length }}
|
<span>已读</span>
|
||||||
|
<em>{{ readNotifications.length }}</em>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,22 +197,35 @@
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="notification-row"
|
class="notification-row"
|
||||||
|
:class="{ unread: item.unread }"
|
||||||
@click="openNotification(item)"
|
@click="openNotification(item)"
|
||||||
>
|
>
|
||||||
<span class="notification-dot" :class="item.tone"></span>
|
<span class="notification-type-icon" :class="item.tone">
|
||||||
<span class="notification-copy">
|
<i :class="resolveNotificationIcon(item)"></i>
|
||||||
<strong>{{ item.title }}</strong>
|
|
||||||
<small>{{ item.description }}</small>
|
|
||||||
<em>{{ item.time }}</em>
|
|
||||||
</span>
|
</span>
|
||||||
<i class="mdi mdi-chevron-right"></i>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="notification-empty">
|
<div v-else class="notification-empty">
|
||||||
|
<span class="notification-empty-icon" aria-hidden="true">
|
||||||
<i class="mdi mdi-bell-check-outline"></i>
|
<i class="mdi mdi-bell-check-outline"></i>
|
||||||
<span>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</span>
|
</span>
|
||||||
|
<strong>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</strong>
|
||||||
|
<span>新的单据与待办会在这里汇总展示</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="topbar-icon-btn" type="button" aria-label="帮助">
|
<button class="topbar-icon-btn" type="button" aria-label="帮助">
|
||||||
@@ -274,8 +313,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
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'
|
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -342,7 +383,8 @@ const emit = defineEmits([
|
|||||||
'batchApprove',
|
'batchApprove',
|
||||||
'openChat',
|
'openChat',
|
||||||
'newApplication',
|
'newApplication',
|
||||||
'openDocument'
|
'openDocument',
|
||||||
|
'navigate'
|
||||||
])
|
])
|
||||||
const isChat = computed(() => props.activeView === 'chat')
|
const isChat = computed(() => props.activeView === 'chat')
|
||||||
const isOverview = computed(() => props.activeView === 'overview')
|
const isOverview = computed(() => props.activeView === 'overview')
|
||||||
@@ -359,25 +401,198 @@ const eyebrowLabel = computed(() => (
|
|||||||
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
||||||
))
|
))
|
||||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
||||||
const topbarNotificationCount = computed(() => {
|
const MAX_NOTIFICATION_ITEMS = 30
|
||||||
const summary = props.workbenchSummary ?? {}
|
const {
|
||||||
const count = Number(summary.unreadNotificationCount ?? 0)
|
markDocumentInboxRowRead,
|
||||||
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0
|
markDocumentInboxRowsRead,
|
||||||
})
|
notificationRows: documentInboxNotificationRows,
|
||||||
|
refreshDocumentInbox,
|
||||||
|
startDocumentInboxPolling,
|
||||||
|
stopDocumentInboxPolling
|
||||||
|
} = useDocumentCenterInbox()
|
||||||
|
let documentInboxInitialRefreshTimer = null
|
||||||
const notificationOpen = ref(false)
|
const notificationOpen = ref(false)
|
||||||
|
const {
|
||||||
|
readNotificationIds,
|
||||||
|
hideNotificationStates,
|
||||||
|
isNotificationHidden,
|
||||||
|
isNotificationRead,
|
||||||
|
loadNotificationStates,
|
||||||
|
markNotificationStateRead
|
||||||
|
} = useTopBarNotificationStates()
|
||||||
const notificationTab = ref('unread')
|
const notificationTab = ref('unread')
|
||||||
const notificationItems = computed(() => (
|
|
||||||
|
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)
|
Array.isArray(props.workbenchSummary?.notifications)
|
||||||
? 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 unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
|
||||||
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
|
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
|
||||||
const activeNotifications = computed(() => (
|
const activeNotifications = computed(() => (
|
||||||
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
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) {
|
function openNotification(item) {
|
||||||
|
markNotificationRead(item)
|
||||||
notificationOpen.value = false
|
notificationOpen.value = false
|
||||||
const target = item?.target || {}
|
const target = item?.target || {}
|
||||||
if (target.type === 'document' && (target.id || target.claimNo)) {
|
if (target.type === 'document' && (target.id || target.claimNo)) {
|
||||||
@@ -575,6 +790,34 @@ watch(
|
|||||||
{ deep: true }
|
{ 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) {
|
function setRange(range) {
|
||||||
emit('update:activeRange', range)
|
emit('update:activeRange', range)
|
||||||
calendarOpen.value = false
|
calendarOpen.value = false
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseCla
|
|||||||
import {
|
import {
|
||||||
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
|
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
|
||||||
countNewDocuments,
|
countNewDocuments,
|
||||||
|
isNewDocument,
|
||||||
|
markDocumentViewed,
|
||||||
|
markDocumentsViewed,
|
||||||
readViewedDocumentKeys,
|
readViewedDocumentKeys,
|
||||||
resolveDocumentNewKey
|
resolveDocumentNewKey
|
||||||
} from '../utils/documentCenterNewState.js'
|
} from '../utils/documentCenterNewState.js'
|
||||||
@@ -24,6 +27,12 @@ let refreshPromise = null
|
|||||||
let lastRefreshAt = 0
|
let lastRefreshAt = 0
|
||||||
let viewedKeysListenerAttached = false
|
let viewedKeysListenerAttached = false
|
||||||
|
|
||||||
|
const SOURCE_LABELS = {
|
||||||
|
owned: '我的单据',
|
||||||
|
approval: '待我处理',
|
||||||
|
archive: '归档单据'
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeClaimText(...values) {
|
function normalizeClaimText(...values) {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const normalized = String(value || '').trim()
|
const normalized = String(value || '').trim()
|
||||||
@@ -35,18 +44,41 @@ function normalizeClaimText(...values) {
|
|||||||
return ''
|
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) {
|
function buildDocumentInboxRow(claim, source) {
|
||||||
const request = mapExpenseClaimToRequest(claim)
|
const request = mapExpenseClaimToRequest(claim)
|
||||||
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
|
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 documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
|
||||||
const documentKey = normalizeClaimText(claimId, documentNo)
|
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
|
return documentKey
|
||||||
? {
|
? {
|
||||||
source,
|
source,
|
||||||
claimId: claimId || documentKey,
|
claimId: claimId || documentKey,
|
||||||
documentNo,
|
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
|
: null
|
||||||
}
|
}
|
||||||
@@ -127,6 +159,25 @@ export function useDocumentCenterInbox() {
|
|||||||
|
|
||||||
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
|
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
|
||||||
const hasUnread = computed(() => unreadCount.value > 0)
|
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 = {}) {
|
async function refreshDocumentInbox(options = {}) {
|
||||||
const force = Boolean(options.force)
|
const force = Boolean(options.force)
|
||||||
@@ -191,6 +242,9 @@ export function useDocumentCenterInbox() {
|
|||||||
return {
|
return {
|
||||||
hasUnread,
|
hasUnread,
|
||||||
loading,
|
loading,
|
||||||
|
markDocumentInboxRowRead,
|
||||||
|
markDocumentInboxRowsRead,
|
||||||
|
notificationRows,
|
||||||
refreshDocumentInbox,
|
refreshDocumentInbox,
|
||||||
startDocumentInboxPolling,
|
startDocumentInboxPolling,
|
||||||
stopDocumentInboxPolling,
|
stopDocumentInboxPolling,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
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 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 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'])
|
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||||
@@ -83,6 +84,45 @@ function parseNumber(value) {
|
|||||||
return Number.isFinite(nextValue) ? nextValue : 0
|
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) {
|
function toDate(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
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))
|
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
|
||||||
})
|
})
|
||||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
|
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
|
||||||
|
const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
|
||||||
|
|
||||||
return sortedItems.map((item, index) => {
|
return sortedItems.map((item, index) => {
|
||||||
const invoiceId = String(item?.invoice_id || '').trim()
|
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 itemNote = String(item?.item_note || item?.itemNote || '').trim()
|
||||||
const itemAmount = parseNumber(item?.item_amount)
|
const itemAmount = parseNumber(item?.item_amount)
|
||||||
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -1297,6 +1342,15 @@ function buildExpenseItems(claim, riskMeta) {
|
|||||||
itemLocation,
|
itemLocation,
|
||||||
itemNote,
|
itemNote,
|
||||||
itemAmount,
|
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,
|
invoiceId,
|
||||||
isSystemGenerated,
|
isSystemGenerated,
|
||||||
dayLabel: resolveExpenseTimeLabel({
|
dayLabel: resolveExpenseTimeLabel({
|
||||||
@@ -1336,7 +1390,10 @@ export function mapExpenseClaimToRequest(claim) {
|
|||||||
const riskSummary = riskMeta.summary
|
const riskSummary = riskMeta.summary
|
||||||
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
||||||
const expenseItems = buildExpenseItems(claim, riskMeta)
|
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
|
const amountValue = relatedApplication
|
||||||
? expenseItems.length
|
? expenseItems.length
|
||||||
? visibleExpenseAmount
|
? visibleExpenseAmount
|
||||||
|
|||||||
163
web/src/composables/useTopBarNotificationStates.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,42 +196,48 @@ export function buildExpenseStatItems(summary = {}) {
|
|||||||
label: '本月报销笔数',
|
label: '本月报销笔数',
|
||||||
value: summary.monthlyCount ?? 0,
|
value: summary.monthlyCount ?? 0,
|
||||||
unit: '笔',
|
unit: '笔',
|
||||||
tone: 'primary'
|
tone: 'primary',
|
||||||
|
icon: 'mdi mdi-text-box-check-outline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'monthly-amount',
|
key: 'monthly-amount',
|
||||||
label: '本月报销金额',
|
label: '本月报销金额',
|
||||||
value: summary.monthlyAmountLabel || '¥0',
|
value: summary.monthlyAmountLabel || '¥0',
|
||||||
unit: '',
|
unit: '',
|
||||||
tone: 'amount'
|
tone: 'amount',
|
||||||
|
icon: 'mdi mdi-cash-multiple'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'total-count',
|
key: 'total-count',
|
||||||
label: '累计报销笔数',
|
label: '累计报销笔数',
|
||||||
value: summary.totalCount ?? 0,
|
value: summary.totalCount ?? 0,
|
||||||
unit: '笔',
|
unit: '笔',
|
||||||
tone: 'muted'
|
tone: 'muted',
|
||||||
|
icon: 'mdi mdi-clipboard-text-outline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'total-amount',
|
key: 'total-amount',
|
||||||
label: '累计报销金额',
|
label: '累计报销金额',
|
||||||
value: summary.totalAmountLabel || '¥0',
|
value: summary.totalAmountLabel || '¥0',
|
||||||
unit: '',
|
unit: '',
|
||||||
tone: 'amount'
|
tone: 'amount',
|
||||||
|
icon: 'mdi mdi-bank-outline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'in-review',
|
key: 'in-review',
|
||||||
label: '审批中单据',
|
label: '审批中单据',
|
||||||
value: summary.inReviewCount ?? 0,
|
value: summary.inReviewCount ?? 0,
|
||||||
unit: '笔',
|
unit: '笔',
|
||||||
tone: 'warning'
|
tone: 'warning',
|
||||||
|
icon: 'mdi mdi-timer-sand'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pending-payment',
|
key: 'pending-payment',
|
||||||
label: '待付款单据',
|
label: '待付款单据',
|
||||||
value: summary.pendingPaymentCount ?? 0,
|
value: summary.pendingPaymentCount ?? 0,
|
||||||
unit: '笔',
|
unit: '笔',
|
||||||
tone: 'info'
|
tone: 'info',
|
||||||
|
icon: 'mdi mdi-credit-card-outline'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
38
web/src/services/notificationStates.js
Normal 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 : []
|
||||||
|
}
|
||||||
@@ -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 = {}) {
|
export function calculateTravelReimbursement(payload = {}) {
|
||||||
return apiRequest('/reimbursements/travel-calculator', {
|
return apiRequest('/reimbursements/travel-calculator', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -239,6 +239,10 @@ export function canAccessAppView(user, viewId) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (viewId === 'workbench' && isPlatformAdminUser(user)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (viewId === 'budget') {
|
if (viewId === 'budget') {
|
||||||
if (isPlatformAdminUser(user)) {
|
if (isPlatformAdminUser(user)) {
|
||||||
return true
|
return true
|
||||||
@@ -269,6 +273,10 @@ export function filterNavItemsByAccess(navItems, user) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultAuthorizedRoute(user) {
|
export function resolveDefaultAuthorizedRoute(user) {
|
||||||
|
if (isPlatformAdminUser(user) && canAccessAppView(user, 'overview')) {
|
||||||
|
return { name: 'app-overview' }
|
||||||
|
}
|
||||||
|
|
||||||
const firstVisibleView = getAccessibleViewIds(user)[0]
|
const firstVisibleView = getAccessibleViewIds(user)[0]
|
||||||
return { name: `app-${firstVisibleView || 'workbench'}` }
|
return { name: `app-${firstVisibleView || 'workbench'}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,3 +77,26 @@ export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
|
|||||||
writeViewedDocumentKeys(nextKeys, storage)
|
writeViewedDocumentKeys(nextKeys, storage)
|
||||||
return nextKeys
|
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
|
||||||
|
}
|
||||||
|
|||||||
24
web/src/utils/documentCenterSort.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -400,6 +400,22 @@ function normalizeTransportModeOption(value, fallback = '') {
|
|||||||
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : 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 = '') {
|
function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||||
const numericAmount = Number(fields.amount || 0)
|
const numericAmount = Number(fields.amount || 0)
|
||||||
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
||||||
@@ -640,10 +656,7 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
|
|||||||
location: resolveProvidedValue(ontologyFields.location, currentFields.location),
|
location: resolveProvidedValue(ontologyFields.location, currentFields.location),
|
||||||
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
|
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
|
||||||
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
|
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
|
||||||
transportMode: normalizeTransportModeOption(
|
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
|
||||||
ontologyFields.transportMode,
|
|
||||||
currentFields.transportMode
|
|
||||||
),
|
|
||||||
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
|
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
|
||||||
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
|
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
|
||||||
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
|
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
|
||||||
|
|||||||
@@ -194,10 +194,11 @@ function buildTodoItems(ownedRequests) {
|
|||||||
.sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due)))
|
.sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProgressStatusTone(approvalKey) {
|
function resolveProgressStatusTone(approvalKey, statusText = '') {
|
||||||
if (approvalKey === 'completed') return 'muted'
|
const status = String(statusText || '').trim()
|
||||||
if (approvalKey === 'pending_payment') return 'warning'
|
if (approvalKey === 'completed' || /完成|结束|通过/i.test(status)) return 'muted'
|
||||||
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
|
if (approvalKey === 'pending_payment' || /付款|支付/i.test(status)) return 'warning'
|
||||||
|
if (approvalKey === 'supplement' || approvalKey === 'rejected' || /退回|驳回|修改/i.test(status)) return 'danger'
|
||||||
return 'success'
|
return 'success'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,14 +244,15 @@ function buildProgressItems(ownedRequests) {
|
|||||||
const currentStep = steps.find((step) => step.current)
|
const currentStep = steps.find((step) => step.current)
|
||||||
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
|
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
|
||||||
|
|
||||||
|
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
|
||||||
return {
|
return {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
requestId,
|
requestId,
|
||||||
title,
|
title,
|
||||||
expenseTypeLabel: resolveExpenseCategory(request),
|
expenseTypeLabel: resolveExpenseCategory(request),
|
||||||
amount: formatCurrency(request?.amount),
|
amount: formatCurrency(request?.amount),
|
||||||
status: normalizeText(request?.approvalStatus || currentStep?.label) || '处理中',
|
status,
|
||||||
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
|
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
|
||||||
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
|
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
|
||||||
steps,
|
steps,
|
||||||
target: resolveRequestTarget(request),
|
target: resolveRequestTarget(request),
|
||||||
@@ -391,12 +393,14 @@ function buildExpenseProcessingRows(ownedRequests) {
|
|||||||
const latestAt = dates[dates.length - 1] || toDate(request?.updatedAt || request?.submittedAt || request?.createdAt)
|
const latestAt = dates[dates.length - 1] || toDate(request?.updatedAt || request?.submittedAt || request?.createdAt)
|
||||||
const stepCount = Array.isArray(request?.progressSteps) ? request.progressSteps.length : 0
|
const stepCount = Array.isArray(request?.progressSteps) ? request.progressSteps.length : 0
|
||||||
|
|
||||||
|
const status = normalizeText(request?.approvalStatus || request?.status) || '处理中'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: requestId || title,
|
id: requestId || title,
|
||||||
requestId,
|
requestId,
|
||||||
title,
|
title,
|
||||||
status: normalizeText(request?.approvalStatus || request?.status) || '处理中',
|
status,
|
||||||
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
|
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
|
||||||
startedAt: startedAt ? formatDateTimeLabel(startedAt) : '暂无开始时间',
|
startedAt: startedAt ? formatDateTimeLabel(startedAt) : '暂无开始时间',
|
||||||
updatedAt: latestAt ? formatDateTimeLabel(latestAt) : '暂无更新时间',
|
updatedAt: latestAt ? formatDateTimeLabel(latestAt) : '暂无更新时间',
|
||||||
durationLabel: startedAt && latestAt ? formatDurationLabel(latestAt.getTime() - startedAt.getTime()) : '暂无耗时',
|
durationLabel: startedAt && latestAt ? formatDurationLabel(latestAt.getTime() - startedAt.getTime()) : '暂无耗时',
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||||
@new-application="openExpenseApplicationCreate"
|
@new-application="openExpenseApplicationCreate"
|
||||||
@open-document="openWorkbenchDocument"
|
@open-document="openWorkbenchDocument"
|
||||||
|
@navigate="handleNavigate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
:class="{ active: activeScopeTab === tab.value }"
|
:class="{ active: activeScopeTab === tab.value }"
|
||||||
@click="activeScopeTab = tab.value"
|
@click="activeScopeTab = tab.value"
|
||||||
>
|
>
|
||||||
<span>{{ tab.label }}</span>
|
<span class="scope-tab-label">
|
||||||
|
{{ tab.label }}
|
||||||
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
|
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
|
||||||
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
|
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -122,7 +124,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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')">
|
<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>
|
<i class="mdi mdi-file-plus-outline"></i>
|
||||||
<span>发起申请</span>
|
<span>发起申请</span>
|
||||||
@@ -238,7 +249,8 @@ import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
|||||||
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
||||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.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 { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
|
||||||
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
|
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
|
||||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||||
@@ -468,6 +480,17 @@ const scopeTabItems = computed(() =>
|
|||||||
badgeCount: scopeNewCountMap.value[tab] || 0
|
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(() => {
|
const activeScopeRows = computed(() => {
|
||||||
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
|
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
|
||||||
@@ -513,7 +536,7 @@ const statusFilterLabel = computed(() =>
|
|||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
const keyword = listKeyword.value.trim().toLowerCase()
|
const keyword = listKeyword.value.trim().toLowerCase()
|
||||||
|
|
||||||
return activeScopeRows.value.filter((row) => {
|
return sortDocumentRowsByLatestTime(activeScopeRows.value.filter((row) => {
|
||||||
const matchesKeyword = !keyword || [
|
const matchesKeyword = !keyword || [
|
||||||
row.documentNo,
|
row.documentNo,
|
||||||
row.documentTypeLabel,
|
row.documentTypeLabel,
|
||||||
@@ -534,7 +557,7 @@ const filteredRows = computed(() => {
|
|||||||
const matchesDateRange = matchesAppliedDateRange(row)
|
const matchesDateRange = matchesAppliedDateRange(row)
|
||||||
|
|
||||||
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange
|
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange
|
||||||
})
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
|
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 claimId = normalized.claimId || normalized.id || documentNo
|
||||||
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
|
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
|
||||||
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
|
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 documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
||||||
const documentTypeLabel =
|
const documentTypeLabel =
|
||||||
normalized.documentTypeLabel
|
normalized.documentTypeLabel
|
||||||
@@ -667,7 +692,9 @@ function buildDocumentRow(request, options = {}) {
|
|||||||
? false
|
? false
|
||||||
: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
|
: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
|
||||||
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
|
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) {
|
function resolveSourcePriority(row) {
|
||||||
@@ -831,6 +858,14 @@ function openDocument(row) {
|
|||||||
emit('open-document', row.rawRequest || row)
|
emit('open-document', row.rawRequest || row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markAllDocumentsRead() {
|
||||||
|
if (!totalNewDocumentCount.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewedDocumentKeys.value = markDocumentsViewed(allReadableDocumentRows.value, viewedDocumentKeys.value)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSupportingRows() {
|
async function loadSupportingRows() {
|
||||||
supportingLoading.value = true
|
supportingLoading.value = true
|
||||||
supportingError.value = ''
|
supportingError.value = ''
|
||||||
|
|||||||
@@ -130,16 +130,6 @@
|
|||||||
<i class="mdi mdi-robot-outline"></i>
|
<i class="mdi mdi-robot-outline"></i>
|
||||||
<span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span>
|
<span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div v-if="isApplicationDocument" class="application-detail-facts">
|
<div v-if="isApplicationDocument" class="application-detail-facts">
|
||||||
@@ -283,7 +273,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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>
|
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
@@ -380,9 +375,11 @@
|
|||||||
<div class="cell-editor">
|
<div class="cell-editor">
|
||||||
<textarea
|
<textarea
|
||||||
v-model="expenseEditor.itemNote"
|
v-model="expenseEditor.itemNote"
|
||||||
class="editor-textarea"
|
class="editor-textarea risk-note-editor-textarea"
|
||||||
rows="3"
|
rows="1"
|
||||||
placeholder="如票据存在异常或风险,请补充原因"
|
placeholder="如票据存在异常或风险,请补充原因"
|
||||||
|
@input="resizeExpenseNoteInput"
|
||||||
|
@keydown.enter="resizeExpenseNoteInput"
|
||||||
></textarea>
|
></textarea>
|
||||||
<span>用于说明改签、绕行、超标、票据异常等情况</span>
|
<span>用于说明改签、绕行、超标、票据异常等情况</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,7 +436,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<tr v-if="!expenseItems.length" class="empty-row">
|
<tr v-if="!expenseItems.length" class="empty-row">
|
||||||
<td :colspan="expenseTableColumnCount" class="empty-row-cell">
|
<td :colspan="expenseTableColumnCount" class="empty-row-cell">
|
||||||
当前还没有费用明细,点击右上角“增加明细”继续补充。
|
当前还没有费用明细,请通过智能录入上传票据后由系统自动归集。
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -774,7 +771,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="submit-confirm-row">
|
<div class="submit-confirm-row">
|
||||||
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
|
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
|
||||||
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
|
<strong>{{ submitConfirmAmountDisplay }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isApplicationDocument" class="submit-confirm-row">
|
<div v-if="!isApplicationDocument" class="submit-confirm-row">
|
||||||
<span>费用明细</span>
|
<span>费用明细</span>
|
||||||
@@ -784,20 +781,20 @@
|
|||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="riskOverrideDialogOpen"
|
:open="riskOverrideDialogOpen"
|
||||||
badge="重大风险"
|
badge="异常说明"
|
||||||
badge-tone="danger"
|
badge-tone="danger"
|
||||||
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
|
:title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`"
|
||||||
description="如仍需提交审批,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。"
|
description="请先补充异常说明后提交领导审批;也可以不填写说明,选择按职级最高可报销金额重新计算。"
|
||||||
cancel-text="返回整改"
|
cancel-text="返回整改"
|
||||||
confirm-text="保存原因并继续"
|
confirm-text="按职级标准重算"
|
||||||
busy-text="保存中..."
|
busy-text="处理中..."
|
||||||
confirm-tone="danger"
|
confirm-tone="danger"
|
||||||
confirm-icon="mdi mdi-alert-circle-outline"
|
confirm-icon="mdi mdi-calculator-variant-outline"
|
||||||
:busy="riskOverrideBusy"
|
:busy="riskOverrideBusy"
|
||||||
@close="closeRiskOverrideDialog"
|
@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">
|
<div class="risk-override-nav">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -827,11 +824,26 @@
|
|||||||
<p>{{ currentSubmitRiskWarning.risk }}</p>
|
<p>{{ currentSubmitRiskWarning.risk }}</p>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
|
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
|
||||||
|
class="risk-note-editor-textarea"
|
||||||
|
rows="1"
|
||||||
maxlength="160"
|
maxlength="160"
|
||||||
placeholder="请说明为什么仍需提交,例如客户指定酒店、会议高峰、协议酒店满房等"
|
placeholder="请说明原因,例如客户指定酒店、会议高峰、协议酒店满房等"
|
||||||
aria-label="违规提交原因"
|
aria-label="异常说明"
|
||||||
|
@input="resizeExpenseNoteInput"
|
||||||
|
@keydown.enter="resizeExpenseNoteInput"
|
||||||
></textarea>
|
></textarea>
|
||||||
</article>
|
</article>
|
||||||
|
<div class="risk-override-submit-row">
|
||||||
|
<button
|
||||||
|
class="risk-override-save-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="riskOverrideBusy"
|
||||||
|
@click="confirmRiskOverrideReasons"
|
||||||
|
>
|
||||||
|
保存说明并继续提交
|
||||||
|
</button>
|
||||||
|
<span>不填写说明时,系统会按职级最高报销标准重算金额。</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
<TravelRequestDeleteDialog :open="deleteDialogOpen" :badge="deleteActionLabel" :title="deleteDialogTitle" :description="deleteDialogDescription" :busy="deleteBusy" @close="closeDeleteDialog" @confirm="confirmDeleteRequest" />
|
<TravelRequestDeleteDialog :open="deleteDialogOpen" :badge="deleteActionLabel" :title="deleteDialogTitle" :description="deleteDialogDescription" :busy="deleteBusy" @close="closeDeleteDialog" @confirm="confirmDeleteRequest" />
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDele
|
|||||||
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
|
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
|
||||||
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
||||||
import {
|
import {
|
||||||
|
acceptExpenseClaimStandardAdjustment,
|
||||||
approveExpenseClaim,
|
approveExpenseClaim,
|
||||||
|
calculateTravelReimbursement,
|
||||||
createExpenseClaimItem,
|
createExpenseClaimItem,
|
||||||
deleteExpenseClaimItem,
|
deleteExpenseClaimItem,
|
||||||
deleteExpenseClaimItemAttachment,
|
deleteExpenseClaimItemAttachment,
|
||||||
@@ -88,6 +90,13 @@ import {
|
|||||||
resolveSubmitConfirmDescription,
|
resolveSubmitConfirmDescription,
|
||||||
resolveSubmitConfirmText
|
resolveSubmitConfirmText
|
||||||
} from './travelRequestDetailSubmitModel.js'
|
} from './travelRequestDetailSubmitModel.js'
|
||||||
|
import {
|
||||||
|
buildCurrentStandardAdjustmentMap,
|
||||||
|
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
|
||||||
|
filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel,
|
||||||
|
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
|
||||||
|
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel
|
||||||
|
} from './travelRequestDetailStandardAdjustment.js'
|
||||||
import {
|
import {
|
||||||
buildEmployeeProfileAdviceItems,
|
buildEmployeeProfileAdviceItems,
|
||||||
buildTravelReceiptMaterialPrompts
|
buildTravelReceiptMaterialPrompts
|
||||||
@@ -590,7 +599,6 @@ export default {
|
|||||||
const { currentUser } = useSystemState()
|
const { currentUser } = useSystemState()
|
||||||
const editingExpenseId = ref('')
|
const editingExpenseId = ref('')
|
||||||
const savingExpenseId = ref('')
|
const savingExpenseId = ref('')
|
||||||
const creatingExpense = ref(false)
|
|
||||||
const uploadingExpenseId = ref('')
|
const uploadingExpenseId = ref('')
|
||||||
const deletingAttachmentId = ref('')
|
const deletingAttachmentId = ref('')
|
||||||
const deletingExpenseId = ref('')
|
const deletingExpenseId = ref('')
|
||||||
@@ -898,7 +906,6 @@ export default {
|
|||||||
|| returnBusy.value
|
|| returnBusy.value
|
||||||
|| approveBusy.value
|
|| approveBusy.value
|
||||||
|| payBusy.value
|
|| payBusy.value
|
||||||
|| creatingExpense.value
|
|
||||||
|| smartEntryRecognitionBusy.value
|
|| smartEntryRecognitionBusy.value
|
||||||
|| Boolean(uploadingExpenseId.value)
|
|| Boolean(uploadingExpenseId.value)
|
||||||
|| Boolean(deletingAttachmentId.value)
|
|| Boolean(deletingAttachmentId.value)
|
||||||
@@ -996,9 +1003,16 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expenseTotal = computed(() => {
|
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)
|
return formatCurrency(total)
|
||||||
})
|
})
|
||||||
|
const submitConfirmAmountDisplay = computed(() =>
|
||||||
|
isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value
|
||||||
|
)
|
||||||
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
||||||
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
|
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
|
||||||
|
|
||||||
@@ -1086,7 +1100,7 @@ export default {
|
|||||||
return `已选择 ${names.length} 张附件`
|
return `已选择 ${names.length} 张附件`
|
||||||
})
|
})
|
||||||
const smartEntryUploadBusy = computed(() =>
|
const smartEntryUploadBusy = computed(() =>
|
||||||
smartEntryUploadDialogOpen.value && (creatingExpense.value || Boolean(uploadingExpenseId.value))
|
smartEntryUploadDialogOpen.value && Boolean(uploadingExpenseId.value)
|
||||||
)
|
)
|
||||||
const attachmentPreviewEntries = computed(() =>
|
const attachmentPreviewEntries = computed(() =>
|
||||||
expenseItems.value
|
expenseItems.value
|
||||||
@@ -1157,6 +1171,65 @@ export default {
|
|||||||
return requestFlags
|
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) {
|
function resolveAttachmentDisplayName(item) {
|
||||||
const metadata = resolveAttachmentMeta(item)
|
const metadata = resolveAttachmentMeta(item)
|
||||||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||||
@@ -1532,7 +1605,7 @@ export default {
|
|||||||
: []
|
: []
|
||||||
const scopedRiskCards = [
|
const scopedRiskCards = [
|
||||||
...(hasActionableRiskCards ? [] : summaryRiskCards),
|
...(hasActionableRiskCards ? [] : summaryRiskCards),
|
||||||
...directRiskCards
|
...filterSubmitterResolvedRiskCards(directRiskCards, currentBusinessStage)
|
||||||
]
|
]
|
||||||
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
|
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
|
||||||
|
|
||||||
@@ -1654,7 +1727,8 @@ export default {
|
|||||||
|
|
||||||
const submitRiskWarnings = computed(() =>
|
const submitRiskWarnings = computed(() =>
|
||||||
aiAdvice.value.riskCards
|
aiAdvice.value.riskCards
|
||||||
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
|
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||||||
|
.filter((card) => isRiskCardMissingExpenseNote(card))
|
||||||
.map((card, index) => ({
|
.map((card, index) => ({
|
||||||
...card,
|
...card,
|
||||||
id: String(card.id || `submit-risk-${index}`),
|
id: String(card.id || `submit-risk-${index}`),
|
||||||
@@ -1665,7 +1739,6 @@ export default {
|
|||||||
const riskOverrideIndexLabel = computed(() =>
|
const riskOverrideIndexLabel = computed(() =>
|
||||||
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
|
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
|
||||||
)
|
)
|
||||||
const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk'))
|
|
||||||
|
|
||||||
function resetDetailNote() {
|
function resetDetailNote() {
|
||||||
detailNoteEditor.value = detailNoteSource.value
|
detailNoteEditor.value = detailNoteSource.value
|
||||||
@@ -1724,6 +1797,18 @@ export default {
|
|||||||
riskOverrideDialogOpen.value = false
|
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() {
|
function goToPreviousSubmitRisk() {
|
||||||
if (!submitRiskWarnings.value.length) {
|
if (!submitRiskWarnings.value.length) {
|
||||||
return
|
return
|
||||||
@@ -1739,17 +1824,6 @@ export default {
|
|||||||
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
|
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) {
|
function mergeDetailNoteWithRiskOverride(appendix) {
|
||||||
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
|
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
|
||||||
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
|
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())
|
const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
|
||||||
if (missingIndex >= 0) {
|
if (missingIndex >= 0) {
|
||||||
riskOverrideIndex.value = missingIndex
|
riskOverrideIndex.value = missingIndex
|
||||||
toast('请为每一条重大风险填写违规提交原因。')
|
toast('请为每一条风险填写异常说明。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendix = buildRiskOverrideAppendix()
|
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 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (claimLevelRisks.length) {
|
||||||
|
const appendix = claimLevelRisks.join('\n')
|
||||||
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
|
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
|
||||||
if (nextNote.length > 500) {
|
if (nextNote.length > 500) {
|
||||||
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
|
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
riskOverrideBusy.value = true
|
|
||||||
try {
|
|
||||||
await updateExpenseClaim(request.value.claimId, {
|
await updateExpenseClaim(request.value.claimId, {
|
||||||
reason: nextNote
|
reason: nextNote
|
||||||
})
|
})
|
||||||
detailNoteEditor.value = nextNote
|
detailNoteEditor.value = nextNote
|
||||||
|
}
|
||||||
riskOverrideDialogOpen.value = false
|
riskOverrideDialogOpen.value = false
|
||||||
submitConfirmDialogOpen.value = true
|
submitConfirmDialogOpen.value = true
|
||||||
toast('违规提交原因已写入附加说明。')
|
toast('异常说明已保存,可继续提交审批。')
|
||||||
|
emit('request-updated', { claimId: request.value.claimId })
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
riskOverrideBusy.value = false
|
riskOverrideBusy.value = false
|
||||||
}
|
}
|
||||||
@@ -1811,6 +1948,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
populateExpenseEditor(item)
|
populateExpenseEditor(item)
|
||||||
|
void nextTick(() => {
|
||||||
|
const textarea = document.querySelector('.risk-note-editor-textarea')
|
||||||
|
resizeExpenseNoteInput({ target: textarea })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateExpenseEditor() {
|
function validateExpenseEditor() {
|
||||||
@@ -1839,48 +1980,6 @@ export default {
|
|||||||
return ''
|
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() {
|
function triggerSmartEntryUpload() {
|
||||||
if (!isEditableRequest.value || actionBusy.value) {
|
if (!isEditableRequest.value || actionBusy.value) {
|
||||||
return
|
return
|
||||||
@@ -2281,6 +2380,11 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submitRiskWarnings.value.length) {
|
||||||
|
openRiskOverrideDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
submitConfirmDialogOpen.value = true
|
submitConfirmDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2584,18 +2688,18 @@ export default {
|
|||||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
||||||
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
|
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
|
||||||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||||||
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload,
|
confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload,
|
||||||
chooseSmartEntryFile, clearSmartEntryFile,
|
chooseSmartEntryFile, clearSmartEntryFile,
|
||||||
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
||||||
currentSubmitRiskWarning,
|
currentSubmitRiskWarning,
|
||||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||||
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
||||||
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor,
|
||||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||||
focusExpenseRisk,
|
focusExpenseRisk,
|
||||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
|
handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
|
||||||
handleModifyApplication,
|
handleModifyApplication,
|
||||||
handlePayRequest,
|
handlePayRequest,
|
||||||
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||||
@@ -2606,7 +2710,7 @@ export default {
|
|||||||
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||||
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
||||||
resolveExpenseRiskIndicatorTitle,
|
resolveExpenseRiskIndicatorTitle,
|
||||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||||
resolveRiskCardDomId, isHighlightedRiskCard,
|
resolveRiskCardDomId, isHighlightedRiskCard,
|
||||||
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||||
@@ -2618,7 +2722,7 @@ export default {
|
|||||||
showAiAdvicePanel, showApplicationLeaderOpinion,
|
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||||||
showBudgetAnalysis, showStageRiskAdvice,
|
showBudgetAnalysis, showStageRiskAdvice,
|
||||||
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
|
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
|
||||||
submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
|
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
|
||||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ const APPLICATION_TYPE_ALIASES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application'])
|
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 = {
|
const STATUS_LABELS = {
|
||||||
submitted: '审批中',
|
submitted: '审批中',
|
||||||
@@ -53,6 +56,10 @@ function normalizeApplicationStatus(claim) {
|
|||||||
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
|
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalKey(claim) {
|
||||||
|
return normalizeLower(claim?.approvalKey || claim?.approval_key)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDocumentType(claim) {
|
function normalizeDocumentType(claim) {
|
||||||
return normalizeLower(
|
return normalizeLower(
|
||||||
claim?.document_type_code
|
claim?.document_type_code
|
||||||
@@ -141,6 +148,99 @@ function resolveApplicationDetailPayload(claim) {
|
|||||||
return detail && typeof detail === 'object' ? detail : {}
|
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) {
|
function toTimestamp(value) {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
||||||
@@ -265,9 +365,14 @@ export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUsableRequiredApplicationClaim(claim) {
|
export function isUsableRequiredApplicationClaim(claim, linkedApplicationReferences = null) {
|
||||||
const status = normalizeApplicationStatus(claim)
|
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) {
|
export function normalizeRequiredApplicationCandidate(claim) {
|
||||||
@@ -331,10 +436,12 @@ export function filterRequiredApplicationCandidates(claimsPayload, expenseType,
|
|||||||
? claimsPayload.claims
|
? claimsPayload.claims
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
const linkedApplicationReferences = buildLinkedApplicationReferenceIndex(claims)
|
||||||
|
|
||||||
return claims
|
return claims
|
||||||
.filter((claim) => (
|
.filter((claim) => (
|
||||||
isExpenseApplicationClaim(claim)
|
isExpenseApplicationClaim(claim)
|
||||||
&& isUsableRequiredApplicationClaim(claim)
|
&& isUsableRequiredApplicationClaim(claim, linkedApplicationReferences)
|
||||||
&& isClaimOwnedByCurrentUser(claim, currentUser)
|
&& isClaimOwnedByCurrentUser(claim, currentUser)
|
||||||
&& matchesRequiredApplicationExpenseType(claim, expenseType)
|
&& matchesRequiredApplicationExpenseType(claim, expenseType)
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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 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 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 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) {
|
export function parseCurrency(value) {
|
||||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
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) {
|
export function formatCurrency(value) {
|
||||||
return new Intl.NumberFormat('zh-CN', {
|
return new Intl.NumberFormat('zh-CN', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -395,6 +408,60 @@ export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, reque
|
|||||||
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
|
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()) {
|
export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||||||
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
|
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 invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||||||
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
||||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
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 amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||||||
|
const reimbursableAmountDisplay = reimbursableAmount > 0 ? formatCurrency(reimbursableAmount) : '待补充'
|
||||||
const riskText = String(source?.riskText || '').trim()
|
const riskText = String(source?.riskText || '').trim()
|
||||||
const filledAt = formatExpenseFilledTime(
|
const filledAt = formatExpenseFilledTime(
|
||||||
source?.filledAt
|
source?.filledAt
|
||||||
@@ -424,6 +497,15 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
|
|||||||
itemLocation,
|
itemLocation,
|
||||||
itemNote,
|
itemNote,
|
||||||
itemAmount,
|
itemAmount,
|
||||||
|
originalItemAmount,
|
||||||
|
originalAmountDisplay: originalItemAmount > 0 ? formatCurrency(originalItemAmount) : amountDisplay,
|
||||||
|
reimbursableAmount,
|
||||||
|
reimbursableAmountDisplay,
|
||||||
|
employeeAbsorbedAmount,
|
||||||
|
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatCurrency(employeeAbsorbedAmount) : '',
|
||||||
|
hasStandardAdjustment,
|
||||||
|
standardAdjustmentAccepted: Boolean(standardAdjustment),
|
||||||
|
standardAdjustmentMessage: standardAdjustment?.message || '',
|
||||||
invoiceId,
|
invoiceId,
|
||||||
isSystemGenerated,
|
isSystemGenerated,
|
||||||
time: itemDate || '待补充',
|
time: itemDate || '待补充',
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
|
|||||||
summary: normalizeText(analysis?.summary),
|
summary: normalizeText(analysis?.summary),
|
||||||
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
|
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
|
||||||
suggestion: buildCardSuggestion(analysis, insight),
|
suggestion: buildCardSuggestion(analysis, insight),
|
||||||
|
source: 'attachment_analysis',
|
||||||
itemType: normalizeText(item?.itemType),
|
itemType: normalizeText(item?.itemType),
|
||||||
documentType: normalizeText(insight?.documentTypeLabel),
|
documentType: normalizeText(insight?.documentTypeLabel),
|
||||||
visibility_scope: 'submitter',
|
visibility_scope: 'submitter',
|
||||||
@@ -645,6 +646,7 @@ export function buildAttachmentRiskCards({
|
|||||||
summary,
|
summary,
|
||||||
ruleBasis,
|
ruleBasis,
|
||||||
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
|
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
|
||||||
|
source,
|
||||||
risk_domain: flag.risk_domain || flag.riskDomain,
|
risk_domain: flag.risk_domain || flag.riskDomain,
|
||||||
visibility_scope: flag.visibility_scope || flag.visibilityScope,
|
visibility_scope: flag.visibility_scope || flag.visibilityScope,
|
||||||
actionability: flag.actionability
|
actionability: flag.actionability
|
||||||
|
|||||||
175
web/src/views/scripts/travelRequestDetailStandardAdjustment.js
Normal 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 }
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
|
|||||||
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
||||||
}
|
}
|
||||||
if (hasHighRiskWarnings) {
|
if (hasHighRiskWarnings) {
|
||||||
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
|
return '系统自动检测存在风险提示,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
|
||||||
}
|
}
|
||||||
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
|
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
canAccessAppView,
|
canAccessAppView,
|
||||||
canDeleteArchivedExpenseClaims,
|
canDeleteArchivedExpenseClaims,
|
||||||
canEditBudgetCenter,
|
canEditBudgetCenter,
|
||||||
|
filterNavItemsByAccess,
|
||||||
|
getAccessibleViewIds,
|
||||||
isCurrentDirectManagerForRequest,
|
isCurrentDirectManagerForRequest,
|
||||||
isCurrentRequestApplicant,
|
isCurrentRequestApplicant,
|
||||||
canManageExpenseClaims,
|
canManageExpenseClaims,
|
||||||
canReturnExpenseClaims,
|
canReturnExpenseClaims,
|
||||||
|
resolveDefaultAuthorizedRoute,
|
||||||
canSwitchBudgetDepartments
|
canSwitchBudgetDepartments
|
||||||
} from '../src/utils/accessControl.js'
|
} from '../src/utils/accessControl.js'
|
||||||
import { canProcessApprovalRequest } from '../src/utils/approvalInbox.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)
|
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', () => {
|
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({ isAdmin: true, roleCodes: ['manager'] }, 'budget'), true)
|
||||||
assert.equal(canAccessAppView({ username: 'admin', roleCodes: ['manager'] }, 'budget'), true)
|
assert.equal(canAccessAppView({ username: 'admin', roleCodes: ['manager'] }, 'budget'), true)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
countNewDocuments,
|
countNewDocuments,
|
||||||
isNewDocument,
|
isNewDocument,
|
||||||
markDocumentViewed,
|
markDocumentViewed,
|
||||||
|
markDocumentsViewed,
|
||||||
readDocumentScope,
|
readDocumentScope,
|
||||||
readViewedDocumentKeys,
|
readViewedDocumentKeys,
|
||||||
resolveDocumentNewKey,
|
resolveDocumentNewKey,
|
||||||
@@ -47,6 +48,19 @@ test('document center new state counts unseen documents and persists viewed rows
|
|||||||
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1'])
|
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', () => {
|
test('document center archive rows are never marked as new', () => {
|
||||||
const viewedKeys = readViewedDocumentKeys(createMemoryStorage())
|
const viewedKeys = readViewedDocumentKeys(createMemoryStorage())
|
||||||
const rows = [
|
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'])
|
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', () => {
|
test('document center scope state restores only allowed tabs', () => {
|
||||||
const storage = createMemoryStorage()
|
const storage = createMemoryStorage()
|
||||||
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
|
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
|
||||||
|
|||||||
33
web/tests/document-center-sort.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
@@ -68,6 +68,7 @@ test('documents center category tabs map to the intended row sources', () => {
|
|||||||
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
|
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
|
||||||
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
|
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
|
||||||
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
|
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, /activeScopeTab\.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
@@ -92,6 +93,23 @@ test('documents center category tabs map to the intended row sources', () => {
|
|||||||
assert.match(documentsCenterView, /return nonArchivedRows\.value/)
|
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', () => {
|
test('documents center preserves application document type from mapped requests', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
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', () => {
|
test('documents center action buttons are scoped to application and reimbursement tabs', () => {
|
||||||
|
assert.match(documentsCenterView, /v-if="showToolbarActions" class="document-actions"/)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
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(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
@@ -156,6 +175,7 @@ test('documents center category tabs render bubble counts for new documents', ()
|
|||||||
assert.match(documentsCenterView, /readViewedDocumentKeys/)
|
assert.match(documentsCenterView, /readViewedDocumentKeys/)
|
||||||
assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/)
|
assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/)
|
||||||
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
|
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, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
|
||||||
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
|
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
|
||||||
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
|
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
|
||||||
@@ -178,6 +198,31 @@ test('documents center category tabs render bubble counts for new documents', ()
|
|||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
|
/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', () => {
|
test('documents center rows show NEW marker until the row is opened', () => {
|
||||||
|
|||||||
@@ -366,6 +366,39 @@ test('application preview can be refined by ontology model extraction', () => {
|
|||||||
assert.equal(refinedPreview.fields.transportMode, '火车')
|
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', () => {
|
test('application preview precomputes a date range from today when only days are provided', () => {
|
||||||
const preview = buildLocalApplicationPreview(
|
const preview = buildLocalApplicationPreview(
|
||||||
'去北京出差3天,支撑国网仿生产环境部署,飞机,预计费用12000元',
|
'去北京出差3天,支撑国网仿生产环境部署,飞机,预计费用12000元',
|
||||||
|
|||||||
33
web/tests/expense-profile-detail-modal.test.mjs
Normal 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'/)
|
||||||
|
})
|
||||||
16
web/tests/expense-profile-tag-pager.test.mjs
Normal 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;/)
|
||||||
|
})
|
||||||
@@ -14,12 +14,17 @@ test('expense stats detail modal exposes distribution, processing time and opera
|
|||||||
assert.match(modal, /单据处理时间/)
|
assert.match(modal, /单据处理时间/)
|
||||||
assert.match(modal, /系统操作详情/)
|
assert.match(modal, /系统操作详情/)
|
||||||
assert.match(modal, /ElDialog/)
|
assert.match(modal, /ElDialog/)
|
||||||
|
assert.match(modal, /destroy-on-close/)
|
||||||
assert.match(modal, /ElTag/)
|
assert.match(modal, /ElTag/)
|
||||||
assert.match(modal, /import DonutChart from '\.\.\/charts\/DonutChart\.vue'/)
|
assert.match(modal, /import DonutChart from '\.\.\/charts\/DonutChart\.vue'/)
|
||||||
assert.match(modal, /<DonutChart/)
|
assert.match(modal, /<DonutChart/)
|
||||||
|
assert.match(modal, /:key="distributionChartRenderKey"/)
|
||||||
assert.match(modal, /:show-legend="false"/)
|
assert.match(modal, /:show-legend="false"/)
|
||||||
assert.match(modal, /distributionChartItems/)
|
assert.match(modal, /distributionChartItems/)
|
||||||
assert.match(modal, /distributionCenterValue/)
|
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, /distributionRows/)
|
||||||
assert.match(modal, /expense-distribution-summary-list/)
|
assert.match(modal, /expense-distribution-summary-list/)
|
||||||
assert.match(modal, /resolveDistributionColor/)
|
assert.match(modal, /resolveDistributionColor/)
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
|||||||
assert.match(receiptStyles, /\.receipt-association-panel/)
|
assert.match(receiptStyles, /\.receipt-association-panel/)
|
||||||
assert.match(receiptStyles, /\.receipt-preview-box[\s\S]*height: clamp\(380px, 56vh, 640px\)/)
|
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/)
|
||||||
|
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.match(receiptStyles, /\.receipt-edit-log-list/)
|
||||||
assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/)
|
assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/)
|
||||||
assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/)
|
assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/)
|
||||||
|
|||||||
@@ -8,6 +8,26 @@ const sidebar = readFileSync(
|
|||||||
'utf8'
|
'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(
|
const documentInbox = readFileSync(
|
||||||
fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)),
|
fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -18,22 +38,83 @@ const documentNewState = readFileSync(
|
|||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
|
||||||
test('sidebar renders a red dot for unread document center rows', () => {
|
const notificationStatesService = readFileSync(
|
||||||
assert.match(sidebar, /useDocumentCenterInbox/)
|
fileURLToPath(new URL('../src/services/notificationStates.js', import.meta.url)),
|
||||||
assert.match(sidebar, /hasUnread: documentInboxHasUnread/)
|
'utf8'
|
||||||
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\(\)/)
|
const topbarNotificationStates = readFileSync(
|
||||||
assert.match(sidebar, /startDocumentInboxPolling\(\)/)
|
fileURLToPath(new URL('../src/composables/useTopBarNotificationStates.js', import.meta.url)),
|
||||||
assert.match(sidebar, /stopDocumentInboxPolling\(\)/)
|
'utf8'
|
||||||
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;/)
|
|
||||||
|
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', () => {
|
test('document inbox reuses document center viewed-key state', () => {
|
||||||
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||||
assert.match(documentInbox, /readViewedDocumentKeys/)
|
assert.match(documentInbox, /readViewedDocumentKeys/)
|
||||||
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
|
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, /fetchExpenseClaims/)
|
||||||
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
|
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
|
||||||
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
|
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ test('guided reimbursement requires application selection for travel and enterta
|
|||||||
reason: '客户招待沟通项目',
|
reason: '客户招待沟通项目',
|
||||||
location: '武汉',
|
location: '武汉',
|
||||||
amount: 600,
|
amount: 600,
|
||||||
status: 'submitted',
|
status: 'approved',
|
||||||
created_at: '2026-05-21T08:00:00Z'
|
created_at: '2026-05-21T08:00:00Z'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -265,6 +265,44 @@ test('guided reimbursement requires application selection for travel and enterta
|
|||||||
reason: '草稿出差申请',
|
reason: '草稿出差申请',
|
||||||
status: 'draft'
|
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',
|
id: 'app-other-user',
|
||||||
claim_no: 'AP-202605-004',
|
claim_no: 'AP-202605-004',
|
||||||
|
|||||||