Compare commits
4 Commits
08a4fa3577
...
103f225f54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
103f225f54 | ||
|
|
e42dedaba1 | ||
|
|
607e127f59 | ||
|
|
6d33ba5742 |
@@ -37,6 +37,11 @@
|
||||
|
||||
根目录 `start.sh` 是统一编排入口;前端和后端的子启动脚本分别是 `web/web_start.sh` 与 `server/server_start.sh`。
|
||||
|
||||
Docker Compose 运行方式见 `docker/README.md`:
|
||||
|
||||
- `docker-compose.yml`:只启动主应用容器,适合复用已有数据库、ONLYOFFICE 等外部依赖。
|
||||
- `docker-compose.full.yml`:启动主应用、PostgreSQL、Qdrant、ONLYOFFICE 的完整本地开发栈。
|
||||
|
||||
手动进入前端目录:
|
||||
|
||||
```bash
|
||||
|
||||
141
docker-compose.full.yml
Normal file
141
docker-compose.full.yml
Normal file
@@ -0,0 +1,141 @@
|
||||
services:
|
||||
main:
|
||||
image: x-financial-dev:latest
|
||||
container_name: x-financial-main
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
onlyoffice:
|
||||
condition: service_started
|
||||
qdrant:
|
||||
condition: service_started
|
||||
environment:
|
||||
WEB_HOST: 0.0.0.0
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
||||
X_FINANCIAL_PREFER_ENV_FILE: "false"
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: "${POSTGRES_DB:-x_financial}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-x_financial}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-x_financial}"
|
||||
DATABASE_URL: "postgresql+psycopg://${POSTGRES_USER:-x_financial}:${POSTGRES_PASSWORD:-x_financial}@postgres:5432/${POSTGRES_DB:-x_financial}"
|
||||
ONLYOFFICE_ENABLED: "true"
|
||||
ONLYOFFICE_PUBLIC_URL: "${LOCAL_ONLYOFFICE_PUBLIC_URL:-http://127.0.0.1:${ONLYOFFICE_PORT:-8082}}"
|
||||
ONLYOFFICE_BACKEND_URL: "${LOCAL_ONLYOFFICE_BACKEND_URL:-http://main:${SERVER_PORT:-8000}}"
|
||||
ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||
QDRANT_URL: "http://qdrant:6333"
|
||||
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
|
||||
ports:
|
||||
- "${WEB_PORT:-5273}:${WEB_PORT:-5273}"
|
||||
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
||||
- "2223:22"
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command:
|
||||
- /bin/sh
|
||||
- -lc
|
||||
- >
|
||||
apt-get update &&
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
||||
python3 python3-pip python3-venv fontconfig openssh-server poppler-data &&
|
||||
if ! fc-match 'Noto Sans CJK SC' | grep -qi 'Noto'; then if ! timeout "${CJK_FONT_INSTALL_TIMEOUT_SECONDS:-45}" sh -lc 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra'; then printf '%s\n' '[WARN] CJK font installation timed out or failed; continuing startup without blocking the app.'; fi; fi &&
|
||||
printf '%s\n'
|
||||
'<?xml version="1.0"?>'
|
||||
'<!DOCTYPE fontconfig SYSTEM "fonts.dtd">'
|
||||
'<fontconfig>'
|
||||
' <alias><family>SimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||
' <alias><family>NSimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||
' <alias><family>KaiTi</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||
' <alias><family>FangSong</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||
' <alias><family>SimHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||
' <alias><family>DengXian</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||
' <alias><family>Microsoft YaHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||
'</fontconfig>'
|
||||
> /etc/fonts/local.conf &&
|
||||
fc-cache -f &&
|
||||
mkdir -p /run/sshd && /usr/sbin/sshd &&
|
||||
printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh &&
|
||||
chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&
|
||||
touch /root/.bashrc /root/.profile &&
|
||||
if ! grep -qxF 'cd /app >/dev/null 2>&1 || true' /root/.bashrc; then printf '\ncd /app >/dev/null 2>&1 || true\n' >> /root/.bashrc; fi &&
|
||||
if ! grep -qxF 'cd /app >/dev/null 2>&1 || true' /root/.profile; then printf '\ncd /app >/dev/null 2>&1 || true\n' >> /root/.profile; fi &&
|
||||
sed -i 's/\r$//' /app/start.sh /app/web/web_start.sh /app/server/server_start.sh &&
|
||||
chmod +x /app/start.sh /app/web/web_start.sh /app/server/server_start.sh &&
|
||||
cd /app &&
|
||||
./start.sh all
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5273}/ >/dev/null || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
networks:
|
||||
- financial-internal
|
||||
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: x-financial-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB:-x_financial}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-x_financial}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-x_financial}"
|
||||
ports:
|
||||
- "${POSTGRES_HOST_PORT:-55432}:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
networks:
|
||||
- financial-internal
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: x-financial-qdrant
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${QDRANT_HTTP_PORT:-6333}:6333"
|
||||
- "${QDRANT_GRPC_PORT:-6334}:6334"
|
||||
volumes:
|
||||
- qdrant-storage:/qdrant/storage
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -lc 'exec 3<>/dev/tcp/127.0.0.1/6333' || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
networks:
|
||||
- financial-internal
|
||||
|
||||
onlyoffice:
|
||||
image: onlyoffice/documentserver:latest
|
||||
container_name: x-financial-onlyoffice
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
JWT_ENABLED: "true"
|
||||
JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||
ports:
|
||||
- "${ONLYOFFICE_PORT:-8082}:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
networks:
|
||||
- financial-internal
|
||||
|
||||
networks:
|
||||
financial-internal:
|
||||
name: financial-internal
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
qdrant-storage:
|
||||
@@ -3,6 +3,13 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: "${POSTGRES_DB:-x_financial}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-x_financial}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-x_financial}"
|
||||
DATABASE_URL: "postgresql+psycopg://${POSTGRES_USER:-x_financial}:${POSTGRES_PASSWORD:-x_financial}@postgres:5432/${POSTGRES_DB:-x_financial}"
|
||||
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
|
||||
@@ -3,21 +3,16 @@ services:
|
||||
image: x-financial-dev:latest
|
||||
container_name: x-financial-main
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
onlyoffice:
|
||||
condition: service_started
|
||||
qdrant:
|
||||
condition: service_started
|
||||
environment:
|
||||
WEB_HOST: 0.0.0.0
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
||||
X_FINANCIAL_PREFER_ENV_FILE: "true"
|
||||
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-true}"
|
||||
ONLYOFFICE_PUBLIC_URL: "${ONLYOFFICE_PUBLIC_URL:-http://127.0.0.1:${ONLYOFFICE_PORT:-8082}}"
|
||||
ONLYOFFICE_BACKEND_URL: "http://main:${SERVER_PORT:-8000}"
|
||||
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-false}"
|
||||
ONLYOFFICE_PUBLIC_URL: "${ONLYOFFICE_PUBLIC_URL:-}"
|
||||
ONLYOFFICE_BACKEND_URL: "${ONLYOFFICE_BACKEND_URL:-}"
|
||||
ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||
QDRANT_URL: "http://qdrant:6333"
|
||||
QDRANT_URL: "${QDRANT_URL:-}"
|
||||
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
|
||||
ports:
|
||||
- "${WEB_PORT:-5273}:${WEB_PORT:-5273}"
|
||||
@@ -67,45 +62,6 @@ services:
|
||||
networks:
|
||||
- financial-internal
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: x-financial-qdrant
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${QDRANT_HTTP_PORT:-6333}:6333"
|
||||
- "${QDRANT_GRPC_PORT:-6334}:6334"
|
||||
volumes:
|
||||
- qdrant-storage:/qdrant/storage
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -lc 'exec 3<>/dev/tcp/127.0.0.1/6333' || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
networks:
|
||||
- financial-internal
|
||||
|
||||
onlyoffice:
|
||||
image: onlyoffice/documentserver:latest
|
||||
container_name: x-financial-onlyoffice
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
JWT_ENABLED: "true"
|
||||
JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||
ports:
|
||||
- "${ONLYOFFICE_PORT:-8082}:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
networks:
|
||||
- financial-internal
|
||||
|
||||
networks:
|
||||
financial-internal:
|
||||
name: financial-internal
|
||||
|
||||
volumes:
|
||||
qdrant-storage:
|
||||
|
||||
146
docker/README.md
146
docker/README.md
@@ -1,67 +1,127 @@
|
||||
# Docker Compose
|
||||
|
||||
This project currently uses the Vite `__setup/*` middleware during the initial setup flow.
|
||||
Because of that, the Docker deployment keeps the web frontend and FastAPI startup chain in
|
||||
the same main container and runs the existing root `start.sh`.
|
||||
X-Financial 现在按运行依赖分成两层 Docker Compose:
|
||||
|
||||
## Start
|
||||
- `docker-compose.yml`:只启动主应用容器,适合已经有远端 PostgreSQL、ONLYOFFICE 或 Qdrant 的环境。
|
||||
- `docker-compose.full.yml`:启动完整本地开发栈,适合没有外部依赖、希望本机一次性跑齐所有服务的环境。
|
||||
|
||||
主应用容器仍然同时启动 Web 前端和 FastAPI 后端,并复用根目录 `start.sh`。
|
||||
项目根目录会挂载到容器内 `/app`。
|
||||
|
||||
## 轻量启动:只跑主应用
|
||||
|
||||
适合你已经有数据库和 ONLYOFFICE 的情况。
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open:
|
||||
默认只会启动:
|
||||
|
||||
```text
|
||||
main
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
http://<your-linux-host>:5273
|
||||
```
|
||||
|
||||
## Container Layout
|
||||
这条路径不会主动拉起本地 PostgreSQL、Qdrant 或 ONLYOFFICE。
|
||||
数据库、ONLYOFFICE 和 Qdrant 地址都从 `.env` 或外部环境变量读取。
|
||||
|
||||
- `main`: web + FastAPI main container
|
||||
- `onlyoffice`: ONLYOFFICE Document Server
|
||||
- `postgres`: PostgreSQL database container
|
||||
|
||||
The project root is mounted directly into the main container:
|
||||
常见外部依赖变量:
|
||||
|
||||
```text
|
||||
.:/app
|
||||
DATABASE_URL
|
||||
POSTGRES_HOST
|
||||
POSTGRES_PORT
|
||||
ONLYOFFICE_ENABLED
|
||||
ONLYOFFICE_PUBLIC_URL
|
||||
ONLYOFFICE_BACKEND_URL
|
||||
QDRANT_URL
|
||||
```
|
||||
|
||||
That means the container reads your existing `.env`, source code, `server/.secrets`, logs,
|
||||
and generated dependency directories directly from the mapped project folder.
|
||||
## 完整启动:本地全栈
|
||||
|
||||
This is a `compose`-only setup. There is no custom `Dockerfile`.
|
||||
The tradeoff is that the `main` container installs the Python runtime packages it needs
|
||||
when it starts.
|
||||
适合没有远端数据库和 ONLYOFFICE 的情况。
|
||||
|
||||
## Persistence
|
||||
```bash
|
||||
docker compose -f docker-compose.full.yml up -d
|
||||
```
|
||||
|
||||
The PostgreSQL data directory is stored in the named volume `postgres_data`.
|
||||
会启动:
|
||||
|
||||
## Notes
|
||||
```text
|
||||
main
|
||||
postgres
|
||||
qdrant
|
||||
onlyoffice
|
||||
```
|
||||
|
||||
- Most configuration should be maintained in the project root `.env`.
|
||||
- The first `docker compose up -d` does not require an existing `.env`; the compose file
|
||||
uses built-in defaults for the PostgreSQL container and the main container database URL.
|
||||
- Docker Compose only overrides a few values that must differ inside containers:
|
||||
- `WEB_HOST=0.0.0.0`
|
||||
- `SERVER_HOST=0.0.0.0`
|
||||
- `POSTGRES_HOST=postgres`
|
||||
- `POSTGRES_PORT=5432`
|
||||
- `DATABASE_URL=...@postgres:...`
|
||||
- PostgreSQL is also published to the host by default as `127.0.0.1:55432`.
|
||||
- ONLYOFFICE is published to the host by default as `127.0.0.1:8082`.
|
||||
- First boot with `SETUP_COMPLETED=false` starts the setup UI only.
|
||||
- After you complete setup in the browser, the Vite setup bridge will start FastAPI in the
|
||||
same container using the saved runtime configuration.
|
||||
- On later restarts, `start.sh` will detect the saved setup state and start both web and
|
||||
server automatically.
|
||||
- If you access the system from another machine, make sure `CORS_ORIGINS` in `.env` includes
|
||||
the frontend address you actually use.
|
||||
- For Navicat or any host-side client, use `127.0.0.1:55432`.
|
||||
- If you need to access ONLYOFFICE from another machine, override `ONLYOFFICE_PUBLIC_URL`
|
||||
so the browser can reach the document server address you actually expose.
|
||||
- For the setup page, using `127.0.0.1` is acceptable in this Docker layout; the internal
|
||||
test bridge will resolve that back to the Docker PostgreSQL service.
|
||||
本地服务端口:
|
||||
|
||||
```text
|
||||
Web: 5273
|
||||
FastAPI: 8000
|
||||
PostgreSQL: 55432 -> 5432
|
||||
Qdrant: 6333 / 6334
|
||||
ONLYOFFICE: 8082
|
||||
SSH: 2223
|
||||
```
|
||||
|
||||
完整栈会把主容器内的数据库地址指向 `postgres:5432`,
|
||||
并把 Qdrant 地址指向 `http://qdrant:6333`。
|
||||
|
||||
ONLYOFFICE 默认使用本机可访问地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8082
|
||||
```
|
||||
|
||||
如果浏览器从另一台机器访问,需要覆盖:
|
||||
|
||||
```bash
|
||||
LOCAL_ONLYOFFICE_PUBLIC_URL=http://<host>:8082 \
|
||||
docker compose -f docker-compose.full.yml up -d
|
||||
```
|
||||
|
||||
如果 ONLYOFFICE 回调后端也需要外部地址,可以同时覆盖:
|
||||
|
||||
```bash
|
||||
LOCAL_ONLYOFFICE_BACKEND_URL=http://<host>:8000 \
|
||||
docker compose -f docker-compose.full.yml up -d
|
||||
```
|
||||
|
||||
## 可选:只额外启动本地 PostgreSQL
|
||||
|
||||
如果只想在轻量主容器旁边补一个本地 PostgreSQL,可以使用覆盖文件:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d
|
||||
```
|
||||
|
||||
这会启动:
|
||||
|
||||
```text
|
||||
main
|
||||
postgres
|
||||
```
|
||||
|
||||
## 停止与清理
|
||||
|
||||
停止当前默认轻量栈:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
停止完整本地栈:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.full.yml down
|
||||
```
|
||||
|
||||
如需删除本地数据卷,先确认不再需要其中数据,再手动执行带 `-v` 的清理命令。
|
||||
|
||||
159
docs/superpowers/plans/2026-06-22-refactor-under-800-lines.md
Normal file
159
docs/superpowers/plans/2026-06-22-refactor-under-800-lines.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Refactor Under 800 Lines Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Keep every Python class and every frontend core component/module under 800 lines, while deleting proven dead code and reducing avoidable runtime overhead.
|
||||
|
||||
**Architecture:** Add automated code-size guardrails first, then split oversized units by existing responsibility boundaries. Preserve public APIs wherever possible, move private helpers into focused modules, and delete code only after usage checks or tests prove it is not needed.
|
||||
|
||||
**Tech Stack:** Vue single-file components, Vite/Node test runner, Python/FastAPI service layer, pytest inside Docker container `x-financial-main`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Guardrails
|
||||
|
||||
**Files:**
|
||||
- Create: `web/tests/code-size-limits.test.mjs`
|
||||
- Create: `server/tests/test_code_size_limits.py`
|
||||
|
||||
- [x] **Step 1: Add frontend source-unit limit test**
|
||||
|
||||
```bash
|
||||
node --test web/tests/code-size-limits.test.mjs
|
||||
```
|
||||
|
||||
Expected current result: FAIL, listing oversized files in `web/src/components`, `web/src/composables`, `web/src/utils`, and `web/src/views`.
|
||||
|
||||
- [x] **Step 2: Add backend class limit test**
|
||||
|
||||
```bash
|
||||
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_code_size_limits.py
|
||||
```
|
||||
|
||||
Expected current result: FAIL, listing Python classes over 800 lines.
|
||||
|
||||
|
||||
### Task 2: Backend Python Class Split
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/app/services/user_agent_application.py`
|
||||
- Modify: `server/src/app/services/risk_rule_template_executor.py`
|
||||
- Modify: `server/src/app/services/steward_planner.py`
|
||||
- Modify: `server/src/app/services/receipt_folder.py`
|
||||
- Modify: `server/src/app/services/expense_claim_draft_flow.py`
|
||||
- Modify: `server/src/app/services/expense_claims.py`
|
||||
- Modify: `server/src/app/services/orchestrator_execution.py`
|
||||
- Modify: `server/src/app/services/finance_dashboard.py`
|
||||
- Modify: `server/src/app/services/agent_assets.py`
|
||||
- Create focused helper or mixin files under `server/src/app/services/`
|
||||
|
||||
- [ ] **Step 1: Split low-risk helper groups first**
|
||||
|
||||
Move private formatting, parsing, label, and serialization methods into helper mixins or helper modules. Keep original public classes and method names stable.
|
||||
|
||||
- [ ] **Step 2: Split domain-heavy groups**
|
||||
|
||||
Move larger responsibility groups into named mixins:
|
||||
|
||||
```text
|
||||
UserAgentApplicationMixin
|
||||
├── application fact resolution
|
||||
├── application persistence
|
||||
└── application duplicate/detail helpers
|
||||
|
||||
RiskRuleTemplateExecutor
|
||||
├── condition evaluators
|
||||
├── value resolvers
|
||||
└── date/window parsing
|
||||
|
||||
ReceiptFolderService
|
||||
├── storage/meta helpers
|
||||
├── editable field resolution
|
||||
└── train ticket extraction
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify backend class limit**
|
||||
|
||||
```bash
|
||||
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_code_size_limits.py
|
||||
```
|
||||
|
||||
Expected final result: PASS.
|
||||
|
||||
- [ ] **Step 4: Run targeted backend regression tests**
|
||||
|
||||
```bash
|
||||
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_user_agent_service.py server/tests/test_expense_claim_service.py server/tests/test_steward_planner.py server/tests/test_risk_rule_generation.py server/tests/test_agent_asset_service.py server/tests/test_reimbursement_endpoints.py
|
||||
```
|
||||
|
||||
Expected final result: PASS or a documented pre-existing failure with evidence.
|
||||
|
||||
|
||||
### Task 3: Frontend Source Unit Split
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/business/PersonalWorkbenchAiMode.vue`
|
||||
- Modify: `web/src/views/scripts/TravelReimbursementCreateView.js`
|
||||
- Modify: `web/src/views/scripts/TravelRequestDetailView.js`
|
||||
- Modify: `web/src/views/scripts/useTravelReimbursementSubmitComposer.js`
|
||||
- Modify remaining files reported by `web/tests/code-size-limits.test.mjs`
|
||||
- Create focused modules beside the existing owners under `web/src/views/scripts/`, `web/src/components/`, `web/src/composables/`, and `web/src/utils/`
|
||||
|
||||
- [ ] **Step 1: Split pure helpers before stateful runtime code**
|
||||
|
||||
Extract pure formatting, payload normalization, label mapping, row building, and text rendering helpers. This reduces file size without changing component state ownership.
|
||||
|
||||
- [ ] **Step 2: Split composables and child components**
|
||||
|
||||
For Vue files, move stable repeated UI blocks into child components only when props/events are clear. For script modules, move independent computed builders and action helpers into colocated modules.
|
||||
|
||||
- [ ] **Step 3: Remove proven redundant frontend code**
|
||||
|
||||
Use `rg` to confirm an export, class, helper, CSS hook, or branch is unused before deleting it. If a usage is dynamic, keep it unless a regression test proves it is dead.
|
||||
|
||||
- [ ] **Step 4: Verify frontend source limit**
|
||||
|
||||
```bash
|
||||
node --test web/tests/code-size-limits.test.mjs
|
||||
```
|
||||
|
||||
Expected final result: PASS.
|
||||
|
||||
- [ ] **Step 5: Run targeted frontend regression tests**
|
||||
|
||||
```bash
|
||||
node --test web/tests/workbench-ai-mode-switch.test.mjs web/tests/expense-application-fast-preview.test.mjs web/tests/expense-profile-detail-modal.test.mjs web/tests/finance-dashboard-ranking.test.mjs
|
||||
npm --prefix web run build
|
||||
```
|
||||
|
||||
Expected final result: PASS.
|
||||
|
||||
|
||||
### Task 4: Performance And Cleanup Pass
|
||||
|
||||
**Files:**
|
||||
- Modify only files already touched by Tasks 2 and 3 unless a usage check proves a separate dead module can be removed.
|
||||
|
||||
- [ ] **Step 1: Remove repeated computation inside hot paths**
|
||||
|
||||
Cache local computed values inside functions, avoid repeated JSON/string/date parsing loops, and prefer early returns for blocked states.
|
||||
|
||||
- [ ] **Step 2: Delete redundant private helpers**
|
||||
|
||||
Delete helpers only when all of these checks are true:
|
||||
|
||||
```bash
|
||||
rg "helperName" server web
|
||||
node --test affected-web-test.mjs
|
||||
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q affected_server_test.py
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Final verification**
|
||||
|
||||
```bash
|
||||
node --test web/tests/code-size-limits.test.mjs
|
||||
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_code_size_limits.py
|
||||
npm --prefix web run build
|
||||
```
|
||||
|
||||
Expected final result: PASS.
|
||||
BIN
docs/ui-mockups/attachment-association-enterprise-ui-v1.png
Normal file
BIN
docs/ui-mockups/attachment-association-enterprise-ui-v1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -46,17 +46,305 @@ from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_sco
|
||||
logger = get_logger("app.services.agent_assets")
|
||||
|
||||
|
||||
class AgentAssetService(
|
||||
AgentAssetOnlyOfficeMixin,
|
||||
AgentAssetSpreadsheetHelperMixin,
|
||||
AgentAssetRiskRuleLevelMixin,
|
||||
AgentAssetRiskRulePublishMixin,
|
||||
AgentAssetRiskRuleFeedbackMixin,
|
||||
AgentAssetRiskRuleTestingMixin,
|
||||
AgentAssetRiskRuleSimulationMixin,
|
||||
AgentAssetTimelineMixin,
|
||||
AgentAssetJsonRuleMixin,
|
||||
):
|
||||
class AgentAssetVersionMixin:
|
||||
def _validate_version_payload(
|
||||
self, asset: AgentAsset, payload: AgentAssetVersionCreate
|
||||
) -> None:
|
||||
if (
|
||||
asset.asset_type == AgentAssetType.RULE.value
|
||||
and payload.content_type != AgentAssetContentType.MARKDOWN
|
||||
):
|
||||
raise ValueError("规则资产版本内容必须使用 markdown。")
|
||||
if (
|
||||
asset.asset_type not in {AgentAssetType.RULE.value, AgentAssetType.TASK.value}
|
||||
and payload.content_type != AgentAssetContentType.JSON
|
||||
):
|
||||
raise ValueError("技能、MCP 资产版本内容必须使用 json。")
|
||||
if payload.content_type == AgentAssetContentType.MARKDOWN and not isinstance(
|
||||
payload.content, str
|
||||
):
|
||||
raise ValueError("Markdown 内容必须是字符串。")
|
||||
if payload.content_type == AgentAssetContentType.JSON and not isinstance(
|
||||
payload.content, (dict, list)
|
||||
):
|
||||
raise ValueError("JSON 内容必须是对象或数组。")
|
||||
|
||||
def restore_version_as_working_copy(
|
||||
self,
|
||||
asset_id: str,
|
||||
source_version: str,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAssetRead:
|
||||
self._ensure_ready()
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise LookupError("Asset not found")
|
||||
|
||||
source = self.repository.get_version(asset_id, source_version)
|
||||
if source is None:
|
||||
raise LookupError(f"版本 {source_version} 不存在")
|
||||
|
||||
if (
|
||||
asset.asset_type == AgentAssetType.RULE.value
|
||||
and str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
):
|
||||
metadata = self.spreadsheet_manager.parse_version_markdown(str(source.content or ""))
|
||||
if metadata is None:
|
||||
raise FileNotFoundError("历史规则表快照不存在,无法恢复。")
|
||||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(metadata.file_name)
|
||||
restored = self.upload_rule_spreadsheet(
|
||||
asset.id,
|
||||
filename=metadata.file_name,
|
||||
content=file_path.read_bytes(),
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||||
source="restore",
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="restore_agent_asset_version",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"source_version": source_version},
|
||||
after_json={"working_version": restored.working_version},
|
||||
request_id=request_id,
|
||||
)
|
||||
return restored
|
||||
|
||||
next_version = self._increment_version(self._resolve_working_version(asset))
|
||||
self.create_version(
|
||||
asset.id,
|
||||
AgentAssetVersionCreate(
|
||||
version=next_version,
|
||||
content=self._deserialize_content(source),
|
||||
content_type=AgentAssetContentType(source.content_type),
|
||||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||||
created_by=actor,
|
||||
),
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
restored = self.get_asset(asset.id)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="restore_agent_asset_version",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"source_version": source_version},
|
||||
after_json={"working_version": next_version},
|
||||
request_id=request_id,
|
||||
)
|
||||
return restored # type: ignore[return-value]
|
||||
|
||||
def _serialize_version(
|
||||
self, version: AgentAssetVersion, asset: AgentAsset
|
||||
) -> AgentAssetVersionRead:
|
||||
latest_review = self.repository.get_review(asset.id, version.version)
|
||||
working_version = self._resolve_working_version(asset)
|
||||
published_version = self._resolve_published_version(asset)
|
||||
return AgentAssetVersionRead(
|
||||
id=version.id,
|
||||
asset_id=version.asset_id,
|
||||
version=version.version,
|
||||
content=self._deserialize_content(version),
|
||||
content_type=version.content_type,
|
||||
change_note=version.change_note,
|
||||
created_by=version.created_by,
|
||||
created_at=version.created_at,
|
||||
is_current=version.version == working_version,
|
||||
is_published=version.version == published_version,
|
||||
is_working=version.version == working_version,
|
||||
lifecycle_state=self._resolve_version_lifecycle_state(
|
||||
version.version,
|
||||
working_version=working_version,
|
||||
published_version=published_version,
|
||||
latest_review_status=latest_review.review_status if latest_review else "",
|
||||
),
|
||||
)
|
||||
|
||||
def _collect_version_stats(self, assets: list[AgentAsset]) -> dict[str, dict[str, Any]]:
|
||||
asset_ids = [item.id for item in assets]
|
||||
versions = self.repository.list_versions_for_assets(asset_ids)
|
||||
reviews = self.repository.list_reviews_for_assets(asset_ids)
|
||||
spreadsheet_logs = self.audit_service.repository.list_for_resources(
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_ids=[
|
||||
item.id
|
||||
for item in assets
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
],
|
||||
action="edit_rule_spreadsheet",
|
||||
)
|
||||
working_versions = {item.id: self._resolve_working_version(item) for item in assets}
|
||||
version_counts: dict[str, int] = defaultdict(int)
|
||||
modified_by: dict[str, str | None] = {item.id: None for item in assets}
|
||||
published_versions = {item.id: self._resolve_published_version(item) for item in assets}
|
||||
published_by: dict[str, str | None] = {}
|
||||
published_at: dict[str, datetime | None] = {}
|
||||
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
|
||||
spreadsheet_last_actor: dict[str, str | None] = {}
|
||||
spreadsheet_last_changed_at: dict[str, datetime] = {}
|
||||
|
||||
for version in versions:
|
||||
version_counts[version.asset_id] += 1
|
||||
if modified_by.get(
|
||||
version.asset_id
|
||||
) is None and version.version == working_versions.get(version.asset_id):
|
||||
modified_by[version.asset_id] = version.created_by
|
||||
|
||||
for review in reviews:
|
||||
if review.asset_id in published_at:
|
||||
continue
|
||||
if review.version != published_versions.get(review.asset_id):
|
||||
continue
|
||||
if review.review_status != AgentReviewStatus.APPROVED.value:
|
||||
continue
|
||||
published_by[review.asset_id] = review.reviewer
|
||||
published_at[review.asset_id] = review.reviewed_at or review.created_at
|
||||
|
||||
for log in spreadsheet_logs:
|
||||
spreadsheet_edit_counts[log.resource_id] += 1
|
||||
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
|
||||
if last_changed_at is None or log.created_at >= last_changed_at:
|
||||
spreadsheet_last_changed_at[log.resource_id] = log.created_at
|
||||
spreadsheet_last_actor[log.resource_id] = log.actor
|
||||
|
||||
return {
|
||||
item.id: {
|
||||
"change_count": (
|
||||
spreadsheet_edit_counts.get(item.id, 0)
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
and spreadsheet_edit_counts.get(item.id, 0) > 0
|
||||
else max(version_counts.get(item.id, 0) - 1, 0)
|
||||
),
|
||||
"modified_by": (
|
||||
spreadsheet_last_actor.get(item.id)
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
and spreadsheet_last_actor.get(item.id)
|
||||
else modified_by.get(item.id)
|
||||
),
|
||||
"published_by": published_by.get(item.id),
|
||||
"published_at": published_at.get(item.id),
|
||||
}
|
||||
for item in assets
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_list_item(
|
||||
asset: AgentAsset,
|
||||
version_stats: dict[str, int | str | None] | None = None,
|
||||
) -> AgentAssetListItem:
|
||||
payload = AgentAssetListItem.model_validate(asset).model_dump()
|
||||
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
|
||||
payload["modified_by"] = str((version_stats or {}).get("modified_by") or "").strip() or None
|
||||
payload["published_by"] = (
|
||||
str((version_stats or {}).get("published_by") or "").strip() or None
|
||||
)
|
||||
payload["published_at"] = (version_stats or {}).get("published_at")
|
||||
return AgentAssetListItem.model_validate(payload)
|
||||
|
||||
@staticmethod
|
||||
def _sort_versions(
|
||||
versions: list[AgentAssetVersion], current_version: str | None
|
||||
) -> list[AgentAssetVersion]:
|
||||
return sorted(
|
||||
versions,
|
||||
key=lambda item: (item.version == current_version, item.created_at),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content(content: Any, content_type: str) -> str:
|
||||
if content_type == AgentAssetContentType.MARKDOWN.value:
|
||||
return str(content)
|
||||
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_content(version: AgentAssetVersion | None) -> Any:
|
||||
if version is None:
|
||||
return None
|
||||
if version.content_type == AgentAssetContentType.MARKDOWN.value:
|
||||
return version.content
|
||||
return json.loads(version.content)
|
||||
|
||||
@staticmethod
|
||||
def _increment_version(version: str | None) -> str:
|
||||
normalized = str(version or "").strip().removeprefix("v")
|
||||
parts = normalized.split(".")
|
||||
if len(parts) != 3 or not all(item.isdigit() for item in parts):
|
||||
return "v1.0.0"
|
||||
major, minor, patch = [int(item) for item in parts]
|
||||
return f"v{major}.{minor}.{patch + 1}"
|
||||
|
||||
@staticmethod
|
||||
def _hash_bytes(content: bytes) -> str:
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]:
|
||||
return {
|
||||
"asset_type": asset.asset_type,
|
||||
"code": asset.code,
|
||||
"name": asset.name,
|
||||
"status": asset.status,
|
||||
"current_version": asset.current_version,
|
||||
"published_version": asset.published_version,
|
||||
"working_version": asset.working_version,
|
||||
"domain": asset.domain,
|
||||
"owner": asset.owner,
|
||||
"reviewer": asset.reviewer,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_working_version(asset: AgentAsset) -> str:
|
||||
return str(asset.working_version or asset.current_version or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_published_version(asset: AgentAsset) -> str:
|
||||
return str(asset.published_version or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_version_lifecycle_state(
|
||||
version: str,
|
||||
*,
|
||||
working_version: str,
|
||||
published_version: str,
|
||||
latest_review_status: str,
|
||||
) -> str:
|
||||
if version == published_version:
|
||||
return "published"
|
||||
if version != working_version:
|
||||
return "history"
|
||||
if latest_review_status == AgentReviewStatus.PENDING.value:
|
||||
return "pending_review"
|
||||
if latest_review_status == AgentReviewStatus.APPROVED.value:
|
||||
return "approved"
|
||||
if latest_review_status == AgentReviewStatus.REJECTED.value:
|
||||
return "rejected"
|
||||
return "draft"
|
||||
|
||||
def _next_available_version(self, asset: AgentAsset) -> str:
|
||||
candidate = self._increment_version(self._resolve_working_version(asset))
|
||||
while self.repository.get_version(asset.id, candidate) is not None:
|
||||
candidate = self._increment_version(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, AgentAssetSpreadsheetHelperMixin, AgentAssetRiskRuleLevelMixin, AgentAssetRiskRulePublishMixin, AgentAssetRiskRuleFeedbackMixin, AgentAssetRiskRuleTestingMixin, AgentAssetRiskRuleSimulationMixin, AgentAssetTimelineMixin, AgentAssetJsonRuleMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.repository = AgentAssetRepository(db)
|
||||
@@ -559,298 +847,3 @@ class AgentAssetService(
|
||||
self.db.commit()
|
||||
return synced_count
|
||||
|
||||
def _validate_version_payload(
|
||||
self, asset: AgentAsset, payload: AgentAssetVersionCreate
|
||||
) -> None:
|
||||
if (
|
||||
asset.asset_type == AgentAssetType.RULE.value
|
||||
and payload.content_type != AgentAssetContentType.MARKDOWN
|
||||
):
|
||||
raise ValueError("规则资产版本内容必须使用 markdown。")
|
||||
if (
|
||||
asset.asset_type not in {AgentAssetType.RULE.value, AgentAssetType.TASK.value}
|
||||
and payload.content_type != AgentAssetContentType.JSON
|
||||
):
|
||||
raise ValueError("技能、MCP 资产版本内容必须使用 json。")
|
||||
if payload.content_type == AgentAssetContentType.MARKDOWN and not isinstance(
|
||||
payload.content, str
|
||||
):
|
||||
raise ValueError("Markdown 内容必须是字符串。")
|
||||
if payload.content_type == AgentAssetContentType.JSON and not isinstance(
|
||||
payload.content, (dict, list)
|
||||
):
|
||||
raise ValueError("JSON 内容必须是对象或数组。")
|
||||
|
||||
def restore_version_as_working_copy(
|
||||
self,
|
||||
asset_id: str,
|
||||
source_version: str,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAssetRead:
|
||||
self._ensure_ready()
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise LookupError("Asset not found")
|
||||
|
||||
source = self.repository.get_version(asset_id, source_version)
|
||||
if source is None:
|
||||
raise LookupError(f"版本 {source_version} 不存在")
|
||||
|
||||
if (
|
||||
asset.asset_type == AgentAssetType.RULE.value
|
||||
and str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
):
|
||||
metadata = self.spreadsheet_manager.parse_version_markdown(str(source.content or ""))
|
||||
if metadata is None:
|
||||
raise FileNotFoundError("历史规则表快照不存在,无法恢复。")
|
||||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(metadata.file_name)
|
||||
restored = self.upload_rule_spreadsheet(
|
||||
asset.id,
|
||||
filename=metadata.file_name,
|
||||
content=file_path.read_bytes(),
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||||
source="restore",
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="restore_agent_asset_version",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"source_version": source_version},
|
||||
after_json={"working_version": restored.working_version},
|
||||
request_id=request_id,
|
||||
)
|
||||
return restored
|
||||
|
||||
next_version = self._increment_version(self._resolve_working_version(asset))
|
||||
self.create_version(
|
||||
asset.id,
|
||||
AgentAssetVersionCreate(
|
||||
version=next_version,
|
||||
content=self._deserialize_content(source),
|
||||
content_type=AgentAssetContentType(source.content_type),
|
||||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||||
created_by=actor,
|
||||
),
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
restored = self.get_asset(asset.id)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="restore_agent_asset_version",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"source_version": source_version},
|
||||
after_json={"working_version": next_version},
|
||||
request_id=request_id,
|
||||
)
|
||||
return restored # type: ignore[return-value]
|
||||
|
||||
def _serialize_version(
|
||||
self, version: AgentAssetVersion, asset: AgentAsset
|
||||
) -> AgentAssetVersionRead:
|
||||
latest_review = self.repository.get_review(asset.id, version.version)
|
||||
working_version = self._resolve_working_version(asset)
|
||||
published_version = self._resolve_published_version(asset)
|
||||
return AgentAssetVersionRead(
|
||||
id=version.id,
|
||||
asset_id=version.asset_id,
|
||||
version=version.version,
|
||||
content=self._deserialize_content(version),
|
||||
content_type=version.content_type,
|
||||
change_note=version.change_note,
|
||||
created_by=version.created_by,
|
||||
created_at=version.created_at,
|
||||
is_current=version.version == working_version,
|
||||
is_published=version.version == published_version,
|
||||
is_working=version.version == working_version,
|
||||
lifecycle_state=self._resolve_version_lifecycle_state(
|
||||
version.version,
|
||||
working_version=working_version,
|
||||
published_version=published_version,
|
||||
latest_review_status=latest_review.review_status if latest_review else "",
|
||||
),
|
||||
)
|
||||
|
||||
def _collect_version_stats(self, assets: list[AgentAsset]) -> dict[str, dict[str, Any]]:
|
||||
asset_ids = [item.id for item in assets]
|
||||
versions = self.repository.list_versions_for_assets(asset_ids)
|
||||
reviews = self.repository.list_reviews_for_assets(asset_ids)
|
||||
spreadsheet_logs = self.audit_service.repository.list_for_resources(
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_ids=[
|
||||
item.id
|
||||
for item in assets
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
],
|
||||
action="edit_rule_spreadsheet",
|
||||
)
|
||||
working_versions = {item.id: self._resolve_working_version(item) for item in assets}
|
||||
version_counts: dict[str, int] = defaultdict(int)
|
||||
modified_by: dict[str, str | None] = {item.id: None for item in assets}
|
||||
published_versions = {item.id: self._resolve_published_version(item) for item in assets}
|
||||
published_by: dict[str, str | None] = {}
|
||||
published_at: dict[str, datetime | None] = {}
|
||||
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
|
||||
spreadsheet_last_actor: dict[str, str | None] = {}
|
||||
spreadsheet_last_changed_at: dict[str, datetime] = {}
|
||||
|
||||
for version in versions:
|
||||
version_counts[version.asset_id] += 1
|
||||
if modified_by.get(
|
||||
version.asset_id
|
||||
) is None and version.version == working_versions.get(version.asset_id):
|
||||
modified_by[version.asset_id] = version.created_by
|
||||
|
||||
for review in reviews:
|
||||
if review.asset_id in published_at:
|
||||
continue
|
||||
if review.version != published_versions.get(review.asset_id):
|
||||
continue
|
||||
if review.review_status != AgentReviewStatus.APPROVED.value:
|
||||
continue
|
||||
published_by[review.asset_id] = review.reviewer
|
||||
published_at[review.asset_id] = review.reviewed_at or review.created_at
|
||||
|
||||
for log in spreadsheet_logs:
|
||||
spreadsheet_edit_counts[log.resource_id] += 1
|
||||
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
|
||||
if last_changed_at is None or log.created_at >= last_changed_at:
|
||||
spreadsheet_last_changed_at[log.resource_id] = log.created_at
|
||||
spreadsheet_last_actor[log.resource_id] = log.actor
|
||||
|
||||
return {
|
||||
item.id: {
|
||||
"change_count": (
|
||||
spreadsheet_edit_counts.get(item.id, 0)
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
and spreadsheet_edit_counts.get(item.id, 0) > 0
|
||||
else max(version_counts.get(item.id, 0) - 1, 0)
|
||||
),
|
||||
"modified_by": (
|
||||
spreadsheet_last_actor.get(item.id)
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
and spreadsheet_last_actor.get(item.id)
|
||||
else modified_by.get(item.id)
|
||||
),
|
||||
"published_by": published_by.get(item.id),
|
||||
"published_at": published_at.get(item.id),
|
||||
}
|
||||
for item in assets
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_list_item(
|
||||
asset: AgentAsset,
|
||||
version_stats: dict[str, int | str | None] | None = None,
|
||||
) -> AgentAssetListItem:
|
||||
payload = AgentAssetListItem.model_validate(asset).model_dump()
|
||||
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
|
||||
payload["modified_by"] = str((version_stats or {}).get("modified_by") or "").strip() or None
|
||||
payload["published_by"] = (
|
||||
str((version_stats or {}).get("published_by") or "").strip() or None
|
||||
)
|
||||
payload["published_at"] = (version_stats or {}).get("published_at")
|
||||
return AgentAssetListItem.model_validate(payload)
|
||||
|
||||
@staticmethod
|
||||
def _sort_versions(
|
||||
versions: list[AgentAssetVersion], current_version: str | None
|
||||
) -> list[AgentAssetVersion]:
|
||||
return sorted(
|
||||
versions,
|
||||
key=lambda item: (item.version == current_version, item.created_at),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content(content: Any, content_type: str) -> str:
|
||||
if content_type == AgentAssetContentType.MARKDOWN.value:
|
||||
return str(content)
|
||||
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_content(version: AgentAssetVersion | None) -> Any:
|
||||
if version is None:
|
||||
return None
|
||||
if version.content_type == AgentAssetContentType.MARKDOWN.value:
|
||||
return version.content
|
||||
return json.loads(version.content)
|
||||
|
||||
@staticmethod
|
||||
def _increment_version(version: str | None) -> str:
|
||||
normalized = str(version or "").strip().removeprefix("v")
|
||||
parts = normalized.split(".")
|
||||
if len(parts) != 3 or not all(item.isdigit() for item in parts):
|
||||
return "v1.0.0"
|
||||
major, minor, patch = [int(item) for item in parts]
|
||||
return f"v{major}.{minor}.{patch + 1}"
|
||||
|
||||
@staticmethod
|
||||
def _hash_bytes(content: bytes) -> str:
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]:
|
||||
return {
|
||||
"asset_type": asset.asset_type,
|
||||
"code": asset.code,
|
||||
"name": asset.name,
|
||||
"status": asset.status,
|
||||
"current_version": asset.current_version,
|
||||
"published_version": asset.published_version,
|
||||
"working_version": asset.working_version,
|
||||
"domain": asset.domain,
|
||||
"owner": asset.owner,
|
||||
"reviewer": asset.reviewer,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_working_version(asset: AgentAsset) -> str:
|
||||
return str(asset.working_version or asset.current_version or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_published_version(asset: AgentAsset) -> str:
|
||||
return str(asset.published_version or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_version_lifecycle_state(
|
||||
version: str,
|
||||
*,
|
||||
working_version: str,
|
||||
published_version: str,
|
||||
latest_review_status: str,
|
||||
) -> str:
|
||||
if version == published_version:
|
||||
return "published"
|
||||
if version != working_version:
|
||||
return "history"
|
||||
if latest_review_status == AgentReviewStatus.PENDING.value:
|
||||
return "pending_review"
|
||||
if latest_review_status == AgentReviewStatus.APPROVED.value:
|
||||
return "approved"
|
||||
if latest_review_status == AgentReviewStatus.REJECTED.value:
|
||||
return "rejected"
|
||||
return "draft"
|
||||
|
||||
def _next_available_version(self, asset: AgentAsset) -> str:
|
||||
candidate = self._increment_version(self._resolve_working_version(asset))
|
||||
while self.repository.get_version(asset.id, candidate) is not None:
|
||||
candidate = self._increment_version(candidate)
|
||||
return candidate
|
||||
|
||||
@@ -153,19 +153,35 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
media_type=media_type,
|
||||
item=item,
|
||||
)
|
||||
source_receipt_document = self._resolve_source_receipt_document(
|
||||
source_receipt_id=source_receipt_id,
|
||||
current_user=current_user,
|
||||
fallback_filename=normalized_name,
|
||||
fallback_media_type=resolved_media_type,
|
||||
)
|
||||
ocr_document = None
|
||||
document_info = None
|
||||
requirement_check = None
|
||||
ocr_status = "empty"
|
||||
ocr_error = ""
|
||||
upload_ocr_document = None
|
||||
try:
|
||||
ocr_result = OcrService(self.db).recognize_files(
|
||||
[(normalized_name, content, media_type or "application/octet-stream")]
|
||||
)
|
||||
documents = list(ocr_result.documents or [])
|
||||
if documents:
|
||||
ocr_document = documents[0]
|
||||
upload_ocr_document = documents[0]
|
||||
except Exception as exc: # pragma: no cover - fallback path depends on OCR runtime
|
||||
ocr_error = str(exc)
|
||||
|
||||
ocr_document = self._choose_attachment_ocr_document(
|
||||
source_receipt_document=source_receipt_document,
|
||||
upload_ocr_document=upload_ocr_document,
|
||||
)
|
||||
if ocr_document is not None:
|
||||
ocr_status = "recognized"
|
||||
ocr_error = ""
|
||||
document_info = self._build_attachment_document_info(ocr_document)
|
||||
self._backfill_item_type_from_attachment(
|
||||
item=item,
|
||||
@@ -197,9 +213,8 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
document_info=document_info,
|
||||
requirement_check=requirement_check,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - fallback path depends on OCR runtime
|
||||
elif ocr_error:
|
||||
ocr_status = "failed"
|
||||
ocr_error = str(exc)
|
||||
attachment_analysis = self._build_failed_ocr_attachment_analysis(
|
||||
media_type=media_type,
|
||||
error_message=ocr_error,
|
||||
@@ -240,6 +255,7 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
if str(item).strip()
|
||||
],
|
||||
"ocr_warnings": [str(item) for item in getattr(ocr_document, "warnings", []) or []],
|
||||
"source_receipt_id": str(source_receipt_id or "").strip(),
|
||||
}
|
||||
self._attachment_storage.write_meta(file_path, meta)
|
||||
ReceiptFolderService().save_linked_attachment(
|
||||
@@ -283,6 +299,143 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
}
|
||||
|
||||
def _resolve_source_receipt_document(
|
||||
self,
|
||||
*,
|
||||
source_receipt_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
fallback_filename: str,
|
||||
fallback_media_type: str,
|
||||
) -> SimpleNamespace | None:
|
||||
normalized_receipt_id = str(source_receipt_id or "").strip()
|
||||
if not normalized_receipt_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
receipt = ReceiptFolderService().get_receipt(normalized_receipt_id, current_user)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
raw_meta = receipt.raw_meta if isinstance(receipt.raw_meta, dict) else {}
|
||||
fields = self._normalize_receipt_document_fields(
|
||||
[field.model_dump() for field in list(receipt.fields or [])]
|
||||
)
|
||||
if not fields:
|
||||
fields = self._normalize_receipt_document_fields(raw_meta.get("document_fields"))
|
||||
|
||||
document = SimpleNamespace(
|
||||
filename=str(receipt.file_name or fallback_filename or "").strip(),
|
||||
media_type=str(receipt.media_type or fallback_media_type or "application/octet-stream").strip(),
|
||||
engine=str(receipt.engine or raw_meta.get("engine") or ""),
|
||||
model=str(receipt.model or raw_meta.get("model") or ""),
|
||||
text=str(receipt.ocr_text or raw_meta.get("ocr_text") or ""),
|
||||
summary=str(receipt.summary or raw_meta.get("summary") or ""),
|
||||
avg_score=float(receipt.avg_score or raw_meta.get("ocr_avg_score") or 0.0),
|
||||
line_count=int(receipt.line_count or raw_meta.get("ocr_line_count") or 0),
|
||||
page_count=max(1, int(receipt.page_count or raw_meta.get("page_count") or 1)),
|
||||
document_type=str(receipt.document_type or raw_meta.get("document_type") or "other").strip(),
|
||||
document_type_label=str(
|
||||
receipt.document_type_label or raw_meta.get("document_type_label") or "其他单据"
|
||||
).strip(),
|
||||
scene_code=str(receipt.scene_code or raw_meta.get("scene_code") or "other").strip(),
|
||||
scene_label=str(receipt.scene_label or raw_meta.get("scene_label") or "其他票据").strip(),
|
||||
classification_source=str(raw_meta.get("ocr_classification_source") or "receipt_folder"),
|
||||
classification_confidence=float(
|
||||
receipt.classification_confidence
|
||||
or raw_meta.get("ocr_classification_confidence")
|
||||
or 0.0
|
||||
),
|
||||
classification_evidence=[
|
||||
str(value)
|
||||
for value in list(
|
||||
receipt.classification_evidence
|
||||
or raw_meta.get("ocr_classification_evidence")
|
||||
or []
|
||||
)
|
||||
if str(value).strip()
|
||||
],
|
||||
document_fields=fields,
|
||||
preview_kind=str(raw_meta.get("preview_kind") or ""),
|
||||
preview_data_url="",
|
||||
warnings=[
|
||||
str(value)
|
||||
for value in list(receipt.warnings or raw_meta.get("ocr_warnings") or [])
|
||||
if str(value).strip()
|
||||
],
|
||||
)
|
||||
return document if self._attachment_ocr_signal_score(document) > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_receipt_document_fields(raw_fields: Any) -> list[dict[str, str]]:
|
||||
fields: list[dict[str, str]] = []
|
||||
for field in list(raw_fields or []):
|
||||
if isinstance(field, dict):
|
||||
key = str(field.get("key") or "").strip()
|
||||
label = str(field.get("label") or "").strip()
|
||||
value = str(field.get("value") or "").strip()
|
||||
else:
|
||||
key = str(getattr(field, "key", "") or "").strip()
|
||||
label = str(getattr(field, "label", "") or "").strip()
|
||||
value = str(getattr(field, "value", "") or "").strip()
|
||||
if label and value:
|
||||
fields.append({"key": key, "label": label, "value": value})
|
||||
return fields
|
||||
|
||||
@classmethod
|
||||
def _choose_attachment_ocr_document(
|
||||
cls,
|
||||
*,
|
||||
source_receipt_document: Any | None,
|
||||
upload_ocr_document: Any | None,
|
||||
) -> Any | None:
|
||||
source_score = cls._attachment_ocr_signal_score(source_receipt_document)
|
||||
upload_score = cls._attachment_ocr_signal_score(upload_ocr_document)
|
||||
if source_score <= 0:
|
||||
return upload_ocr_document if upload_score > 0 else None
|
||||
if upload_score <= 0:
|
||||
return source_receipt_document
|
||||
|
||||
source_type = cls._attachment_document_type(source_receipt_document)
|
||||
upload_type = cls._attachment_document_type(upload_ocr_document)
|
||||
if source_type not in {"", "other"} and upload_type in {"", "other"}:
|
||||
return source_receipt_document
|
||||
if (
|
||||
source_type == upload_type
|
||||
and cls._attachment_document_field_count(source_receipt_document)
|
||||
> cls._attachment_document_field_count(upload_ocr_document)
|
||||
):
|
||||
return source_receipt_document
|
||||
if source_score > upload_score + 2:
|
||||
return source_receipt_document
|
||||
return upload_ocr_document
|
||||
|
||||
@classmethod
|
||||
def _attachment_ocr_signal_score(cls, document: Any | None) -> int:
|
||||
if document is None:
|
||||
return 0
|
||||
score = 0
|
||||
document_type = cls._attachment_document_type(document)
|
||||
if document_type not in {"", "other"}:
|
||||
score += 4
|
||||
score += min(3, cls._attachment_document_field_count(document))
|
||||
if str(getattr(document, "text", "") or "").strip():
|
||||
score += 2
|
||||
if str(getattr(document, "summary", "") or "").strip():
|
||||
score += 1
|
||||
if int(getattr(document, "line_count", 0) or 0) > 0:
|
||||
score += 1
|
||||
return score
|
||||
|
||||
@staticmethod
|
||||
def _attachment_document_type(document: Any | None) -> str:
|
||||
return str(getattr(document, "document_type", "") or "").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _attachment_document_field_count(document: Any | None) -> int:
|
||||
if document is None:
|
||||
return 0
|
||||
return len(list(getattr(document, "document_fields", []) or []))
|
||||
|
||||
def get_claim_item_attachment_meta(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -114,294 +114,7 @@ APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
|
||||
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {"cancelled", "canceled", "deleted"}
|
||||
|
||||
|
||||
class ExpenseClaimDraftFlowMixin:
|
||||
def upsert_draft_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
user_id: str | None,
|
||||
message: str,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
self._ensure_ready()
|
||||
context_json = dict(context_json or {})
|
||||
retry_count = self._resolve_claim_no_retry_count(context_json)
|
||||
|
||||
review_action = str(context_json.get("review_action") or "").strip()
|
||||
attachment_names = self._resolve_attachment_names(context_json)
|
||||
context_documents = self._resolve_context_documents(context_json)
|
||||
|
||||
employee = self._resolve_employee(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
draft_owner_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
association_candidate = self._find_association_candidate(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
employee=employee,
|
||||
)
|
||||
if self._should_defer_multi_document_association(
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
context_documents=context_documents,
|
||||
):
|
||||
document_count = max(len(context_documents), len(attachment_names), self._resolve_attachment_count(context_json))
|
||||
return {
|
||||
"message": (
|
||||
f"检测到你已有草稿 {association_candidate.claim_no},"
|
||||
f"当前新上传了 {document_count} 张票据,请先选择关联到现有草稿,或单独建立新的报销单。"
|
||||
),
|
||||
"draft_only": False,
|
||||
"status": "pending_association_decision",
|
||||
"pending_association_decision": True,
|
||||
"association_candidate_claim_id": association_candidate.id,
|
||||
"association_candidate_claim_no": association_candidate.claim_no,
|
||||
}
|
||||
|
||||
claim = self._find_target_claim(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
)
|
||||
is_new_claim = claim is None
|
||||
before_json = self._serialize_claim(claim) if claim is not None else None
|
||||
application_link_block_result = self._build_application_link_block_result(
|
||||
context_json=context_json,
|
||||
target_claim=claim,
|
||||
)
|
||||
if application_link_block_result is not None:
|
||||
return application_link_block_result
|
||||
if is_new_claim:
|
||||
existing_draft_count = self._count_draft_claims_for_owner(
|
||||
employee=employee,
|
||||
user_id=user_id,
|
||||
)
|
||||
if existing_draft_count >= MAX_DRAFT_CLAIMS_PER_USER:
|
||||
return {
|
||||
"message": (
|
||||
f"你当前已保存 {MAX_DRAFT_CLAIMS_PER_USER} 个草稿,请先完成已保存的草稿,"
|
||||
"才能再次新建草稿。"
|
||||
),
|
||||
"draft_limit_reached": True,
|
||||
"draft_only": False,
|
||||
"status": "blocked",
|
||||
"draft_count": existing_draft_count,
|
||||
"max_draft_count": MAX_DRAFT_CLAIMS_PER_USER,
|
||||
}
|
||||
|
||||
amount = self._resolve_amount(ontology.entities, context_json=context_json)
|
||||
occurred_at = self._resolve_occurred_at(ontology, context_json=context_json)
|
||||
explicit_expense_type = self._resolve_explicit_review_expense_type(context_json)
|
||||
inferred_expense_type = self._resolve_expense_type(ontology.entities, context_json=context_json)
|
||||
locked_expense_type = explicit_expense_type
|
||||
if not locked_expense_type and claim is not None and review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS:
|
||||
locked_expense_type = str(claim.expense_type or "").strip()
|
||||
expense_type = locked_expense_type or inferred_expense_type
|
||||
location = self._resolve_location(message=message, context_json=context_json)
|
||||
reason = self._resolve_reason(
|
||||
message=message,
|
||||
context_json=context_json,
|
||||
allow_message_fallback=is_new_claim,
|
||||
)
|
||||
attachment_count = len(attachment_names) or self._resolve_attachment_count(context_json)
|
||||
|
||||
final_amount = amount if amount is not None else (claim.amount if claim is not None else Decimal("0.00"))
|
||||
final_occurred_at = (
|
||||
occurred_at if occurred_at is not None else (claim.occurred_at if claim is not None else datetime.now(UTC))
|
||||
)
|
||||
final_expense_type = expense_type or (claim.expense_type if claim is not None else "other")
|
||||
final_location = location or (claim.location if claim is not None else "待补充")
|
||||
final_reason = reason or (claim.reason if claim is not None else "待补充")
|
||||
final_attachment_count = (
|
||||
attachment_count if attachment_count > 0 else int(claim.invoice_count or 0) if claim is not None else 0
|
||||
)
|
||||
final_risk_flags = self._merge_persistent_claim_risk_flags(
|
||||
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
||||
next_flags=list(ontology.risk_flags),
|
||||
)
|
||||
final_risk_flags = self._merge_application_link_flag(
|
||||
final_risk_flags,
|
||||
context_json=context_json,
|
||||
)
|
||||
if context_documents or attachment_names:
|
||||
document_specs = self._build_context_item_specs(
|
||||
context_documents=context_documents,
|
||||
attachment_names=attachment_names,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
context_json=context_json,
|
||||
employee_grade=str(employee.grade or "").strip() if employee is not None else "",
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
document_specs = []
|
||||
|
||||
if claim is not None and review_action == "link_to_existing_draft" and document_specs:
|
||||
duplicate_result = self._build_duplicate_attachment_block_result(
|
||||
claim=claim,
|
||||
document_specs=document_specs,
|
||||
context_documents=context_documents,
|
||||
)
|
||||
if duplicate_result is not None:
|
||||
return duplicate_result
|
||||
|
||||
try:
|
||||
if claim is None:
|
||||
claim = ExpenseClaim(
|
||||
claim_no=self._generate_claim_no(final_occurred_at),
|
||||
employee_id=employee.id if employee is not None else None,
|
||||
employee_name=draft_owner_name,
|
||||
department_id=employee.organization_unit_id if employee is not None else None,
|
||||
department_name=self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
),
|
||||
project_code=self._resolve_project_code(ontology.entities),
|
||||
expense_type=final_expense_type,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
amount=final_amount,
|
||||
currency="CNY",
|
||||
invoice_count=final_attachment_count,
|
||||
occurred_at=final_occurred_at,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=final_risk_flags,
|
||||
)
|
||||
self.db.add(claim)
|
||||
else:
|
||||
claim.employee_id = employee.id if employee is not None else claim.employee_id
|
||||
claim.employee_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
fallback=claim.employee_name,
|
||||
)
|
||||
)
|
||||
claim.department_id = employee.organization_unit_id if employee is not None else claim.department_id
|
||||
claim.department_name = self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
fallback=claim.department_name,
|
||||
)
|
||||
claim.project_code = self._resolve_project_code(ontology.entities) or claim.project_code
|
||||
claim.expense_type = final_expense_type
|
||||
claim.reason = final_reason
|
||||
claim.location = final_location
|
||||
claim.amount = final_amount
|
||||
claim.invoice_count = final_attachment_count
|
||||
claim.occurred_at = final_occurred_at
|
||||
claim.status = "draft"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.risk_flags_json = final_risk_flags
|
||||
|
||||
self.db.flush()
|
||||
skip_primary_item = self._should_skip_application_link_placeholder_item(
|
||||
claim=claim,
|
||||
context_json=context_json,
|
||||
document_specs=document_specs,
|
||||
attachment_count=attachment_count,
|
||||
amount=amount,
|
||||
)
|
||||
if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
|
||||
if review_action == "link_to_existing_draft" and claim.items:
|
||||
self._append_document_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
else:
|
||||
self._replace_claim_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
elif skip_primary_item:
|
||||
self._clear_application_link_placeholder_items(claim, context_json=context_json)
|
||||
if claim.items:
|
||||
self._sync_claim_from_items(claim)
|
||||
else:
|
||||
self._sync_application_link_draft_without_items(claim)
|
||||
else:
|
||||
self._upsert_primary_item(
|
||||
claim=claim,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
attachment_names=attachment_names,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
if locked_expense_type:
|
||||
claim.expense_type = locked_expense_type
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
except IntegrityError as exc:
|
||||
self.db.rollback()
|
||||
if (
|
||||
is_new_claim
|
||||
and retry_count < MAX_CLAIM_NO_RETRY_ATTEMPTS
|
||||
and self._is_claim_no_conflict_error(exc)
|
||||
):
|
||||
retry_context = dict(context_json)
|
||||
retry_context["_claim_no_retry_count"] = retry_count + 1
|
||||
return self.upsert_draft_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=retry_context,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=user_id or claim.employee_name or "anonymous",
|
||||
action="expense_claim.draft_upsert",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=run_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": (
|
||||
f"已{'创建' if is_new_claim else '更新'}报销草稿 {claim.claim_no},当前状态为 draft。"
|
||||
"请核对识别结果,确认无误后继续提交。"
|
||||
),
|
||||
"draft_only": True,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"status": claim.status,
|
||||
"amount": float(claim.amount),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
class ExpenseClaimApplicationLinkMixin:
|
||||
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
@@ -826,6 +539,8 @@ class ExpenseClaimDraftFlowMixin:
|
||||
def _normalize_context_object(value: Any) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class ExpenseClaimDraftAttachmentAssociationMixin:
|
||||
def _find_target_claim(
|
||||
self,
|
||||
*,
|
||||
@@ -1062,3 +777,293 @@ class ExpenseClaimDraftFlowMixin:
|
||||
"amount": float(claim.amount or Decimal("0.00")),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
|
||||
class ExpenseClaimDraftFlowMixin(ExpenseClaimApplicationLinkMixin, ExpenseClaimDraftAttachmentAssociationMixin):
|
||||
def upsert_draft_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
user_id: str | None,
|
||||
message: str,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
self._ensure_ready()
|
||||
context_json = dict(context_json or {})
|
||||
retry_count = self._resolve_claim_no_retry_count(context_json)
|
||||
|
||||
review_action = str(context_json.get("review_action") or "").strip()
|
||||
attachment_names = self._resolve_attachment_names(context_json)
|
||||
context_documents = self._resolve_context_documents(context_json)
|
||||
|
||||
employee = self._resolve_employee(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
draft_owner_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
association_candidate = self._find_association_candidate(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
employee=employee,
|
||||
)
|
||||
if self._should_defer_multi_document_association(
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
context_documents=context_documents,
|
||||
):
|
||||
document_count = max(len(context_documents), len(attachment_names), self._resolve_attachment_count(context_json))
|
||||
return {
|
||||
"message": (
|
||||
f"检测到你已有草稿 {association_candidate.claim_no},"
|
||||
f"当前新上传了 {document_count} 张票据,请先选择关联到现有草稿,或单独建立新的报销单。"
|
||||
),
|
||||
"draft_only": False,
|
||||
"status": "pending_association_decision",
|
||||
"pending_association_decision": True,
|
||||
"association_candidate_claim_id": association_candidate.id,
|
||||
"association_candidate_claim_no": association_candidate.claim_no,
|
||||
}
|
||||
|
||||
claim = self._find_target_claim(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
)
|
||||
is_new_claim = claim is None
|
||||
before_json = self._serialize_claim(claim) if claim is not None else None
|
||||
application_link_block_result = self._build_application_link_block_result(
|
||||
context_json=context_json,
|
||||
target_claim=claim,
|
||||
)
|
||||
if application_link_block_result is not None:
|
||||
return application_link_block_result
|
||||
if is_new_claim:
|
||||
existing_draft_count = self._count_draft_claims_for_owner(
|
||||
employee=employee,
|
||||
user_id=user_id,
|
||||
)
|
||||
if existing_draft_count >= MAX_DRAFT_CLAIMS_PER_USER:
|
||||
return {
|
||||
"message": (
|
||||
f"你当前已保存 {MAX_DRAFT_CLAIMS_PER_USER} 个草稿,请先完成已保存的草稿,"
|
||||
"才能再次新建草稿。"
|
||||
),
|
||||
"draft_limit_reached": True,
|
||||
"draft_only": False,
|
||||
"status": "blocked",
|
||||
"draft_count": existing_draft_count,
|
||||
"max_draft_count": MAX_DRAFT_CLAIMS_PER_USER,
|
||||
}
|
||||
|
||||
amount = self._resolve_amount(ontology.entities, context_json=context_json)
|
||||
occurred_at = self._resolve_occurred_at(ontology, context_json=context_json)
|
||||
explicit_expense_type = self._resolve_explicit_review_expense_type(context_json)
|
||||
inferred_expense_type = self._resolve_expense_type(ontology.entities, context_json=context_json)
|
||||
locked_expense_type = explicit_expense_type
|
||||
if not locked_expense_type and claim is not None and review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS:
|
||||
locked_expense_type = str(claim.expense_type or "").strip()
|
||||
expense_type = locked_expense_type or inferred_expense_type
|
||||
location = self._resolve_location(message=message, context_json=context_json)
|
||||
reason = self._resolve_reason(
|
||||
message=message,
|
||||
context_json=context_json,
|
||||
allow_message_fallback=is_new_claim,
|
||||
)
|
||||
attachment_count = len(attachment_names) or self._resolve_attachment_count(context_json)
|
||||
|
||||
final_amount = amount if amount is not None else (claim.amount if claim is not None else Decimal("0.00"))
|
||||
final_occurred_at = (
|
||||
occurred_at if occurred_at is not None else (claim.occurred_at if claim is not None else datetime.now(UTC))
|
||||
)
|
||||
final_expense_type = expense_type or (claim.expense_type if claim is not None else "other")
|
||||
final_location = location or (claim.location if claim is not None else "待补充")
|
||||
final_reason = reason or (claim.reason if claim is not None else "待补充")
|
||||
final_attachment_count = (
|
||||
attachment_count if attachment_count > 0 else int(claim.invoice_count or 0) if claim is not None else 0
|
||||
)
|
||||
final_risk_flags = self._merge_persistent_claim_risk_flags(
|
||||
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
||||
next_flags=list(ontology.risk_flags),
|
||||
)
|
||||
final_risk_flags = self._merge_application_link_flag(
|
||||
final_risk_flags,
|
||||
context_json=context_json,
|
||||
)
|
||||
if context_documents or attachment_names:
|
||||
document_specs = self._build_context_item_specs(
|
||||
context_documents=context_documents,
|
||||
attachment_names=attachment_names,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
context_json=context_json,
|
||||
employee_grade=str(employee.grade or "").strip() if employee is not None else "",
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
document_specs = []
|
||||
|
||||
if claim is not None and review_action == "link_to_existing_draft" and document_specs:
|
||||
duplicate_result = self._build_duplicate_attachment_block_result(
|
||||
claim=claim,
|
||||
document_specs=document_specs,
|
||||
context_documents=context_documents,
|
||||
)
|
||||
if duplicate_result is not None:
|
||||
return duplicate_result
|
||||
|
||||
try:
|
||||
if claim is None:
|
||||
claim = ExpenseClaim(
|
||||
claim_no=self._generate_claim_no(final_occurred_at),
|
||||
employee_id=employee.id if employee is not None else None,
|
||||
employee_name=draft_owner_name,
|
||||
department_id=employee.organization_unit_id if employee is not None else None,
|
||||
department_name=self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
),
|
||||
project_code=self._resolve_project_code(ontology.entities),
|
||||
expense_type=final_expense_type,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
amount=final_amount,
|
||||
currency="CNY",
|
||||
invoice_count=final_attachment_count,
|
||||
occurred_at=final_occurred_at,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=final_risk_flags,
|
||||
)
|
||||
self.db.add(claim)
|
||||
else:
|
||||
claim.employee_id = employee.id if employee is not None else claim.employee_id
|
||||
claim.employee_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
fallback=claim.employee_name,
|
||||
)
|
||||
)
|
||||
claim.department_id = employee.organization_unit_id if employee is not None else claim.department_id
|
||||
claim.department_name = self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
fallback=claim.department_name,
|
||||
)
|
||||
claim.project_code = self._resolve_project_code(ontology.entities) or claim.project_code
|
||||
claim.expense_type = final_expense_type
|
||||
claim.reason = final_reason
|
||||
claim.location = final_location
|
||||
claim.amount = final_amount
|
||||
claim.invoice_count = final_attachment_count
|
||||
claim.occurred_at = final_occurred_at
|
||||
claim.status = "draft"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.risk_flags_json = final_risk_flags
|
||||
|
||||
self.db.flush()
|
||||
skip_primary_item = self._should_skip_application_link_placeholder_item(
|
||||
claim=claim,
|
||||
context_json=context_json,
|
||||
document_specs=document_specs,
|
||||
attachment_count=attachment_count,
|
||||
amount=amount,
|
||||
)
|
||||
if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
|
||||
if review_action == "link_to_existing_draft" and claim.items:
|
||||
self._append_document_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
else:
|
||||
self._replace_claim_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
elif skip_primary_item:
|
||||
self._clear_application_link_placeholder_items(claim, context_json=context_json)
|
||||
if claim.items:
|
||||
self._sync_claim_from_items(claim)
|
||||
else:
|
||||
self._sync_application_link_draft_without_items(claim)
|
||||
else:
|
||||
self._upsert_primary_item(
|
||||
claim=claim,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
attachment_names=attachment_names,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
if locked_expense_type:
|
||||
claim.expense_type = locked_expense_type
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
except IntegrityError as exc:
|
||||
self.db.rollback()
|
||||
if (
|
||||
is_new_claim
|
||||
and retry_count < MAX_CLAIM_NO_RETRY_ATTEMPTS
|
||||
and self._is_claim_no_conflict_error(exc)
|
||||
):
|
||||
retry_context = dict(context_json)
|
||||
retry_context["_claim_no_retry_count"] = retry_count + 1
|
||||
return self.upsert_draft_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=retry_context,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=user_id or claim.employee_name or "anonymous",
|
||||
action="expense_claim.draft_upsert",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=run_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": (
|
||||
f"已{'创建' if is_new_claim else '更新'}报销草稿 {claim.claim_no},当前状态为 draft。"
|
||||
"请核对识别结果,确认无误后继续提交。"
|
||||
),
|
||||
"draft_only": True,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"status": claim.status,
|
||||
"amount": float(claim.amount),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -140,183 +140,7 @@ from app.services.ocr import OcrService
|
||||
|
||||
|
||||
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimPaginationMixin,
|
||||
ExpenseClaimApprovalFlowMixin,
|
||||
ExpenseClaimApprovalRoutingMixin,
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimPreReviewMixin,
|
||||
ExpenseClaimBudgetFlowMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
ExpenseClaimReviewPreviewMixin,
|
||||
ExpenseClaimDraftFlowMixin,
|
||||
ExpenseClaimDraftPersistenceMixin,
|
||||
ExpenseClaimDocumentItemBuilderMixin,
|
||||
ExpenseClaimDocumentParsingMixin,
|
||||
ExpenseClaimOntologyResolverMixin,
|
||||
ExpenseClaimAttachmentDocumentMixin,
|
||||
ExpenseClaimAttachmentAnalysisMixin,
|
||||
ExpenseClaimReadModelMixin,
|
||||
ExpenseClaimRiskReviewMixin,
|
||||
ExpenseClaimWorkflowRepairMixin,
|
||||
):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.audit_service = AuditLogService(db)
|
||||
self._access_policy = ExpenseClaimAccessPolicy(db)
|
||||
self._attachment_storage = ExpenseClaimAttachmentStorage()
|
||||
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)
|
||||
|
||||
@staticmethod
|
||||
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||
document_type = str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
return (
|
||||
is_application_claim_no(claim_no)
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
or document_type in {"application", "expense_application"}
|
||||
)
|
||||
|
||||
def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
||||
issues: list[str] = []
|
||||
if self._is_missing_value(claim.employee_name):
|
||||
issues.append("申请人未完善")
|
||||
if self._is_missing_value(claim.department_name):
|
||||
issues.append("所属部门未完善")
|
||||
if self._is_missing_value(claim.expense_type):
|
||||
issues.append("申请类型未完善")
|
||||
if self._is_missing_value(claim.reason):
|
||||
issues.append("申请事由未完善")
|
||||
if self._is_missing_value(claim.location):
|
||||
issues.append("业务地点未完善")
|
||||
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
||||
issues.append("预计总费用未完善")
|
||||
if claim.occurred_at is None:
|
||||
issues.append("申请时间未完善")
|
||||
return issues
|
||||
|
||||
def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
claim = self.db.scalar(stmt)
|
||||
if claim is not None:
|
||||
self._repair_duplicate_budget_approval_stages([claim])
|
||||
return self._access_policy.attach_approval_snapshot(claim)
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
if claim is None:
|
||||
return self._access_policy.is_budget_manager_user(current_user)
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self._access_policy.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
if (
|
||||
self._access_policy.has_privileged_claim_access(current_user)
|
||||
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
|
||||
):
|
||||
return True
|
||||
if self._access_policy.can_approve_claim(current_user, claim):
|
||||
return True
|
||||
if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
return False
|
||||
return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
|
||||
def update_claim(
|
||||
self,
|
||||
*,
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimUpdate,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
self._ensure_draft_pending_claim(claim)
|
||||
before_json = self._serialize_claim(claim)
|
||||
|
||||
if payload.reason is not None:
|
||||
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
|
||||
if not self._is_expense_application_claim(claim):
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.update",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
class ExpenseClaimStandardAdjustmentMixin:
|
||||
@staticmethod
|
||||
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
|
||||
try:
|
||||
@@ -579,6 +403,8 @@ class ExpenseClaimService(
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
class ExpenseClaimItemActionMixin:
|
||||
def update_claim_item(
|
||||
self,
|
||||
*,
|
||||
@@ -736,11 +562,6 @@ class ExpenseClaimService(
|
||||
"item_id": item.id,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def submit_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
@@ -840,11 +661,6 @@ class ExpenseClaimService(
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None and current_user.is_admin:
|
||||
@@ -1035,4 +851,161 @@ class ExpenseClaimService(
|
||||
return claim
|
||||
|
||||
|
||||
class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.audit_service = AuditLogService(db)
|
||||
self._access_policy = ExpenseClaimAccessPolicy(db)
|
||||
self._attachment_storage = ExpenseClaimAttachmentStorage()
|
||||
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)
|
||||
|
||||
@staticmethod
|
||||
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||
document_type = str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
return (
|
||||
is_application_claim_no(claim_no)
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
or document_type in {"application", "expense_application"}
|
||||
)
|
||||
|
||||
def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
||||
issues: list[str] = []
|
||||
if self._is_missing_value(claim.employee_name):
|
||||
issues.append("申请人未完善")
|
||||
if self._is_missing_value(claim.department_name):
|
||||
issues.append("所属部门未完善")
|
||||
if self._is_missing_value(claim.expense_type):
|
||||
issues.append("申请类型未完善")
|
||||
if self._is_missing_value(claim.reason):
|
||||
issues.append("申请事由未完善")
|
||||
if self._is_missing_value(claim.location):
|
||||
issues.append("业务地点未完善")
|
||||
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
||||
issues.append("预计总费用未完善")
|
||||
if claim.occurred_at is None:
|
||||
issues.append("申请时间未完善")
|
||||
return issues
|
||||
|
||||
def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
claim = self.db.scalar(stmt)
|
||||
if claim is not None:
|
||||
self._repair_duplicate_budget_approval_stages([claim])
|
||||
return self._access_policy.attach_approval_snapshot(claim)
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
if claim is None:
|
||||
return self._access_policy.is_budget_manager_user(current_user)
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self._access_policy.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
if (
|
||||
self._access_policy.has_privileged_claim_access(current_user)
|
||||
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
|
||||
):
|
||||
return True
|
||||
if self._access_policy.can_approve_claim(current_user, claim):
|
||||
return True
|
||||
if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
return False
|
||||
return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
|
||||
def update_claim(
|
||||
self,
|
||||
*,
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimUpdate,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
self._ensure_draft_pending_claim(claim)
|
||||
before_json = self._serialize_claim(claim)
|
||||
|
||||
if payload.reason is not None:
|
||||
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
|
||||
if not self._is_expense_application_claim(claim):
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.update",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
@@ -28,71 +28,7 @@ from app.services.finance_dashboard_constants import (
|
||||
)
|
||||
|
||||
|
||||
class FinanceDashboardService(BudgetSupportMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def build_dashboard(
|
||||
self,
|
||||
*,
|
||||
range_key: str = "近10日",
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
trend_range: str = "近12天",
|
||||
department_range: str = "本月",
|
||||
) -> FinanceDashboardRead:
|
||||
now = datetime.now(UTC)
|
||||
start, end, resolved_key = self._resolve_scope(
|
||||
range_key=range_key,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
now=now,
|
||||
)
|
||||
previous_start = start - (end - start)
|
||||
trend_start, trend_end, trend_labels = self._resolve_trend_scope(
|
||||
trend_range,
|
||||
now,
|
||||
fallback_start=start,
|
||||
fallback_end=end,
|
||||
)
|
||||
ranking_start, ranking_end = self._resolve_ranking_scope(
|
||||
department_range,
|
||||
now,
|
||||
fallback_start=start,
|
||||
fallback_end=end,
|
||||
)
|
||||
|
||||
claims = [
|
||||
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
||||
]
|
||||
scope_claims = self._claims_between(claims, start, end)
|
||||
previous_claims = self._claims_between(claims, previous_start, start)
|
||||
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
||||
ranking_claims = self._claims_between(claims, ranking_start, ranking_end)
|
||||
|
||||
totals = self._totals(scope_claims)
|
||||
previous_totals = self._totals(previous_claims)
|
||||
|
||||
return FinanceDashboardRead(
|
||||
range_key=resolved_key,
|
||||
start_date=start.date().isoformat(),
|
||||
end_date=(end - timedelta(days=1)).date().isoformat(),
|
||||
generated_at=now.isoformat(),
|
||||
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
|
||||
totals=totals,
|
||||
metric_meta=self._metric_meta(totals, previous_totals),
|
||||
trend=self._trend(trend_labels, trend_claims, now),
|
||||
spend_by_category=self._spend_by_category(scope_claims),
|
||||
exception_mix=self._payment_status_mix(scope_claims),
|
||||
department_ranking=self._department_ranking(ranking_claims),
|
||||
department_employee_mix=self._department_employee_mix(ranking_claims),
|
||||
employee_ranking=self._employee_ranking(ranking_claims),
|
||||
top_claims=self._top_claims(ranking_claims),
|
||||
bottlenecks=self._bottlenecks(scope_claims),
|
||||
budget_summary=self._budget_summary(now.year),
|
||||
budget_metrics=self._budget_metrics(now.year),
|
||||
)
|
||||
|
||||
class FinanceDashboardMetricMixin:
|
||||
def _fetch_claims(self) -> list[ExpenseClaim]:
|
||||
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
||||
return list(self.db.scalars(stmt).all())
|
||||
@@ -456,6 +392,8 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class FinanceDashboardBudgetAndLabelMixin:
|
||||
def _top_claims(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
spend_claims = [
|
||||
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||
@@ -882,3 +820,70 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
prefix = "-¥" if value < Decimal("0") else "¥"
|
||||
amount = abs(value)
|
||||
return f"{prefix}{amount:,.0f}"
|
||||
|
||||
|
||||
class FinanceDashboardService(FinanceDashboardMetricMixin, FinanceDashboardBudgetAndLabelMixin, BudgetSupportMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def build_dashboard(
|
||||
self,
|
||||
*,
|
||||
range_key: str = "近10日",
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
trend_range: str = "近12天",
|
||||
department_range: str = "本月",
|
||||
) -> FinanceDashboardRead:
|
||||
now = datetime.now(UTC)
|
||||
start, end, resolved_key = self._resolve_scope(
|
||||
range_key=range_key,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
now=now,
|
||||
)
|
||||
previous_start = start - (end - start)
|
||||
trend_start, trend_end, trend_labels = self._resolve_trend_scope(
|
||||
trend_range,
|
||||
now,
|
||||
fallback_start=start,
|
||||
fallback_end=end,
|
||||
)
|
||||
ranking_start, ranking_end = self._resolve_ranking_scope(
|
||||
department_range,
|
||||
now,
|
||||
fallback_start=start,
|
||||
fallback_end=end,
|
||||
)
|
||||
|
||||
claims = [
|
||||
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
||||
]
|
||||
scope_claims = self._claims_between(claims, start, end)
|
||||
previous_claims = self._claims_between(claims, previous_start, start)
|
||||
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
||||
ranking_claims = self._claims_between(claims, ranking_start, ranking_end)
|
||||
|
||||
totals = self._totals(scope_claims)
|
||||
previous_totals = self._totals(previous_claims)
|
||||
|
||||
return FinanceDashboardRead(
|
||||
range_key=resolved_key,
|
||||
start_date=start.date().isoformat(),
|
||||
end_date=(end - timedelta(days=1)).date().isoformat(),
|
||||
generated_at=now.isoformat(),
|
||||
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
|
||||
totals=totals,
|
||||
metric_meta=self._metric_meta(totals, previous_totals),
|
||||
trend=self._trend(trend_labels, trend_claims, now),
|
||||
spend_by_category=self._spend_by_category(scope_claims),
|
||||
exception_mix=self._payment_status_mix(scope_claims),
|
||||
department_ranking=self._department_ranking(ranking_claims),
|
||||
department_employee_mix=self._department_employee_mix(ranking_claims),
|
||||
employee_ranking=self._employee_ranking(ranking_claims),
|
||||
top_claims=self._top_claims(ranking_claims),
|
||||
bottlenecks=self._bottlenecks(scope_claims),
|
||||
budget_summary=self._budget_summary(now.year),
|
||||
budget_metrics=self._budget_metrics(now.year),
|
||||
)
|
||||
|
||||
|
||||
@@ -30,265 +30,7 @@ class ExecutionOutcome:
|
||||
failed_tool_count: int
|
||||
|
||||
|
||||
class OrchestratorExecutionEngine:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
db: Session,
|
||||
run_service,
|
||||
expense_claim_service,
|
||||
knowledge_service,
|
||||
user_agent_service,
|
||||
database_query_builder,
|
||||
trace_service=None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.run_service = run_service
|
||||
self.expense_claim_service = expense_claim_service
|
||||
self.knowledge_service = knowledge_service
|
||||
self.user_agent_service = user_agent_service
|
||||
self.database_query_builder = database_query_builder
|
||||
self.trace_service = trace_service
|
||||
|
||||
def _execute_user_agent(
|
||||
self,
|
||||
*,
|
||||
payload: OrchestratorRequest,
|
||||
run_id: str,
|
||||
ontology: OntologyParseResult,
|
||||
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
||||
requires_confirmation: bool,
|
||||
context_json: dict[str, Any],
|
||||
) -> ExecutionOutcome:
|
||||
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
||||
if requires_confirmation:
|
||||
response, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="user_agent.confirmation_placeholder",
|
||||
request_json={
|
||||
"message": payload.message,
|
||||
"permission_level": ontology.permission.level,
|
||||
},
|
||||
context_json=context_json,
|
||||
executor=lambda: {
|
||||
"confirmation_title": "操作需要确认",
|
||||
"message": f"{ontology.permission.reason} 当前仅返回确认摘要,不直接执行动作。",
|
||||
},
|
||||
fallback_factory=lambda exc: {
|
||||
"confirmation_title": "操作需要确认",
|
||||
"message": f"确认摘要生成失败,已阻断自动执行:{exc}",
|
||||
},
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.BLOCKED.value,
|
||||
result={**response, "degraded": degraded},
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
next_step = self._resolve_next_step(
|
||||
ontology,
|
||||
payload.source,
|
||||
context_json=context_json,
|
||||
)
|
||||
if next_step == "query_database":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name=self._database_tool_name(ontology.scenario),
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self.database_query_builder.build_database_answer(
|
||||
ontology,
|
||||
user_id=payload.user_id,
|
||||
context_json=context_json,
|
||||
message=payload.message or "",
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"数据库查询暂时不可用,已返回降级说明:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
if next_step == "search_knowledge":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name="knowledge.search",
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_knowledge_answer(
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
capabilities=capabilities,
|
||||
context_json=context_json,
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"知识检索暂时不可用,建议稍后重试:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
if next_step == "run_rule":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.RULE_ENGINE.value,
|
||||
tool_name=self._rule_tool_name(capabilities),
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_rule_answer(ontology),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"规则检查暂时不可用,已返回人工复核建议:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
tool_type = AgentToolType.LLM.value
|
||||
tool_name = "user_agent.draft_placeholder"
|
||||
executor = lambda: {
|
||||
"message": (
|
||||
f"已生成 {ontology.scenario} 场景草稿,"
|
||||
"占位能力后续由 Day 5 User Agent 接管。"
|
||||
),
|
||||
"draft_only": True,
|
||||
}
|
||||
fallback_factory = lambda exc: {
|
||||
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||
is_persistence_action = self._is_expense_persistence_action(context_json)
|
||||
tool_type = (
|
||||
AgentToolType.DATABASE.value
|
||||
if is_persistence_action
|
||||
else AgentToolType.LLM.value
|
||||
)
|
||||
tool_name = (
|
||||
"database.expense_claims.save_or_submit"
|
||||
if is_persistence_action
|
||||
else "user_agent.expense_review_preview"
|
||||
)
|
||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
fallback_factory = lambda exc: {
|
||||
"message": (
|
||||
f"报销草稿落库失败,请稍后再试:{exc}"
|
||||
if is_persistence_action
|
||||
else f"报销内容预览生成失败,请稍后再试:{exc}"
|
||||
),
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=executor,
|
||||
fallback_factory=fallback_factory,
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
class OrchestratorExecutionTaskMixin:
|
||||
def _execute_hermes(
|
||||
self,
|
||||
*,
|
||||
@@ -600,6 +342,8 @@ class OrchestratorExecutionEngine:
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
|
||||
class OrchestratorExecutionHelperMixin:
|
||||
@staticmethod
|
||||
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
|
||||
if task_asset is None:
|
||||
@@ -898,3 +642,263 @@ class OrchestratorExecutionEngine:
|
||||
"permission": ontology.permission.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
class OrchestratorExecutionEngine(OrchestratorExecutionTaskMixin, OrchestratorExecutionHelperMixin):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
db: Session,
|
||||
run_service,
|
||||
expense_claim_service,
|
||||
knowledge_service,
|
||||
user_agent_service,
|
||||
database_query_builder,
|
||||
trace_service=None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.run_service = run_service
|
||||
self.expense_claim_service = expense_claim_service
|
||||
self.knowledge_service = knowledge_service
|
||||
self.user_agent_service = user_agent_service
|
||||
self.database_query_builder = database_query_builder
|
||||
self.trace_service = trace_service
|
||||
|
||||
def _execute_user_agent(
|
||||
self,
|
||||
*,
|
||||
payload: OrchestratorRequest,
|
||||
run_id: str,
|
||||
ontology: OntologyParseResult,
|
||||
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
||||
requires_confirmation: bool,
|
||||
context_json: dict[str, Any],
|
||||
) -> ExecutionOutcome:
|
||||
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
||||
if requires_confirmation:
|
||||
response, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="user_agent.confirmation_placeholder",
|
||||
request_json={
|
||||
"message": payload.message,
|
||||
"permission_level": ontology.permission.level,
|
||||
},
|
||||
context_json=context_json,
|
||||
executor=lambda: {
|
||||
"confirmation_title": "操作需要确认",
|
||||
"message": f"{ontology.permission.reason} 当前仅返回确认摘要,不直接执行动作。",
|
||||
},
|
||||
fallback_factory=lambda exc: {
|
||||
"confirmation_title": "操作需要确认",
|
||||
"message": f"确认摘要生成失败,已阻断自动执行:{exc}",
|
||||
},
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.BLOCKED.value,
|
||||
result={**response, "degraded": degraded},
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
next_step = self._resolve_next_step(
|
||||
ontology,
|
||||
payload.source,
|
||||
context_json=context_json,
|
||||
)
|
||||
if next_step == "query_database":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name=self._database_tool_name(ontology.scenario),
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self.database_query_builder.build_database_answer(
|
||||
ontology,
|
||||
user_id=payload.user_id,
|
||||
context_json=context_json,
|
||||
message=payload.message or "",
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"数据库查询暂时不可用,已返回降级说明:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
if next_step == "search_knowledge":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name="knowledge.search",
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_knowledge_answer(
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
capabilities=capabilities,
|
||||
context_json=context_json,
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"知识检索暂时不可用,建议稍后重试:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
if next_step == "run_rule":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.RULE_ENGINE.value,
|
||||
tool_name=self._rule_tool_name(capabilities),
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_rule_answer(ontology),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"规则检查暂时不可用,已返回人工复核建议:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
tool_type = AgentToolType.LLM.value
|
||||
tool_name = "user_agent.draft_placeholder"
|
||||
executor = lambda: {
|
||||
"message": (
|
||||
f"已生成 {ontology.scenario} 场景草稿,"
|
||||
"占位能力后续由 Day 5 User Agent 接管。"
|
||||
),
|
||||
"draft_only": True,
|
||||
}
|
||||
fallback_factory = lambda exc: {
|
||||
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||
is_persistence_action = self._is_expense_persistence_action(context_json)
|
||||
tool_type = (
|
||||
AgentToolType.DATABASE.value
|
||||
if is_persistence_action
|
||||
else AgentToolType.LLM.value
|
||||
)
|
||||
tool_name = (
|
||||
"database.expense_claims.save_or_submit"
|
||||
if is_persistence_action
|
||||
else "user_agent.expense_review_preview"
|
||||
)
|
||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
fallback_factory = lambda exc: {
|
||||
"message": (
|
||||
f"报销草稿落库失败,请稍后再试:{exc}"
|
||||
if is_persistence_action
|
||||
else f"报销内容预览生成失败,请稍后再试:{exc}"
|
||||
),
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=executor,
|
||||
fallback_factory=fallback_factory,
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,47 +17,7 @@ CITY_CONSISTENCY_SEMANTIC_TYPES = {
|
||||
ROUTE_CITY_SPLIT_PATTERN = re.compile(r"\s*(?:至|到|→|->|-|-|—|~|~|/|、|,|,|;|;)\s*")
|
||||
|
||||
|
||||
class RiskRuleTemplateExecutor:
|
||||
def evaluate_with_trace(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
result = self.evaluate(manifest, claim=claim, contexts=contexts)
|
||||
return {
|
||||
"hit": result is not None,
|
||||
"result": result,
|
||||
"trace": build_risk_rule_execution_trace(manifest, result=result),
|
||||
}
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()
|
||||
|
||||
if template_key == "field_required_v1":
|
||||
return self._evaluate_required_fields(params, claim=claim, contexts=contexts)
|
||||
if template_key == "field_compare_v1":
|
||||
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return self._evaluate_city_consistency_rule(
|
||||
params,
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts)
|
||||
if template_key == "keyword_match_v1":
|
||||
return self._evaluate_keyword_match(params, claim=claim, contexts=contexts)
|
||||
if template_key == COMPOSITE_RULE_TEMPLATE_KEY:
|
||||
return self._evaluate_composite_rule(params, claim=claim, contexts=contexts)
|
||||
return None
|
||||
|
||||
class RiskRuleTemplateConditionMixin:
|
||||
def _evaluate_required_fields(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
@@ -488,6 +448,8 @@ class RiskRuleTemplateExecutor:
|
||||
"right_values": right_numbers[:8],
|
||||
}
|
||||
|
||||
|
||||
class RiskRuleTemplateValueResolverMixin:
|
||||
def _resolve_group_values(
|
||||
self,
|
||||
field_keys: list[str],
|
||||
@@ -1162,3 +1124,46 @@ class RiskRuleTemplateExecutor:
|
||||
def _resolve_message(params: dict[str, Any], *, fallback: str) -> str:
|
||||
template = str(params.get("message_template") or "").strip()
|
||||
return template or fallback
|
||||
|
||||
|
||||
class RiskRuleTemplateExecutor(RiskRuleTemplateConditionMixin, RiskRuleTemplateValueResolverMixin):
|
||||
def evaluate_with_trace(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
result = self.evaluate(manifest, claim=claim, contexts=contexts)
|
||||
return {
|
||||
"hit": result is not None,
|
||||
"result": result,
|
||||
"trace": build_risk_rule_execution_trace(manifest, result=result),
|
||||
}
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()
|
||||
|
||||
if template_key == "field_required_v1":
|
||||
return self._evaluate_required_fields(params, claim=claim, contexts=contexts)
|
||||
if template_key == "field_compare_v1":
|
||||
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return self._evaluate_city_consistency_rule(
|
||||
params,
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts)
|
||||
if template_key == "keyword_match_v1":
|
||||
return self._evaluate_keyword_match(params, claim=claim, contexts=contexts)
|
||||
if template_key == COMPOSITE_RULE_TEMPLATE_KEY:
|
||||
return self._evaluate_composite_rule(params, claim=claim, contexts=contexts)
|
||||
return None
|
||||
|
||||
|
||||
@@ -131,69 +131,7 @@ class PlannedTaskDraft:
|
||||
index: int
|
||||
|
||||
|
||||
class StewardPlannerService:
|
||||
"""小财管家第一版规划服务:只生成计划,不执行入库类动作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
intent_agent: StewardIntentAgent | None = None,
|
||||
off_topic_agent: StewardOffTopicAgent | None = None,
|
||||
) -> None:
|
||||
self.intent_agent = intent_agent
|
||||
self.off_topic_agent = off_topic_agent
|
||||
|
||||
def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
|
||||
message = self._clean_text(request.message)
|
||||
if not message:
|
||||
raise ValueError("小财管家需要一段任务描述。")
|
||||
|
||||
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
|
||||
# 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。
|
||||
scenario = self._classify_irrelevant_input(message, request)
|
||||
if scenario is not None:
|
||||
return self._build_off_topic_plan(request, scenario=scenario)
|
||||
model_call_traces: list[dict[str, Any]] = []
|
||||
fallback_reason = ""
|
||||
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
|
||||
try:
|
||||
intent_result = self.intent_agent.detect(
|
||||
request,
|
||||
base_date=base_date,
|
||||
canonical_fields=list(BUSINESS_CANONICAL_FIELD_ORDER),
|
||||
)
|
||||
if intent_result is not None:
|
||||
model_call_traces = intent_result.model_call_traces
|
||||
llm_plan = StewardModelPlanBuilder(self).build(
|
||||
intent_result,
|
||||
request=request,
|
||||
base_date=base_date,
|
||||
)
|
||||
if llm_plan is not None:
|
||||
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
|
||||
return self._build_pending_flow_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=(
|
||||
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
|
||||
"服务端已改为候选流程确认,避免误入申请流程。"
|
||||
),
|
||||
planning_source="llm_function_call",
|
||||
)
|
||||
return llm_plan
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
|
||||
except Exception as exc:
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = f"主模型 function calling 调用失败,已切换到规则兜底:{exc}"
|
||||
|
||||
return self._build_rule_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=fallback_reason,
|
||||
)
|
||||
|
||||
class StewardPlannerFallbackMixin:
|
||||
def _should_use_model_intent_recognition(
|
||||
self,
|
||||
message: str,
|
||||
@@ -602,6 +540,8 @@ class StewardPlannerService:
|
||||
|
||||
return drafts
|
||||
|
||||
|
||||
class StewardPlannerExtractionMixin:
|
||||
def _has_multiple_financial_demands(self, message: str) -> bool:
|
||||
task_drafts = self._extract_task_drafts(message)
|
||||
if len(task_drafts) > 1:
|
||||
@@ -1219,3 +1159,68 @@ class StewardPlannerService:
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
class StewardPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtractionMixin):
|
||||
"""小财管家第一版规划服务:只生成计划,不执行入库类动作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
intent_agent: StewardIntentAgent | None = None,
|
||||
off_topic_agent: StewardOffTopicAgent | None = None,
|
||||
) -> None:
|
||||
self.intent_agent = intent_agent
|
||||
self.off_topic_agent = off_topic_agent
|
||||
|
||||
def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
|
||||
message = self._clean_text(request.message)
|
||||
if not message:
|
||||
raise ValueError("小财管家需要一段任务描述。")
|
||||
|
||||
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
|
||||
# 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。
|
||||
scenario = self._classify_irrelevant_input(message, request)
|
||||
if scenario is not None:
|
||||
return self._build_off_topic_plan(request, scenario=scenario)
|
||||
model_call_traces: list[dict[str, Any]] = []
|
||||
fallback_reason = ""
|
||||
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
|
||||
try:
|
||||
intent_result = self.intent_agent.detect(
|
||||
request,
|
||||
base_date=base_date,
|
||||
canonical_fields=list(BUSINESS_CANONICAL_FIELD_ORDER),
|
||||
)
|
||||
if intent_result is not None:
|
||||
model_call_traces = intent_result.model_call_traces
|
||||
llm_plan = StewardModelPlanBuilder(self).build(
|
||||
intent_result,
|
||||
request=request,
|
||||
base_date=base_date,
|
||||
)
|
||||
if llm_plan is not None:
|
||||
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
|
||||
return self._build_pending_flow_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=(
|
||||
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
|
||||
"服务端已改为候选流程确认,避免误入申请流程。"
|
||||
),
|
||||
planning_source="llm_function_call",
|
||||
)
|
||||
return llm_plan
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
|
||||
except Exception as exc:
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = f"主模型 function calling 调用失败,已切换到规则兜底:{exc}"
|
||||
|
||||
return self._build_rule_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=fallback_reason,
|
||||
)
|
||||
|
||||
|
||||
@@ -151,432 +151,7 @@ APPLICATION_DUPLICATE_IGNORED_STATUSES = {
|
||||
}
|
||||
|
||||
|
||||
class UserAgentApplicationMixin:
|
||||
@staticmethod
|
||||
def _is_expense_application_request(payload: UserAgentRequest) -> bool:
|
||||
context_json = payload.context_json or {}
|
||||
context_values = {
|
||||
str(context_json.get("session_type") or "").strip(),
|
||||
str(context_json.get("entry_source") or "").strip(),
|
||||
str(context_json.get("document_type") or "").strip(),
|
||||
str(context_json.get("application_stage") or "").strip(),
|
||||
}
|
||||
conversation_state = context_json.get("conversation_state")
|
||||
if isinstance(conversation_state, dict):
|
||||
context_values.update(
|
||||
{
|
||||
str(conversation_state.get("session_type") or "").strip(),
|
||||
str(conversation_state.get("entry_source") or "").strip(),
|
||||
str(conversation_state.get("document_type") or "").strip(),
|
||||
str(conversation_state.get("application_stage") or "").strip(),
|
||||
}
|
||||
)
|
||||
if context_values & APPLICATION_CONTEXT_VALUES:
|
||||
return True
|
||||
|
||||
history = context_json.get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
return False
|
||||
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||
looks_like_submit = (
|
||||
any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||
or compact_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||
)
|
||||
if not looks_like_submit:
|
||||
return False
|
||||
return any(
|
||||
isinstance(item, dict)
|
||||
and str(item.get("role") or "").strip() == "assistant"
|
||||
and (
|
||||
"#application-submit" in str(item.get("content") or "")
|
||||
or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or ""))
|
||||
)
|
||||
for item in history[-6:]
|
||||
)
|
||||
|
||||
def _build_expense_application_response(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
risk_flags: list[str],
|
||||
) -> UserAgentResponse:
|
||||
facts = self._resolve_expense_application_facts(payload)
|
||||
step = self._resolve_expense_application_step(payload, facts)
|
||||
application_claim = None
|
||||
if step in {"draft", "submitted"}:
|
||||
editable_claim = self._find_editable_expense_application_record(payload)
|
||||
if editable_claim is not None:
|
||||
application_claim = self._update_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
editable_claim,
|
||||
submit=step == "submitted",
|
||||
)
|
||||
facts["application_edit_mode"] = "true"
|
||||
elif step == "submitted":
|
||||
application_claim = self._find_duplicate_expense_application_record(payload, facts)
|
||||
if application_claim is not None:
|
||||
step = "duplicate"
|
||||
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=True,
|
||||
)
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=False,
|
||||
)
|
||||
if application_claim is not None:
|
||||
facts["application_no"] = application_claim.claim_no
|
||||
facts["application_claim_id"] = application_claim.id
|
||||
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
||||
return UserAgentResponse(
|
||||
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
||||
citations=[],
|
||||
suggested_actions=self._build_expense_application_actions(step, facts),
|
||||
query_payload=None,
|
||||
draft_payload=(
|
||||
self._build_persisted_application_payload(application_claim, facts)
|
||||
if step in {"draft", "submitted"}
|
||||
else None
|
||||
),
|
||||
review_payload=None,
|
||||
risk_flags=risk_flags,
|
||||
requires_confirmation=step == "preview",
|
||||
)
|
||||
|
||||
def _build_expense_application_answer(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
facts: dict[str, str],
|
||||
step: str,
|
||||
) -> str:
|
||||
recognized_table = build_application_summary_table(facts, include_empty=False)
|
||||
|
||||
if step == "ask_missing":
|
||||
missing_fields = self._resolve_application_missing_fields(facts)
|
||||
missing_text = "、".join(
|
||||
self._display_application_slot_label(item)
|
||||
for item in missing_fields
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
"我已按「费用申请 / 事前审批」来处理这条内容。",
|
||||
"已识别信息:\n" + recognized_table,
|
||||
f"当前还需要补充:{missing_text}。",
|
||||
"请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "draft":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
return "\n\n".join(
|
||||
[
|
||||
"申请草稿已保存。",
|
||||
f"草稿单号:{application_no}" if application_no else "草稿单号:待生成",
|
||||
"当前节点:待提交。",
|
||||
"后续可进入单据详情继续核对、补充或提交审批。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "submitted":
|
||||
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
||||
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
||||
submitted_title = (
|
||||
"申请单据已修改并重新提交,已进入审批流程。"
|
||||
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
|
||||
else "申请单据已生成,并已进入审批流程。"
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
submitted_title,
|
||||
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
|
||||
f"申请单号:{application_no}",
|
||||
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "duplicate":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
|
||||
time_label = resolve_application_time_label(facts)
|
||||
return "\n\n".join(
|
||||
[
|
||||
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
|
||||
f"已有申请单号:{application_no}",
|
||||
f"当前节点:{stage}",
|
||||
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(
|
||||
[
|
||||
"这是费用申请核对结果,请核对:",
|
||||
build_application_summary_table(facts),
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||
]
|
||||
)
|
||||
|
||||
def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]:
|
||||
facts = {
|
||||
"time": "",
|
||||
"location": "",
|
||||
"reason": "",
|
||||
"days": "",
|
||||
"transport_mode": "",
|
||||
"amount": "",
|
||||
"application_type": "",
|
||||
"applicant": "",
|
||||
"grade": "",
|
||||
"department": "",
|
||||
"position": "",
|
||||
"manager_name": "",
|
||||
"lodging_daily_cap": "",
|
||||
"subsidy_daily_cap": "",
|
||||
"transport_policy": "",
|
||||
"policy_estimate": "",
|
||||
"matched_city": "",
|
||||
"rule_name": "",
|
||||
"rule_version": "",
|
||||
"hotel_amount": "",
|
||||
"allowance_amount": "",
|
||||
"transport_estimated_amount": "",
|
||||
"transport_estimate_source": "",
|
||||
"transport_estimate_confidence": "",
|
||||
"policy_total_amount": "",
|
||||
}
|
||||
for message, is_current in self._iter_application_user_messages(payload):
|
||||
partial = {
|
||||
"time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message),
|
||||
"location": self._resolve_application_location(payload, message=message, use_entities=is_current),
|
||||
"reason": self._resolve_application_reason(message),
|
||||
"days": self._resolve_application_days(message),
|
||||
"transport_mode": self._resolve_application_transport_mode(message),
|
||||
"amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message),
|
||||
"application_type": self._resolve_application_type_from_text(message),
|
||||
}
|
||||
for key, value in partial.items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
facts["application_type"] = self._normalize_application_type_label(facts.get("application_type", ""))
|
||||
context_json = payload.context_json or {}
|
||||
context_time = self._resolve_application_time_from_context(context_json)
|
||||
if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
|
||||
facts["time"] = context_time
|
||||
current_user = self._build_application_current_user(payload)
|
||||
employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
|
||||
if not facts["applicant"]:
|
||||
facts["applicant"] = str(
|
||||
context_json.get("name")
|
||||
or context_json.get("user_name")
|
||||
or context_json.get("applicant")
|
||||
or (employee.name if employee is not None else "")
|
||||
or current_user.name
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["grade"]:
|
||||
facts["grade"] = str(
|
||||
context_json.get("grade")
|
||||
or context_json.get("employee_grade")
|
||||
or context_json.get("employeeGrade")
|
||||
or current_user.grade
|
||||
or (employee.grade if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["department"]:
|
||||
facts["department"] = str(
|
||||
context_json.get("department")
|
||||
or context_json.get("department_name")
|
||||
or context_json.get("departmentName")
|
||||
or current_user.department_name
|
||||
or (
|
||||
employee.organization_unit.name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["position"]:
|
||||
facts["position"] = str(
|
||||
context_json.get("position")
|
||||
or context_json.get("employee_position")
|
||||
or context_json.get("employeePosition")
|
||||
or current_user.position
|
||||
or (employee.position if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["manager_name"]:
|
||||
facts["manager_name"] = str(
|
||||
context_json.get("manager_name")
|
||||
or context_json.get("managerName")
|
||||
or context_json.get("direct_manager_name")
|
||||
or context_json.get("directManagerName")
|
||||
or current_user.manager_name
|
||||
or (
|
||||
employee.manager.name
|
||||
if employee is not None and employee.manager is not None
|
||||
else ""
|
||||
)
|
||||
or (
|
||||
employee.organization_unit.manager_name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not facts["application_type"]:
|
||||
facts["application_type"] = self._infer_application_type(facts)
|
||||
facts["time"] = self._expand_application_time_with_days(
|
||||
facts.get("time", ""),
|
||||
facts.get("days", ""),
|
||||
payload.context_json or {},
|
||||
)
|
||||
if self._is_application_missing_value(facts.get("days", "")):
|
||||
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
|
||||
if range_days:
|
||||
facts["days"] = f"{range_days}天"
|
||||
self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
|
||||
apply_application_system_estimate_to_facts(facts)
|
||||
return facts
|
||||
|
||||
def _apply_rule_center_travel_policy_to_application_facts(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> None:
|
||||
if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
|
||||
return
|
||||
|
||||
location = str(facts.get("location") or "").strip()
|
||||
grade = str(facts.get("grade") or "").strip()
|
||||
if not location or not grade:
|
||||
return
|
||||
|
||||
days = self._parse_application_days_count(facts.get("days", "")) or 1
|
||||
try:
|
||||
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
|
||||
self._build_application_current_user(payload),
|
||||
)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
hotel_rate = self._format_application_policy_money(result.hotel_rate)
|
||||
hotel_amount = self._format_application_policy_money(result.hotel_amount)
|
||||
allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
|
||||
allowance_amount = self._format_application_policy_money(result.allowance_amount)
|
||||
if hotel_rate:
|
||||
facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
|
||||
if hotel_amount:
|
||||
facts["hotel_amount"] = f"{hotel_amount}元"
|
||||
if allowance_rate:
|
||||
facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
|
||||
if allowance_amount:
|
||||
facts["allowance_amount"] = f"{allowance_amount}元"
|
||||
if str(result.matched_city or "").strip():
|
||||
facts["matched_city"] = str(result.matched_city).strip()
|
||||
if str(result.rule_name or "").strip():
|
||||
facts["rule_name"] = str(result.rule_name).strip()
|
||||
if str(result.rule_version or "").strip():
|
||||
facts["rule_version"] = str(result.rule_version).strip()
|
||||
|
||||
@staticmethod
|
||||
def _format_application_policy_money(value: object) -> str:
|
||||
try:
|
||||
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return ""
|
||||
if amount == amount.to_integral():
|
||||
return f"{int(amount):,}"
|
||||
return f"{amount:,.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
@staticmethod
|
||||
def _parse_application_days_count(value: object) -> int:
|
||||
match = re.search(r"\d+", str(value or ""))
|
||||
if not match:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(match.group(0)))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
|
||||
preview = context_json.get("application_preview")
|
||||
if not isinstance(preview, dict):
|
||||
return {}
|
||||
fields = preview.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for key in keys:
|
||||
value = str(fields.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
|
||||
return {
|
||||
"application_type": UserAgentApplicationMixin._normalize_application_type_label(
|
||||
pick("applicationType", "application_type")
|
||||
),
|
||||
"time": pick("time", "timeRange", "time_range"),
|
||||
"location": pick("location"),
|
||||
"reason": reason,
|
||||
"days": pick("days"),
|
||||
"transport_mode": pick("transportMode", "transport_mode"),
|
||||
"amount": pick("amount"),
|
||||
"applicant": pick("applicant", "name", "userName", "user_name"),
|
||||
"grade": pick("grade"),
|
||||
"department": pick("department", "departmentName", "department_name"),
|
||||
"position": pick("position", "employeePosition", "employee_position"),
|
||||
"manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
|
||||
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
|
||||
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
|
||||
"transport_policy": pick("transportPolicy", "transport_policy"),
|
||||
"policy_estimate": pick("policyEstimate", "policy_estimate"),
|
||||
"matched_city": pick("matchedCity", "matched_city"),
|
||||
"rule_name": pick("ruleName", "rule_name"),
|
||||
"rule_version": pick("ruleVersion", "rule_version"),
|
||||
"hotel_amount": pick("hotelAmount", "hotel_amount"),
|
||||
"allowance_amount": pick("allowanceAmount", "allowance_amount"),
|
||||
"transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"),
|
||||
"transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"),
|
||||
"transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"),
|
||||
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_application_missing_value(value: object) -> bool:
|
||||
return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES
|
||||
|
||||
def _resolve_expense_application_step(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> str:
|
||||
if self._is_application_save_draft_action(payload):
|
||||
return "draft"
|
||||
if self._resolve_application_missing_base_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._resolve_application_missing_followup_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._is_application_submit_confirmation(payload):
|
||||
return "submitted"
|
||||
return "preview"
|
||||
|
||||
class UserAgentApplicationSlotMixin:
|
||||
@staticmethod
|
||||
def _iter_application_user_messages(payload: UserAgentRequest) -> list[tuple[str, bool]]:
|
||||
messages: list[tuple[str, bool]] = []
|
||||
@@ -1027,6 +602,8 @@ class UserAgentApplicationMixin:
|
||||
return "会务费用申请"
|
||||
return "差旅费用申请"
|
||||
|
||||
|
||||
class UserAgentApplicationPersistenceMixin:
|
||||
@staticmethod
|
||||
def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
|
||||
if not isinstance(context_json, dict):
|
||||
@@ -1512,3 +1089,431 @@ class UserAgentApplicationMixin:
|
||||
"application",
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicationPersistenceMixin):
|
||||
@staticmethod
|
||||
def _is_expense_application_request(payload: UserAgentRequest) -> bool:
|
||||
context_json = payload.context_json or {}
|
||||
context_values = {
|
||||
str(context_json.get("session_type") or "").strip(),
|
||||
str(context_json.get("entry_source") or "").strip(),
|
||||
str(context_json.get("document_type") or "").strip(),
|
||||
str(context_json.get("application_stage") or "").strip(),
|
||||
}
|
||||
conversation_state = context_json.get("conversation_state")
|
||||
if isinstance(conversation_state, dict):
|
||||
context_values.update(
|
||||
{
|
||||
str(conversation_state.get("session_type") or "").strip(),
|
||||
str(conversation_state.get("entry_source") or "").strip(),
|
||||
str(conversation_state.get("document_type") or "").strip(),
|
||||
str(conversation_state.get("application_stage") or "").strip(),
|
||||
}
|
||||
)
|
||||
if context_values & APPLICATION_CONTEXT_VALUES:
|
||||
return True
|
||||
|
||||
history = context_json.get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
return False
|
||||
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||
looks_like_submit = (
|
||||
any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||
or compact_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||
)
|
||||
if not looks_like_submit:
|
||||
return False
|
||||
return any(
|
||||
isinstance(item, dict)
|
||||
and str(item.get("role") or "").strip() == "assistant"
|
||||
and (
|
||||
"#application-submit" in str(item.get("content") or "")
|
||||
or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or ""))
|
||||
)
|
||||
for item in history[-6:]
|
||||
)
|
||||
|
||||
def _build_expense_application_response(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
risk_flags: list[str],
|
||||
) -> UserAgentResponse:
|
||||
facts = self._resolve_expense_application_facts(payload)
|
||||
step = self._resolve_expense_application_step(payload, facts)
|
||||
application_claim = None
|
||||
if step in {"draft", "submitted"}:
|
||||
editable_claim = self._find_editable_expense_application_record(payload)
|
||||
if editable_claim is not None:
|
||||
application_claim = self._update_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
editable_claim,
|
||||
submit=step == "submitted",
|
||||
)
|
||||
facts["application_edit_mode"] = "true"
|
||||
elif step == "submitted":
|
||||
application_claim = self._find_duplicate_expense_application_record(payload, facts)
|
||||
if application_claim is not None:
|
||||
step = "duplicate"
|
||||
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=True,
|
||||
)
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=False,
|
||||
)
|
||||
if application_claim is not None:
|
||||
facts["application_no"] = application_claim.claim_no
|
||||
facts["application_claim_id"] = application_claim.id
|
||||
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
||||
return UserAgentResponse(
|
||||
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
||||
citations=[],
|
||||
suggested_actions=self._build_expense_application_actions(step, facts),
|
||||
query_payload=None,
|
||||
draft_payload=(
|
||||
self._build_persisted_application_payload(application_claim, facts)
|
||||
if step in {"draft", "submitted"}
|
||||
else None
|
||||
),
|
||||
review_payload=None,
|
||||
risk_flags=risk_flags,
|
||||
requires_confirmation=step == "preview",
|
||||
)
|
||||
|
||||
def _build_expense_application_answer(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
facts: dict[str, str],
|
||||
step: str,
|
||||
) -> str:
|
||||
recognized_table = build_application_summary_table(facts, include_empty=False)
|
||||
|
||||
if step == "ask_missing":
|
||||
missing_fields = self._resolve_application_missing_fields(facts)
|
||||
missing_text = "、".join(
|
||||
self._display_application_slot_label(item)
|
||||
for item in missing_fields
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
"我已按「费用申请 / 事前审批」来处理这条内容。",
|
||||
"已识别信息:\n" + recognized_table,
|
||||
f"当前还需要补充:{missing_text}。",
|
||||
"请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "draft":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
return "\n\n".join(
|
||||
[
|
||||
"申请草稿已保存。",
|
||||
f"草稿单号:{application_no}" if application_no else "草稿单号:待生成",
|
||||
"当前节点:待提交。",
|
||||
"后续可进入单据详情继续核对、补充或提交审批。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "submitted":
|
||||
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
||||
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
||||
submitted_title = (
|
||||
"申请单据已修改并重新提交,已进入审批流程。"
|
||||
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
|
||||
else "申请单据已生成,并已进入审批流程。"
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
submitted_title,
|
||||
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
|
||||
f"申请单号:{application_no}",
|
||||
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "duplicate":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
|
||||
time_label = resolve_application_time_label(facts)
|
||||
return "\n\n".join(
|
||||
[
|
||||
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
|
||||
f"已有申请单号:{application_no}",
|
||||
f"当前节点:{stage}",
|
||||
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(
|
||||
[
|
||||
"这是费用申请核对结果,请核对:",
|
||||
build_application_summary_table(facts),
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||
]
|
||||
)
|
||||
|
||||
def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]:
|
||||
facts = {
|
||||
"time": "",
|
||||
"location": "",
|
||||
"reason": "",
|
||||
"days": "",
|
||||
"transport_mode": "",
|
||||
"amount": "",
|
||||
"application_type": "",
|
||||
"applicant": "",
|
||||
"grade": "",
|
||||
"department": "",
|
||||
"position": "",
|
||||
"manager_name": "",
|
||||
"lodging_daily_cap": "",
|
||||
"subsidy_daily_cap": "",
|
||||
"transport_policy": "",
|
||||
"policy_estimate": "",
|
||||
"matched_city": "",
|
||||
"rule_name": "",
|
||||
"rule_version": "",
|
||||
"hotel_amount": "",
|
||||
"allowance_amount": "",
|
||||
"transport_estimated_amount": "",
|
||||
"transport_estimate_source": "",
|
||||
"transport_estimate_confidence": "",
|
||||
"policy_total_amount": "",
|
||||
}
|
||||
for message, is_current in self._iter_application_user_messages(payload):
|
||||
partial = {
|
||||
"time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message),
|
||||
"location": self._resolve_application_location(payload, message=message, use_entities=is_current),
|
||||
"reason": self._resolve_application_reason(message),
|
||||
"days": self._resolve_application_days(message),
|
||||
"transport_mode": self._resolve_application_transport_mode(message),
|
||||
"amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message),
|
||||
"application_type": self._resolve_application_type_from_text(message),
|
||||
}
|
||||
for key, value in partial.items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
facts["application_type"] = self._normalize_application_type_label(facts.get("application_type", ""))
|
||||
context_json = payload.context_json or {}
|
||||
context_time = self._resolve_application_time_from_context(context_json)
|
||||
if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
|
||||
facts["time"] = context_time
|
||||
current_user = self._build_application_current_user(payload)
|
||||
employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
|
||||
if not facts["applicant"]:
|
||||
facts["applicant"] = str(
|
||||
context_json.get("name")
|
||||
or context_json.get("user_name")
|
||||
or context_json.get("applicant")
|
||||
or (employee.name if employee is not None else "")
|
||||
or current_user.name
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["grade"]:
|
||||
facts["grade"] = str(
|
||||
context_json.get("grade")
|
||||
or context_json.get("employee_grade")
|
||||
or context_json.get("employeeGrade")
|
||||
or current_user.grade
|
||||
or (employee.grade if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["department"]:
|
||||
facts["department"] = str(
|
||||
context_json.get("department")
|
||||
or context_json.get("department_name")
|
||||
or context_json.get("departmentName")
|
||||
or current_user.department_name
|
||||
or (
|
||||
employee.organization_unit.name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["position"]:
|
||||
facts["position"] = str(
|
||||
context_json.get("position")
|
||||
or context_json.get("employee_position")
|
||||
or context_json.get("employeePosition")
|
||||
or current_user.position
|
||||
or (employee.position if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["manager_name"]:
|
||||
facts["manager_name"] = str(
|
||||
context_json.get("manager_name")
|
||||
or context_json.get("managerName")
|
||||
or context_json.get("direct_manager_name")
|
||||
or context_json.get("directManagerName")
|
||||
or current_user.manager_name
|
||||
or (
|
||||
employee.manager.name
|
||||
if employee is not None and employee.manager is not None
|
||||
else ""
|
||||
)
|
||||
or (
|
||||
employee.organization_unit.manager_name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not facts["application_type"]:
|
||||
facts["application_type"] = self._infer_application_type(facts)
|
||||
facts["time"] = self._expand_application_time_with_days(
|
||||
facts.get("time", ""),
|
||||
facts.get("days", ""),
|
||||
payload.context_json or {},
|
||||
)
|
||||
if self._is_application_missing_value(facts.get("days", "")):
|
||||
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
|
||||
if range_days:
|
||||
facts["days"] = f"{range_days}天"
|
||||
self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
|
||||
apply_application_system_estimate_to_facts(facts)
|
||||
return facts
|
||||
|
||||
def _apply_rule_center_travel_policy_to_application_facts(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> None:
|
||||
if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
|
||||
return
|
||||
|
||||
location = str(facts.get("location") or "").strip()
|
||||
grade = str(facts.get("grade") or "").strip()
|
||||
if not location or not grade:
|
||||
return
|
||||
|
||||
days = self._parse_application_days_count(facts.get("days", "")) or 1
|
||||
try:
|
||||
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
|
||||
self._build_application_current_user(payload),
|
||||
)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
hotel_rate = self._format_application_policy_money(result.hotel_rate)
|
||||
hotel_amount = self._format_application_policy_money(result.hotel_amount)
|
||||
allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
|
||||
allowance_amount = self._format_application_policy_money(result.allowance_amount)
|
||||
if hotel_rate:
|
||||
facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
|
||||
if hotel_amount:
|
||||
facts["hotel_amount"] = f"{hotel_amount}元"
|
||||
if allowance_rate:
|
||||
facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
|
||||
if allowance_amount:
|
||||
facts["allowance_amount"] = f"{allowance_amount}元"
|
||||
if str(result.matched_city or "").strip():
|
||||
facts["matched_city"] = str(result.matched_city).strip()
|
||||
if str(result.rule_name or "").strip():
|
||||
facts["rule_name"] = str(result.rule_name).strip()
|
||||
if str(result.rule_version or "").strip():
|
||||
facts["rule_version"] = str(result.rule_version).strip()
|
||||
|
||||
@staticmethod
|
||||
def _format_application_policy_money(value: object) -> str:
|
||||
try:
|
||||
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return ""
|
||||
if amount == amount.to_integral():
|
||||
return f"{int(amount):,}"
|
||||
return f"{amount:,.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
@staticmethod
|
||||
def _parse_application_days_count(value: object) -> int:
|
||||
match = re.search(r"\d+", str(value or ""))
|
||||
if not match:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(match.group(0)))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
|
||||
preview = context_json.get("application_preview")
|
||||
if not isinstance(preview, dict):
|
||||
return {}
|
||||
fields = preview.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for key in keys:
|
||||
value = str(fields.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
|
||||
return {
|
||||
"application_type": UserAgentApplicationMixin._normalize_application_type_label(
|
||||
pick("applicationType", "application_type")
|
||||
),
|
||||
"time": pick("time", "timeRange", "time_range"),
|
||||
"location": pick("location"),
|
||||
"reason": reason,
|
||||
"days": pick("days"),
|
||||
"transport_mode": pick("transportMode", "transport_mode"),
|
||||
"amount": pick("amount"),
|
||||
"applicant": pick("applicant", "name", "userName", "user_name"),
|
||||
"grade": pick("grade"),
|
||||
"department": pick("department", "departmentName", "department_name"),
|
||||
"position": pick("position", "employeePosition", "employee_position"),
|
||||
"manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
|
||||
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
|
||||
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
|
||||
"transport_policy": pick("transportPolicy", "transport_policy"),
|
||||
"policy_estimate": pick("policyEstimate", "policy_estimate"),
|
||||
"matched_city": pick("matchedCity", "matched_city"),
|
||||
"rule_name": pick("ruleName", "rule_name"),
|
||||
"rule_version": pick("ruleVersion", "rule_version"),
|
||||
"hotel_amount": pick("hotelAmount", "hotel_amount"),
|
||||
"allowance_amount": pick("allowanceAmount", "allowance_amount"),
|
||||
"transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"),
|
||||
"transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"),
|
||||
"transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"),
|
||||
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_application_missing_value(value: object) -> bool:
|
||||
return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES
|
||||
|
||||
def _resolve_expense_application_step(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> str:
|
||||
if self._is_application_save_draft_action(payload):
|
||||
return "draft"
|
||||
if self._resolve_application_missing_base_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._resolve_application_missing_followup_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._is_application_submit_confirmation(payload):
|
||||
return "submitted"
|
||||
return "preview"
|
||||
|
||||
|
||||
34
server/tests/test_code_size_limits.py
Normal file
34
server/tests/test_code_size_limits.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAX_CLASS_LINES = 800
|
||||
SERVER_SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src" / "app"
|
||||
|
||||
|
||||
def iter_python_source_files(root: Path) -> list[Path]:
|
||||
ignored_parts = {"__pycache__", "x_financial_server.egg-info"}
|
||||
return sorted(
|
||||
path
|
||||
for path in root.rglob("*.py")
|
||||
if not ignored_parts.intersection(path.parts)
|
||||
)
|
||||
|
||||
|
||||
def test_python_classes_do_not_exceed_800_lines() -> None:
|
||||
oversized_classes: list[str] = []
|
||||
|
||||
for path in iter_python_source_files(SERVER_SOURCE_ROOT):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.end_lineno is not None:
|
||||
line_count = node.end_lineno - node.lineno + 1
|
||||
if line_count > MAX_CLASS_LINES:
|
||||
relative_path = path.relative_to(SERVER_SOURCE_ROOT.parents[1])
|
||||
oversized_classes.append(
|
||||
f"{relative_path}:{node.lineno} {node.name} ({line_count} lines)"
|
||||
)
|
||||
|
||||
assert oversized_classes == []
|
||||
@@ -2207,6 +2207,160 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
|
||||
assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])
|
||||
|
||||
|
||||
def test_upload_auto_collected_attachment_uses_source_receipt_ocr_result(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
current_user = CurrentUserContext(
|
||||
username="auto-collect-travel@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
) -> OcrRecognizeBatchRead:
|
||||
return OcrRecognizeBatchRead(
|
||||
total_file_count=1,
|
||||
success_count=1,
|
||||
documents=[
|
||||
OcrRecognizeDocumentRead(
|
||||
filename="2月22 深圳-上海.pdf",
|
||||
media_type="application/pdf",
|
||||
text="",
|
||||
summary="",
|
||||
avg_score=0.0,
|
||||
line_count=0,
|
||||
page_count=1,
|
||||
document_type="other",
|
||||
document_type_label="其他单据",
|
||||
scene_code="other",
|
||||
scene_label="其他票据",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||
monkeypatch.setattr(
|
||||
ExpenseClaimAttachmentStorage,
|
||||
"root",
|
||||
lambda self: tmp_path / "attachments",
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E-AUTO-COLLECT",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
|
||||
claim = build_claim(expense_type="travel", location="上海")
|
||||
claim.employee = employee
|
||||
claim.employee_id = employee.id
|
||||
claim.employee_name = employee.name
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"severity": "high",
|
||||
"message": "票据类型:未识别到发票、票据、电子行程单等关键字。",
|
||||
}
|
||||
]
|
||||
claim.items[0].item_type = "travel"
|
||||
claim.items[0].item_reason = ""
|
||||
claim.items[0].item_location = "上海"
|
||||
claim.items[0].item_amount = Decimal("0.00")
|
||||
claim.items[0].invoice_id = None
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
receipt = ReceiptFolderService().save_receipt(
|
||||
filename="2月22 深圳-上海.pdf",
|
||||
content=b"%PDF-1.4 fake-train-ticket",
|
||||
media_type="application/pdf",
|
||||
current_user=current_user,
|
||||
document=OcrRecognizeDocumentRead(
|
||||
filename="2月22 深圳-上海.pdf",
|
||||
media_type="application/pdf",
|
||||
text="中国铁路电子客票 深圳北-上海虹桥 2026-02-22 票价:¥388.00",
|
||||
summary="铁路电子客票,深圳至上海,2026-02-22 出发,票价 388 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅票据",
|
||||
document_fields=[
|
||||
{"key": "origin", "label": "出发城市", "value": "深圳"},
|
||||
{"key": "destination", "label": "到达城市", "value": "上海"},
|
||||
{"key": "trip_date", "label": "列车出发时间", "value": "2026-02-22"},
|
||||
{"key": "fare", "label": "票价", "value": "¥388.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
updated = service.upload_claim_item_attachment(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
filename="2月22 深圳-上海.pdf",
|
||||
content=b"%PDF-1.4 fake-train-ticket",
|
||||
media_type="application/pdf",
|
||||
current_user=current_user,
|
||||
source_receipt_id=receipt.id,
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated["item_type"] == "train_ticket"
|
||||
assert updated["item_amount"] == Decimal("388.00")
|
||||
assert updated["item_date"] == "2026-02-22"
|
||||
assert updated["item_reason"] == "深圳北-上海虹桥"
|
||||
|
||||
uploaded_meta = service.get_claim_item_attachment_meta(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
assert uploaded_meta is not None
|
||||
assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
|
||||
assert uploaded_meta["requirement_check"]["matches"] is True
|
||||
assert not any(
|
||||
"未识别到发票" in point or "当前识别为其他单据" in point
|
||||
for point in uploaded_meta["analysis"]["points"]
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
allowance_item = next(
|
||||
item for item in claim.items if item.item_type == "travel_allowance"
|
||||
)
|
||||
assert allowance_item.item_amount > Decimal("0.00")
|
||||
assert "1天" in allowance_item.item_reason
|
||||
assert claim.amount == Decimal("388.00") + allowance_item.item_amount
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
and "未识别到发票" in str(flag.get("message") or "")
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
linked_receipt = ReceiptFolderService().get_receipt(receipt.id, current_user)
|
||||
assert linked_receipt.status == "linked"
|
||||
assert linked_receipt.linked_claim_id == claim.id
|
||||
assert linked_receipt.linked_claim_no == claim.claim_no
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_upload_attachment_response_includes_refreshed_rule_center_risk_flags(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
:global(.expense-profile-dialog-overlay) {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.34), rgba(15, 23, 42, 0.4)),
|
||||
rgba(15, 23, 42, 0.36);
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog.el-dialog) {
|
||||
max-height: calc(100vh - 56px);
|
||||
max-height: calc(100dvh - 56px);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog .el-dialog__header),
|
||||
:global(.expense-profile-dialog .expense-profile-dialog-body),
|
||||
:global(.expense-profile-dialog .el-dialog__footer) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active),
|
||||
:global(.expense-profile-dialog-zoom-leave-active) {
|
||||
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog) {
|
||||
animation: expenseProfileDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
|
||||
animation: expenseProfileDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-from),
|
||||
:global(.expense-profile-dialog-zoom-leave-to) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.profile-dialog-header,
|
||||
.profile-dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.profile-dialog-header {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.profile-dialog-footer {
|
||||
justify-content: flex-start;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.profile-dialog-title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-dialog-eyebrow,
|
||||
.profile-section-title small {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-dialog-header h2 {
|
||||
margin: 3px 0 4px;
|
||||
color: #0f172a;
|
||||
font-size: 19px;
|
||||
line-height: 1.25;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-dialog-header p,
|
||||
.profile-dialog-footer span {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-dialog-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
color: #334155;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.profile-dialog-close:hover {
|
||||
background: #eef4fb;
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.profile-dialog-content {
|
||||
max-height: min(580px, calc(100vh - 176px));
|
||||
max-height: min(580px, calc(100dvh - 176px));
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.profile-dialog-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.profile-dialog-alert.is-error {
|
||||
border-color: rgba(220, 38, 38, 0.24);
|
||||
background: #fff7f7;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.profile-dialog-alert.is-empty {
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
background: #fffaf0;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.profile-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-summary-item,
|
||||
.profile-panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.profile-summary-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.profile-summary-item span,
|
||||
.profile-operation-copy span,
|
||||
.profile-operation-row time {
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-summary-item strong {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
line-height: 1.15;
|
||||
font-weight: 850;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.profile-summary-item small {
|
||||
margin-left: 2px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-summary-item em {
|
||||
overflow: hidden;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-analysis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profile-tags-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-content: stretch;
|
||||
min-height: 312px;
|
||||
}
|
||||
|
||||
.profile-radar-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
align-content: stretch;
|
||||
min-height: 312px;
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-section-title > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profile-section-title span {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-radar-title { align-items: flex-start; }
|
||||
|
||||
.profile-radar-view-select {
|
||||
width: 118px;
|
||||
flex: 0 0 118px;
|
||||
}
|
||||
.profile-radar-view-select :deep(.el-select__wrapper) {
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px #cbd5e1 inset;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.profile-operation-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-panel-empty {
|
||||
margin: 0;
|
||||
padding: 18px 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-tags-panel > .profile-panel-empty {
|
||||
min-height: 244px;
|
||||
}
|
||||
|
||||
.profile-radar-empty {
|
||||
min-height: 268px;
|
||||
}
|
||||
|
||||
.profile-operation-copy strong {
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-operation-status {
|
||||
border-radius: 4px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-radar-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
min-height: 300px;
|
||||
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
.profile-radar-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.profile-behavior-tags {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
min-height: 59px;
|
||||
border-top: 1px solid #e8eef5;
|
||||
}
|
||||
|
||||
.profile-behavior-tags.is-empty { visibility: hidden; }
|
||||
|
||||
.profile-behavior-tags-title {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-behavior-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.profile-behavior-tag {
|
||||
--behavior-tag-rgb: 58, 124, 165;
|
||||
--behavior-tag-text: #235d7e;
|
||||
max-width: 132px;
|
||||
overflow: hidden;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid rgba(var(--behavior-tag-rgb), 0.24);
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--behavior-tag-rgb), 0.08);
|
||||
color: var(--behavior-tag-text);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
animation: profileBehaviorTagIn 260ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--risk {
|
||||
--behavior-tag-rgb: 245, 158, 11;
|
||||
--behavior-tag-text: #92400e;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--positive {
|
||||
--behavior-tag-rgb: 16, 185, 129;
|
||||
--behavior-tag-text: #047857;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-0 {
|
||||
--behavior-tag-rgb: 58, 124, 165;
|
||||
--behavior-tag-text: #235d7e;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-1 {
|
||||
--behavior-tag-rgb: 15, 159, 143;
|
||||
--behavior-tag-text: #0f766e;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-2 {
|
||||
--behavior-tag-rgb: 245, 158, 11;
|
||||
--behavior-tag-text: #92400e;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-3 {
|
||||
--behavior-tag-rgb: 124, 58, 237;
|
||||
--behavior-tag-text: #5b21b6;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-4 {
|
||||
--behavior-tag-rgb: 220, 38, 38;
|
||||
--behavior-tag-text: #991b1b;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-5 {
|
||||
--behavior-tag-rgb: 37, 99, 235;
|
||||
--behavior-tag-text: #1d4ed8;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-6 {
|
||||
--behavior-tag-rgb: 22, 163, 74;
|
||||
--behavior-tag-text: #15803d;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-7 {
|
||||
--behavior-tag-rgb: 219, 39, 119;
|
||||
--behavior-tag-text: #be185d;
|
||||
}
|
||||
|
||||
.profile-operation-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid #e8eef5;
|
||||
}
|
||||
|
||||
.profile-operation-row:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.profile-operation-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.profile-operation-status {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@media (min-width: 861px) and (max-width: 1440px),
|
||||
(min-width: 861px) and (max-height: 820px) {
|
||||
:global(.expense-profile-dialog.el-dialog) {
|
||||
width: min(900px, calc(100vw - 96px)) !important;
|
||||
max-height: calc(100vh - 64px);
|
||||
max-height: calc(100dvh - 64px);
|
||||
}
|
||||
|
||||
.profile-dialog-header,
|
||||
.profile-dialog-footer {
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.profile-dialog-header h2 {
|
||||
margin: 2px 0 3px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.profile-dialog-header p,
|
||||
.profile-dialog-footer span {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.profile-dialog-content {
|
||||
max-height: min(520px, calc(100vh - 152px));
|
||||
max-height: min(520px, calc(100dvh - 152px));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profile-summary-grid,
|
||||
.profile-analysis-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-summary-item {
|
||||
gap: 3px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.profile-summary-item strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.profile-analysis-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.82fr);
|
||||
}
|
||||
|
||||
.profile-tags-panel,
|
||||
.profile-radar-panel {
|
||||
min-height: 272px;
|
||||
}
|
||||
|
||||
.profile-tags-panel > .profile-panel-empty {
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.profile-radar-empty {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.profile-radar-layout {
|
||||
min-height: 248px;
|
||||
}
|
||||
|
||||
.profile-radar-chart {
|
||||
height: 248px;
|
||||
}
|
||||
|
||||
.profile-behavior-tags {
|
||||
gap: 6px;
|
||||
min-height: 50px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.profile-operation-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-operation-row {
|
||||
gap: 8px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expenseProfileDialogIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.94, 0.94, 1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes profileRadarEnter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes profileBehaviorTagIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expenseProfileDialogOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.96, 0.96, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
:global(.expense-profile-dialog.el-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
}
|
||||
|
||||
.profile-summary-grid,
|
||||
.profile-analysis-grid,
|
||||
.profile-radar-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-dialog-content {
|
||||
max-height: calc(100vh - 170px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.profile-dialog-header,
|
||||
.profile-dialog-footer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-dialog-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-operation-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-operation-status {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog),
|
||||
.profile-radar-layout,
|
||||
.profile-behavior-tag {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -995,6 +995,148 @@
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: -8px 0 22px;
|
||||
border: 1px solid rgba(191, 219, 254, 0.52);
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-toggle {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 10px 12px 10px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1e3a8a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-toggle:hover {
|
||||
background: rgba(239, 246, 255, 0.78);
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-toggle-left {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-toggle strong {
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
font-weight: 860;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-toggle small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-toggle > i {
|
||||
color: #64748b;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #38bdf8;
|
||||
box-shadow: 0 0 0 5px rgba(56, 189, 248, 0.13);
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-detail-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.84);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document__head strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document__head span {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document__summary {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 560;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document__fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document__field {
|
||||
display: grid;
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document__field span {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.workbench-ai-ocr-document__field strong {
|
||||
min-width: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.workbench-ai-pending-line {
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
@@ -1476,6 +1618,58 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card) {
|
||||
background-image:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 252, 0.94)),
|
||||
url("../../ai-document-card-bg.png");
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-attachment-association-card .ai-document-card__head) {
|
||||
background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(240, 253, 250, 0.82));
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card) {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(147, 197, 253, 0.42),
|
||||
0 1px 2px rgba(15, 23, 42, 0.03),
|
||||
0 12px 28px rgba(37, 99, 235, 0.045);
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__head) {
|
||||
background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(239, 246, 255, 0.74));
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__reason) {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__status) {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-attachment-association__details) {
|
||||
gap: 14px 26px;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-attachment-association__details .ai-document-card__field--wide) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-attachment-association__muted) {
|
||||
color: #64748b;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-attachment-association__note) {
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(203, 213, 225, 0.68);
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__action) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/* 强力锁定表格中输入框的高度,解决 scoped 模式下有前缀的 Element Plus 子组件无法被 :deep 成功匹配的局限性 */
|
||||
.detail-expense-table .editor-control .el-input__wrapper,
|
||||
.detail-expense-table .editor-control .el-select__wrapper,
|
||||
.detail-expense-table .editor-select .el-select__wrapper,
|
||||
.detail-expense-table .editor-date-picker .el-input__wrapper {
|
||||
box-sizing: border-box !important;
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-control:not(.risk-note-editor-input),
|
||||
.detail-expense-table .editor-date-picker.editor-control,
|
||||
.detail-expense-table .editor-select {
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__wrapper {
|
||||
gap: 4px !important;
|
||||
padding-right: 7px !important;
|
||||
padding-left: 7px !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__inner,
|
||||
.detail-expense-table .editor-input-control.editor-control .el-input__inner,
|
||||
.detail-expense-table .editor-select .el-select__selected-item,
|
||||
.detail-expense-table .editor-select .el-select__placeholder {
|
||||
height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix {
|
||||
flex: 0 0 14px !important;
|
||||
width: 14px !important;
|
||||
min-width: 14px !important;
|
||||
margin: 0 !important;
|
||||
color: #94a3b8 !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
|
||||
height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner {
|
||||
width: 14px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__wrapper {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__inner {
|
||||
height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix,
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix {
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
|
||||
height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper.el-popper {
|
||||
border: 1px solid rgba(148, 163, 184, .32) !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, .14) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-picker-panel {
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
color: #334155 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-picker__header {
|
||||
height: 38px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 10px !important;
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-picker-panel__icon-btn {
|
||||
appearance: none !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
margin: 0 1px !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: transparent !important;
|
||||
color: #64748b !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
transition: background-color 160ms var(--ease), color 160ms var(--ease) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-picker-panel__icon-btn:hover {
|
||||
background: var(--theme-primary-soft) !important;
|
||||
color: var(--theme-primary-active) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-picker__header-label {
|
||||
color: #0f172a !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 800 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-picker-panel__content {
|
||||
margin: 8px 10px 10px !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table th {
|
||||
border-bottom: 1px solid #edf2f7 !important;
|
||||
color: #64748b !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 800 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td {
|
||||
width: 32px !important;
|
||||
height: 30px !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td .el-date-table-cell {
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td .el-date-table-cell__text {
|
||||
width: 26px !important;
|
||||
height: 26px !important;
|
||||
border-radius: 4px !important;
|
||||
color: #334155 !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 26px !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.available:hover .el-date-table-cell__text {
|
||||
background: var(--theme-primary-soft) !important;
|
||||
color: var(--theme-primary-active) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.today .el-date-table-cell__text {
|
||||
color: var(--theme-primary-active) !important;
|
||||
font-weight: 850 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.current .el-date-table-cell__text,
|
||||
.detail-editor-date-popper .el-date-table td.selected .el-date-table-cell__text {
|
||||
background: var(--theme-primary) !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 850 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.prev-month .el-date-table-cell__text,
|
||||
.detail-editor-date-popper .el-date-table td.next-month .el-date-table-cell__text {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.disabled .el-date-table-cell__text {
|
||||
background: #f8fafc !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
@@ -269,624 +269,5 @@ watch(
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(.expense-profile-dialog-overlay) {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.34), rgba(15, 23, 42, 0.4)),
|
||||
rgba(15, 23, 42, 0.36);
|
||||
}
|
||||
<style scoped src="../../assets/styles/components/expense-profile-detail-modal.css"></style>
|
||||
|
||||
:global(.expense-profile-dialog.el-dialog) {
|
||||
max-height: calc(100vh - 56px);
|
||||
max-height: calc(100dvh - 56px);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog .el-dialog__header),
|
||||
:global(.expense-profile-dialog .expense-profile-dialog-body),
|
||||
:global(.expense-profile-dialog .el-dialog__footer) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active),
|
||||
:global(.expense-profile-dialog-zoom-leave-active) {
|
||||
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog) {
|
||||
animation: expenseProfileDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
|
||||
animation: expenseProfileDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-from),
|
||||
:global(.expense-profile-dialog-zoom-leave-to) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.profile-dialog-header,
|
||||
.profile-dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.profile-dialog-header {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.profile-dialog-footer {
|
||||
justify-content: flex-start;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.profile-dialog-title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-dialog-eyebrow,
|
||||
.profile-section-title small {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-dialog-header h2 {
|
||||
margin: 3px 0 4px;
|
||||
color: #0f172a;
|
||||
font-size: 19px;
|
||||
line-height: 1.25;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-dialog-header p,
|
||||
.profile-dialog-footer span {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-dialog-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
color: #334155;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.profile-dialog-close:hover {
|
||||
background: #eef4fb;
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.profile-dialog-content {
|
||||
max-height: min(580px, calc(100vh - 176px));
|
||||
max-height: min(580px, calc(100dvh - 176px));
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.profile-dialog-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.profile-dialog-alert.is-error {
|
||||
border-color: rgba(220, 38, 38, 0.24);
|
||||
background: #fff7f7;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.profile-dialog-alert.is-empty {
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
background: #fffaf0;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.profile-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-summary-item,
|
||||
.profile-panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.profile-summary-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.profile-summary-item span,
|
||||
.profile-operation-copy span,
|
||||
.profile-operation-row time {
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-summary-item strong {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
line-height: 1.15;
|
||||
font-weight: 850;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.profile-summary-item small {
|
||||
margin-left: 2px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-summary-item em {
|
||||
overflow: hidden;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-analysis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profile-tags-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-content: stretch;
|
||||
min-height: 312px;
|
||||
}
|
||||
|
||||
.profile-radar-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
align-content: stretch;
|
||||
min-height: 312px;
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-section-title > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profile-section-title span {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-radar-title { align-items: flex-start; }
|
||||
|
||||
.profile-radar-view-select {
|
||||
width: 118px;
|
||||
flex: 0 0 118px;
|
||||
}
|
||||
.profile-radar-view-select :deep(.el-select__wrapper) {
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px #cbd5e1 inset;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.profile-operation-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-panel-empty {
|
||||
margin: 0;
|
||||
padding: 18px 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-tags-panel > .profile-panel-empty {
|
||||
min-height: 244px;
|
||||
}
|
||||
|
||||
.profile-radar-empty {
|
||||
min-height: 268px;
|
||||
}
|
||||
|
||||
.profile-operation-copy strong {
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-operation-status {
|
||||
border-radius: 4px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-radar-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
min-height: 300px;
|
||||
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
.profile-radar-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.profile-behavior-tags {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
min-height: 59px;
|
||||
border-top: 1px solid #e8eef5;
|
||||
}
|
||||
|
||||
.profile-behavior-tags.is-empty { visibility: hidden; }
|
||||
|
||||
.profile-behavior-tags-title {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-behavior-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.profile-behavior-tag {
|
||||
--behavior-tag-rgb: 58, 124, 165;
|
||||
--behavior-tag-text: #235d7e;
|
||||
max-width: 132px;
|
||||
overflow: hidden;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid rgba(var(--behavior-tag-rgb), 0.24);
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--behavior-tag-rgb), 0.08);
|
||||
color: var(--behavior-tag-text);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
animation: profileBehaviorTagIn 260ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--risk {
|
||||
--behavior-tag-rgb: 245, 158, 11;
|
||||
--behavior-tag-text: #92400e;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--positive {
|
||||
--behavior-tag-rgb: 16, 185, 129;
|
||||
--behavior-tag-text: #047857;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-0 {
|
||||
--behavior-tag-rgb: 58, 124, 165;
|
||||
--behavior-tag-text: #235d7e;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-1 {
|
||||
--behavior-tag-rgb: 15, 159, 143;
|
||||
--behavior-tag-text: #0f766e;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-2 {
|
||||
--behavior-tag-rgb: 245, 158, 11;
|
||||
--behavior-tag-text: #92400e;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-3 {
|
||||
--behavior-tag-rgb: 124, 58, 237;
|
||||
--behavior-tag-text: #5b21b6;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-4 {
|
||||
--behavior-tag-rgb: 220, 38, 38;
|
||||
--behavior-tag-text: #991b1b;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-5 {
|
||||
--behavior-tag-rgb: 37, 99, 235;
|
||||
--behavior-tag-text: #1d4ed8;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-6 {
|
||||
--behavior-tag-rgb: 22, 163, 74;
|
||||
--behavior-tag-text: #15803d;
|
||||
}
|
||||
|
||||
.profile-behavior-tag--accent-7 {
|
||||
--behavior-tag-rgb: 219, 39, 119;
|
||||
--behavior-tag-text: #be185d;
|
||||
}
|
||||
|
||||
.profile-operation-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid #e8eef5;
|
||||
}
|
||||
|
||||
.profile-operation-row:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.profile-operation-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.profile-operation-status {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@media (min-width: 861px) and (max-width: 1440px),
|
||||
(min-width: 861px) and (max-height: 820px) {
|
||||
:global(.expense-profile-dialog.el-dialog) {
|
||||
width: min(900px, calc(100vw - 96px)) !important;
|
||||
max-height: calc(100vh - 64px);
|
||||
max-height: calc(100dvh - 64px);
|
||||
}
|
||||
|
||||
.profile-dialog-header,
|
||||
.profile-dialog-footer {
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.profile-dialog-header h2 {
|
||||
margin: 2px 0 3px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.profile-dialog-header p,
|
||||
.profile-dialog-footer span {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.profile-dialog-content {
|
||||
max-height: min(520px, calc(100vh - 152px));
|
||||
max-height: min(520px, calc(100dvh - 152px));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profile-summary-grid,
|
||||
.profile-analysis-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-summary-item {
|
||||
gap: 3px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.profile-summary-item strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.profile-analysis-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.82fr);
|
||||
}
|
||||
|
||||
.profile-tags-panel,
|
||||
.profile-radar-panel {
|
||||
min-height: 272px;
|
||||
}
|
||||
|
||||
.profile-tags-panel > .profile-panel-empty {
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.profile-radar-empty {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.profile-radar-layout {
|
||||
min-height: 248px;
|
||||
}
|
||||
|
||||
.profile-radar-chart {
|
||||
height: 248px;
|
||||
}
|
||||
|
||||
.profile-behavior-tags {
|
||||
gap: 6px;
|
||||
min-height: 50px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.profile-operation-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-operation-row {
|
||||
gap: 8px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expenseProfileDialogIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.94, 0.94, 1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes profileRadarEnter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes profileBehaviorTagIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expenseProfileDialogOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.96, 0.96, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
:global(.expense-profile-dialog.el-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
}
|
||||
|
||||
.profile-summary-grid,
|
||||
.profile-analysis-grid,
|
||||
.profile-radar-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-dialog-content {
|
||||
max-height: calc(100vh - 170px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.profile-dialog-header,
|
||||
.profile-dialog-footer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-dialog-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-operation-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-operation-status {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog),
|
||||
.profile-radar-layout,
|
||||
.profile-behavior-tag {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
<section
|
||||
class="workbench-ai-mode"
|
||||
:class="{ 'has-conversation': conversationStarted }"
|
||||
aria-label="小财管家 AI 模式"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="workbench-ai-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleAiModeFilesChange"
|
||||
/>
|
||||
|
||||
<Transition name="workbench-ai-panel-swap" mode="out-in" appear>
|
||||
<div v-if="!conversationStarted" key="welcome" class="workbench-ai-shell workbench-ai-home">
|
||||
<div class="workbench-ai-orb" aria-hidden="true">
|
||||
<img
|
||||
:src="orbIcon"
|
||||
class="workbench-ai-orb__image"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-copy">
|
||||
<h2>嗨,{{ displayUserName }},我是您的小财管家</h2>
|
||||
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
|
||||
</div>
|
||||
|
||||
<form class="workbench-ai-composer" @submit.prevent="submitAiModePrompt">
|
||||
<div class="workbench-ai-composer-field">
|
||||
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
|
||||
<i class="mdi mdi-calendar-check-outline"></i>
|
||||
<span>{{ workbenchDateTagLabel }}</span>
|
||||
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="assistantInputRef"
|
||||
v-model="assistantDraft"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '今天我能帮您做点什么?'"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@keydown.enter.exact.prevent="submitAiModePrompt"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-toolbar">
|
||||
<div class="workbench-ai-tool-buttons">
|
||||
<div class="workbench-date-anchor workbench-ai-date-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
|
||||
title="选择日期"
|
||||
aria-label="选择日期"
|
||||
:aria-expanded="workbenchDatePickerOpen"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click.stop="toggleWorkbenchDatePicker"
|
||||
>
|
||||
<i class="mdi mdi-calendar-range"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="workbenchDatePickerOpen"
|
||||
class="workbench-ai-date-popover"
|
||||
role="dialog"
|
||||
aria-label="选择业务日期"
|
||||
@click.stop
|
||||
>
|
||||
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: workbenchDateMode === 'single' }"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="setWorkbenchDateMode('single')"
|
||||
>
|
||||
单日
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: workbenchDateMode === 'range' }"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="setWorkbenchDateMode('range')"
|
||||
>
|
||||
范围
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
||||
<span>业务日期</span>
|
||||
<input
|
||||
v-model="workbenchSingleDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('single')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-else class="workbench-ai-date-range">
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
v-model="workbenchRangeStartDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('range-start')"
|
||||
/>
|
||||
</label>
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
v-model="workbenchRangeEndDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('range-end')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-date-actions">
|
||||
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
|
||||
@click="applyWorkbenchDateSelection"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="上传附件"
|
||||
aria-label="上传附件"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="triggerAiModeFileUpload"
|
||||
>
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="语音输入"
|
||||
aria-label="语音输入"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="handleVoiceInput"
|
||||
>
|
||||
<i class="mdi mdi-microphone-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-right">
|
||||
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
|
||||
<span>{{ displayModelName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="workbench-ai-send-btn"
|
||||
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
|
||||
aria-label="发送给小财管家"
|
||||
>
|
||||
<i class="mdi mdi-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip" aria-label="已选择附件">
|
||||
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
|
||||
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
|
||||
<i :class="file.icon"></i>
|
||||
</span>
|
||||
<span class="workbench-ai-file-card__body">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<small>{{ file.typeLabel }}</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-file-card__remove"
|
||||
:disabled="isAiModeInputLocked"
|
||||
:aria-label="`移除附件 ${file.name}`"
|
||||
@click="removeAiModeFile(file.key)"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-quick-start-section">
|
||||
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
|
||||
<div class="workbench-ai-action-row" aria-label="推荐主题">
|
||||
<button
|
||||
v-for="item in aiModeActionItems"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="workbench-ai-action"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="runAiModeAction(item)"
|
||||
>
|
||||
<div class="action-icon-wrapper">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div class="action-text">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<p>{{ item.prompt }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else key="conversation" class="workbench-ai-conversation">
|
||||
<div class="workbench-ai-conversation-actions" aria-label="对话操作">
|
||||
<button type="button" title="回到对话顶部" aria-label="回到对话顶部" @click="scrollInlineConversationToTop">
|
||||
<i class="mdi mdi-arrow-up"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
title="删除当前对话"
|
||||
aria-label="删除当前对话"
|
||||
:disabled="!conversationMessages.length"
|
||||
@click="requestDeleteCurrentConversation"
|
||||
>
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="conversationScrollRef"
|
||||
class="workbench-ai-thread"
|
||||
aria-live="polite"
|
||||
@scroll.passive="handleInlineConversationScroll"
|
||||
>
|
||||
<div v-if="conversationMessages.length === 0" class="workbench-ai-empty-thread">
|
||||
<strong>{{ activeConversationTitle || '新对话' }}</strong>
|
||||
<p>直接输入问题,小财管家会在当前页面内持续回复。</p>
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-for="message in conversationMessages"
|
||||
:key="message.id"
|
||||
class="workbench-ai-message"
|
||||
:class="`is-${message.role}`"
|
||||
>
|
||||
<div v-if="message.role === 'user'" class="workbench-ai-user-bubble">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<div v-if="message.role === 'user'" class="workbench-ai-message-actions">
|
||||
<button type="button" title="引用" aria-label="引用" @click="quoteInlineMessage(message)">
|
||||
<i class="mdi mdi-format-quote-open"></i>
|
||||
</button>
|
||||
<button type="button" title="复制" aria-label="复制" @click="copyInlineMessage(message)">
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
</button>
|
||||
<time class="workbench-ai-message-time">{{ formatMessageTime(message.createdAt) }}</time>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
class="workbench-ai-answer-card"
|
||||
:class="{ pending: message.pending, 'has-thinking': hasInlineThinking(message) }"
|
||||
>
|
||||
<div
|
||||
v-if="hasInlineThinking(message)"
|
||||
class="workbench-ai-thinking-panel"
|
||||
:class="{
|
||||
'is-expanded': isInlineThinkingExpanded(message),
|
||||
'is-collapsed': !isInlineThinkingExpanded(message),
|
||||
'is-running': message.pending
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-if="!isInlineThinkingExpanded(message)"
|
||||
type="button"
|
||||
class="workbench-ai-thinking-toggle"
|
||||
aria-expanded="false"
|
||||
@click="toggleInlineThinking(message)"
|
||||
>
|
||||
<span class="workbench-ai-thinking-toggle-left">
|
||||
<span class="workbench-ai-thinking-dot" aria-hidden="true"></span>
|
||||
<strong>小财业务思考</strong>
|
||||
<small>{{ resolveInlineThinkingEvents(message).length }} 条</small>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<div v-else class="workbench-ai-thinking-expanded">
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-thinking-collapse-btn"
|
||||
aria-label="折叠小财业务思考"
|
||||
@click="toggleInlineThinking(message)"
|
||||
>
|
||||
<i class="mdi mdi-chevron-up" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<Transition name="workbench-ai-thinking-collapse" appear>
|
||||
<div
|
||||
class="workbench-ai-thinking-list"
|
||||
aria-label="小财业务思考明细"
|
||||
>
|
||||
<div
|
||||
v-for="event in resolveInlineThinkingEvents(message)"
|
||||
:key="event.eventId || `${message.id}-${event.title}`"
|
||||
class="workbench-ai-thinking-item"
|
||||
:class="`is-${event.status || 'completed'}`"
|
||||
>
|
||||
<span class="workbench-ai-thinking-dot" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>{{ event.title || '正在分析' }}</strong>
|
||||
<p v-if="event.content">{{ event.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasInlineAttachmentOcrDetails(message)"
|
||||
class="workbench-ai-ocr-detail-panel"
|
||||
:class="{ 'is-expanded': isInlineAttachmentOcrExpanded(message) }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-ocr-detail-toggle"
|
||||
:aria-expanded="isInlineAttachmentOcrExpanded(message)"
|
||||
@click="toggleInlineAttachmentOcrDetails(message)"
|
||||
>
|
||||
<span class="workbench-ai-ocr-detail-toggle-left">
|
||||
<span class="workbench-ai-ocr-detail-dot" aria-hidden="true"></span>
|
||||
<strong>附件识别明细</strong>
|
||||
<small>{{ resolveInlineAttachmentOcrFileCount(message) }} 份</small>
|
||||
</span>
|
||||
<i
|
||||
class="mdi"
|
||||
:class="isInlineAttachmentOcrExpanded(message) ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<Transition name="workbench-ai-thinking-collapse" appear>
|
||||
<div
|
||||
v-if="isInlineAttachmentOcrExpanded(message)"
|
||||
class="workbench-ai-ocr-detail-body"
|
||||
aria-label="附件 OCR 识别明细"
|
||||
>
|
||||
<article
|
||||
v-for="(document, documentIndex) in resolveInlineAttachmentOcrDocuments(message)"
|
||||
:key="`${message.id}-ocr-${document.filename}-${documentIndex}`"
|
||||
class="workbench-ai-ocr-document"
|
||||
>
|
||||
<header class="workbench-ai-ocr-document__head">
|
||||
<strong>{{ document.filename }}</strong>
|
||||
<span>{{ document.fields.length }} 项</span>
|
||||
</header>
|
||||
<p v-if="document.summary" class="workbench-ai-ocr-document__summary">
|
||||
{{ document.summary }}
|
||||
</p>
|
||||
<div v-if="document.fields.length" class="workbench-ai-ocr-document__fields">
|
||||
<div
|
||||
v-for="field in document.fields"
|
||||
:key="`${message.id}-${document.filename}-${field.label}-${field.value}`"
|
||||
class="workbench-ai-ocr-document__field"
|
||||
>
|
||||
<span>{{ field.label }}</span>
|
||||
<strong>{{ field.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message.content"
|
||||
class="workbench-ai-answer-markdown"
|
||||
@click.capture="handleAiAnswerMarkdownClick($event)"
|
||||
v-html="renderInlineConversationHtml(message.content)"
|
||||
></div>
|
||||
|
||||
<Transition name="structured-card-reveal" appear>
|
||||
<div
|
||||
v-if="message.applicationPreview"
|
||||
class="workbench-ai-application-preview application-preview-shell"
|
||||
aria-label="申请信息核对结果"
|
||||
>
|
||||
<div
|
||||
class="application-preview-table"
|
||||
role="table"
|
||||
aria-label="申请信息核对表"
|
||||
>
|
||||
<div class="application-preview-row head" role="row">
|
||||
<span role="columnheader">字段</span>
|
||||
<span role="columnheader">内容</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in resolveInlineApplicationPreviewRows(message)"
|
||||
:key="`${message.id}-${row.key}`"
|
||||
class="application-preview-row"
|
||||
:class="{
|
||||
missing: row.missing,
|
||||
editable: row.editable,
|
||||
highlight: row.highlight,
|
||||
'is-disabled': isApplicationPreviewEstimatePending(message)
|
||||
}"
|
||||
role="row"
|
||||
:tabindex="row.editable && !isApplicationPreviewEstimatePending(message) ? 0 : -1"
|
||||
:aria-label="row.editable ? `编辑${row.label}` : row.label"
|
||||
@click.stop="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.enter.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.space.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
||||
<span class="application-preview-value" role="cell">
|
||||
<input
|
||||
v-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'text'"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input"
|
||||
type="text"
|
||||
autofocus
|
||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||
@click.stop
|
||||
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="commitInlineApplicationPreviewEditor(message)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input application-preview-select"
|
||||
autofocus
|
||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||
@click.stop
|
||||
@change="commitInlineApplicationPreviewEditor(message)"
|
||||
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="commitInlineApplicationPreviewEditor(message)"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
<option
|
||||
v-for="option in resolveApplicationPreviewEditorOptions(row.key)"
|
||||
:key="`${message.id}-${row.key}-${option}`"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<template v-else>
|
||||
<span class="application-preview-text">{{ row.value }}</span>
|
||||
<button
|
||||
v-if="row.editable"
|
||||
type="button"
|
||||
class="application-preview-edit-btn"
|
||||
title="修改内容"
|
||||
aria-label="修改内容"
|
||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
</button>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="resolveInlineApplicationPreviewMissingFields(message).length"
|
||||
class="application-preview-footer application-preview-footer-missing"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="application-preview-missing-prefix">当前还需要补充:</span>
|
||||
<span class="application-preview-missing-list">
|
||||
<template
|
||||
v-for="(field, index) in resolveInlineApplicationPreviewMissingFields(message)"
|
||||
:key="`${message.id}-missing-${field}`"
|
||||
>
|
||||
<span class="application-preview-missing-chip">{{ field }}</span>
|
||||
<span
|
||||
v-if="index < resolveInlineApplicationPreviewMissingFields(message).length - 1"
|
||||
class="application-preview-missing-separator"
|
||||
>、</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="application-preview-missing-suffix">。点击表格字段补齐后,费用测算会自动刷新。</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="buildInlineApplicationPreviewFooterText(message)"
|
||||
class="application-preview-footer workbench-ai-answer-markdown"
|
||||
v-html="renderInlineConversationHtml(buildInlineApplicationPreviewFooterText(message))"
|
||||
></div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="!message.content && !message.applicationPreview && message.pending && !hasInlineThinking(message)"
|
||||
class="workbench-ai-pending-line"
|
||||
>
|
||||
小财管家正在识别任务、拆解流程并准备下一步建议...
|
||||
</div>
|
||||
|
||||
<div v-if="canShowInlineSuggestedActions(message)" class="workbench-ai-suggested-actions">
|
||||
<button
|
||||
v-for="action in message.suggestedActions"
|
||||
:key="`${message.id}-${action.label}`"
|
||||
type="button"
|
||||
:disabled="isInlineSuggestedActionDisabled(action, message)"
|
||||
@click="handleInlineSuggestedAction(action, message)"
|
||||
>
|
||||
<i :class="action.icon || 'mdi mdi-arrow-right-circle-outline'"></i>
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!message.pending" class="workbench-ai-message-actions" aria-label="消息操作">
|
||||
<button type="button" title="复制" aria-label="复制" @click="copyInlineMessage(message)">
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
</button>
|
||||
<button type="button" title="有帮助" aria-label="有帮助" @click="markInlineMessageFeedback(message, 'up')">
|
||||
<i class="mdi mdi-thumb-up-outline"></i>
|
||||
</button>
|
||||
<button type="button" title="无帮助" aria-label="无帮助" @click="markInlineMessageFeedback(message, 'down')">
|
||||
<i class="mdi mdi-thumb-down-outline"></i>
|
||||
</button>
|
||||
<button type="button" title="重新生成" aria-label="重新生成" @click="regenerateLastReply">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
</button>
|
||||
<time class="workbench-ai-message-time">{{ formatMessageTime(message.createdAt) }}</time>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-conversation-bottom">
|
||||
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip inline" aria-label="已选择附件">
|
||||
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
|
||||
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
|
||||
<i :class="file.icon"></i>
|
||||
</span>
|
||||
<span class="workbench-ai-file-card__body">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<small>{{ file.typeLabel }}</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-file-card__remove"
|
||||
:disabled="isAiModeInputLocked"
|
||||
:aria-label="`移除附件 ${file.name}`"
|
||||
@click="removeAiModeFile(file.key)"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<form class="workbench-ai-composer workbench-ai-composer--inline" @submit.prevent="submitAiModePrompt">
|
||||
<div class="workbench-ai-composer-field">
|
||||
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
|
||||
<i class="mdi mdi-calendar-check-outline"></i>
|
||||
<span>{{ workbenchDateTagLabel }}</span>
|
||||
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="assistantInputRef"
|
||||
v-model="assistantDraft"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '继续和小财管家对话...'"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@keydown.enter.exact.prevent="submitAiModePrompt"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-toolbar">
|
||||
<div class="workbench-ai-tool-buttons">
|
||||
<div class="workbench-date-anchor workbench-ai-date-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
|
||||
title="选择日期"
|
||||
aria-label="选择日期"
|
||||
:aria-expanded="workbenchDatePickerOpen"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click.stop="toggleWorkbenchDatePicker"
|
||||
>
|
||||
<i class="mdi mdi-calendar-range"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="workbenchDatePickerOpen"
|
||||
class="workbench-ai-date-popover"
|
||||
role="dialog"
|
||||
aria-label="选择业务日期"
|
||||
@click.stop
|
||||
>
|
||||
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: workbenchDateMode === 'single' }"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="setWorkbenchDateMode('single')"
|
||||
>
|
||||
单日
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: workbenchDateMode === 'range' }"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="setWorkbenchDateMode('range')"
|
||||
>
|
||||
范围
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
||||
<span>业务日期</span>
|
||||
<input
|
||||
v-model="workbenchSingleDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('single')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-else class="workbench-ai-date-range">
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
v-model="workbenchRangeStartDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('range-start')"
|
||||
/>
|
||||
</label>
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
v-model="workbenchRangeEndDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('range-end')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-date-actions">
|
||||
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
|
||||
@click="applyWorkbenchDateSelection"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="上传附件"
|
||||
aria-label="上传附件"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="triggerAiModeFileUpload"
|
||||
>
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="语音输入"
|
||||
aria-label="语音输入"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="handleVoiceInput"
|
||||
>
|
||||
<i class="mdi mdi-microphone-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-right">
|
||||
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
|
||||
<span>{{ displayModelName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="workbench-ai-send-btn"
|
||||
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
|
||||
aria-label="发送给小财管家"
|
||||
>
|
||||
<i class="mdi mdi-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="workbench-ai-disclaimer">小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="workbench-ai-confirm-fade">
|
||||
<div v-if="deleteDialogOpen" class="workbench-ai-confirm-mask" role="presentation" @click.self="cancelDeleteConversation">
|
||||
<div
|
||||
class="workbench-ai-confirm-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workbench-ai-delete-title"
|
||||
>
|
||||
<h3 id="workbench-ai-delete-title">删除当前对话?</h3>
|
||||
<p>删除后,左侧最近对话中的这条记录也会被移除。这个操作无法恢复。</p>
|
||||
<div class="workbench-ai-confirm-actions">
|
||||
<button type="button" class="ghost" @click="cancelDeleteConversation">取消</button>
|
||||
<button type="button" class="danger" @click="confirmDeleteConversation">删除对话</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="workbench-ai-confirm-fade">
|
||||
<div
|
||||
v-if="applicationSubmitConfirmOpen"
|
||||
class="workbench-ai-confirm-mask"
|
||||
role="presentation"
|
||||
@click.self="cancelInlineApplicationSubmitConfirm"
|
||||
>
|
||||
<div
|
||||
class="workbench-ai-confirm-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workbench-ai-submit-confirm-title"
|
||||
>
|
||||
<h3 id="workbench-ai-submit-confirm-title">确认直接提交申请?</h3>
|
||||
<p>确认后系统会先查询你名下相同日期的申请单;若发现重复或重叠日期,会停止提交并列出已有单据供你查看。</p>
|
||||
<p>若核查通过,申请单会直接进入审批流程。</p>
|
||||
<div class="workbench-ai-confirm-actions">
|
||||
<button type="button" class="ghost" @click="cancelInlineApplicationSubmitConfirm">取消</button>
|
||||
<button type="button" class="primary" :disabled="sending" @click="confirmInlineApplicationSubmit">确认直接提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
File diff suppressed because it is too large
Load Diff
173
web/src/components/business/workbench-ai/WorkbenchAiComposer.vue
Normal file
173
web/src/components/business/workbench-ai/WorkbenchAiComposer.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<form
|
||||
class="workbench-ai-composer"
|
||||
:class="{ 'workbench-ai-composer--inline': inline }"
|
||||
@submit.prevent="runtime.submitAiModePrompt"
|
||||
>
|
||||
<div class="workbench-ai-composer-field">
|
||||
<div v-if="runtime.workbenchDateTagLabel" class="workbench-ai-date-chip">
|
||||
<i class="mdi mdi-calendar-check-outline"></i>
|
||||
<span>{{ runtime.workbenchDateTagLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="移除日期"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@click="runtime.removeWorkbenchDateTag"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
:ref="runtime.setAssistantInputRef"
|
||||
v-model="runtime.assistantDraft"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
:placeholder="runtime.isAiModeInputLocked ? '费用测算中,请稍等...' : placeholder"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@keydown.enter.exact.prevent="runtime.submitAiModePrompt"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-toolbar">
|
||||
<div class="workbench-ai-tool-buttons">
|
||||
<div class="workbench-date-anchor workbench-ai-date-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
:class="{ active: runtime.workbenchDatePickerOpen || runtime.workbenchDateTagLabel }"
|
||||
title="选择日期"
|
||||
aria-label="选择日期"
|
||||
:aria-expanded="runtime.workbenchDatePickerOpen"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@click.stop="runtime.toggleWorkbenchDatePicker"
|
||||
>
|
||||
<i class="mdi mdi-calendar-range"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="runtime.workbenchDatePickerOpen"
|
||||
class="workbench-ai-date-popover"
|
||||
role="dialog"
|
||||
aria-label="选择业务日期"
|
||||
@click.stop
|
||||
>
|
||||
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: runtime.workbenchDateMode === 'single' }"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@click="runtime.setWorkbenchDateMode('single')"
|
||||
>
|
||||
单日
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: runtime.workbenchDateMode === 'range' }"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@click="runtime.setWorkbenchDateMode('range')"
|
||||
>
|
||||
范围
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-if="runtime.workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
||||
<span>业务日期</span>
|
||||
<input
|
||||
v-model="runtime.workbenchSingleDate"
|
||||
type="date"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@change="runtime.handleWorkbenchDateInputChange('single')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-else class="workbench-ai-date-range">
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
v-model="runtime.workbenchRangeStartDate"
|
||||
type="date"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@change="runtime.handleWorkbenchDateInputChange('range-start')"
|
||||
/>
|
||||
</label>
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
v-model="runtime.workbenchRangeEndDate"
|
||||
type="date"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@change="runtime.handleWorkbenchDateInputChange('range-end')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-date-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ghost"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@click="runtime.clearWorkbenchDateSelection"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="!runtime.workbenchCanApplyDateSelection || runtime.isAiModeInputLocked"
|
||||
@click="runtime.applyWorkbenchDateSelection"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="上传附件"
|
||||
aria-label="上传附件"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@click="runtime.triggerAiModeFileUpload"
|
||||
>
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="语音输入"
|
||||
aria-label="语音输入"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@click="runtime.handleVoiceInput"
|
||||
>
|
||||
<i class="mdi mdi-microphone-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-right">
|
||||
<div class="workbench-ai-model-selector" :title="runtime.modelSelectorTitle">
|
||||
<span>{{ runtime.displayModelName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="workbench-ai-send-btn"
|
||||
:disabled="!runtime.canSubmitAiModePrompt || runtime.sending || runtime.isAiModeInputLocked"
|
||||
aria-label="发送给小财管家"
|
||||
>
|
||||
<i class="mdi mdi-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
inline: { type: Boolean, default: false },
|
||||
placeholder: { type: String, required: true },
|
||||
runtime: { type: Object, required: true }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="../../../assets/styles/components/personal-workbench-ai-mode.css"></style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="runtime.selectedFileCards.length"
|
||||
class="workbench-ai-file-strip"
|
||||
:class="{ inline }"
|
||||
aria-label="已选择附件"
|
||||
>
|
||||
<article v-for="file in runtime.selectedFileCards" :key="file.key" class="workbench-ai-file-card">
|
||||
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
|
||||
<i :class="file.icon"></i>
|
||||
</span>
|
||||
<span class="workbench-ai-file-card__body">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<small>{{ file.typeLabel }}</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-file-card__remove"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
:aria-label="`移除附件 ${file.name}`"
|
||||
@click="runtime.removeAiModeFile(file.key)"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
inline: { type: Boolean, default: false },
|
||||
runtime: { type: Object, required: true }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="../../../assets/styles/components/personal-workbench-ai-mode.css"></style>
|
||||
56
web/src/components/business/workbench-ai/WorkbenchAiHome.vue
Normal file
56
web/src/components/business/workbench-ai/WorkbenchAiHome.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="workbench-ai-shell workbench-ai-home">
|
||||
<div class="workbench-ai-orb" aria-hidden="true">
|
||||
<img
|
||||
:src="orbIcon"
|
||||
class="workbench-ai-orb__image"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-copy">
|
||||
<h2>嗨,{{ runtime.displayUserName }},我是您的小财管家</h2>
|
||||
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
|
||||
</div>
|
||||
|
||||
<WorkbenchAiComposer
|
||||
:runtime="runtime"
|
||||
placeholder="今天我能帮您做点什么?"
|
||||
/>
|
||||
<WorkbenchAiFileStrip :runtime="runtime" />
|
||||
|
||||
<div class="workbench-ai-quick-start-section">
|
||||
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
|
||||
<div class="workbench-ai-action-row" aria-label="推荐主题">
|
||||
<button
|
||||
v-for="item in runtime.aiModeActionItems"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="workbench-ai-action"
|
||||
:disabled="runtime.isAiModeInputLocked"
|
||||
@click="runtime.runAiModeAction(item)"
|
||||
>
|
||||
<div class="action-icon-wrapper">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div class="action-text">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<p>{{ item.prompt }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import orbIcon from '../../../assets/workbench-ai-mode-orb-icon.gif'
|
||||
import WorkbenchAiComposer from './WorkbenchAiComposer.vue'
|
||||
import WorkbenchAiFileStrip from './WorkbenchAiFileStrip.vue'
|
||||
|
||||
defineProps({
|
||||
runtime: { type: Object, required: true }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="../../../assets/styles/components/personal-workbench-ai-mode.css"></style>
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
|
||||
<div v-if="!isWorkbenchAiHome" class="title-group">
|
||||
<div class="eyebrow">{{ eyebrowLabel }}</div>
|
||||
@@ -377,9 +377,19 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
||||
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
|
||||
import { useTopBarWorkbenchPopovers } from '../../composables/useTopBarWorkbenchPopovers.js'
|
||||
import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js'
|
||||
import { createCurrentYearDateRange } from '../../utils/dateRangeDefaults.js'
|
||||
import { resolveDocumentNotificationId } from '../../utils/documentCenterNewState.js'
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
import {
|
||||
APPROVAL_KPIS,
|
||||
CHAT_KPIS,
|
||||
buildDigitalEmployeeWorkRecordKpis,
|
||||
buildDocumentKpis,
|
||||
buildEmployeeKpis,
|
||||
buildKnowledgeKpis,
|
||||
buildRequestKpis
|
||||
} from './topBarKpis.js'
|
||||
import { useTopBarOverviewRange } from './useTopBarOverviewRange.js'
|
||||
|
||||
const props = defineProps({
|
||||
currentView: { type: Object, required: true },
|
||||
@@ -688,191 +698,37 @@ function openNotification(item) {
|
||||
}
|
||||
}
|
||||
|
||||
const requestKpis = computed(() => {
|
||||
const summary = props.requestSummary ?? {}
|
||||
const total = Number(summary.total ?? 0)
|
||||
const draft = Number(summary.draft ?? 0)
|
||||
const inProgress = Number(summary.inProgress ?? 0)
|
||||
const completed = Number(summary.completed ?? 0)
|
||||
|
||||
return [
|
||||
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
|
||||
{ label: '草稿', value: draft, delta: '待提交', trend: draft > 0 ? 'down' : 'up', arrow: draft > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
|
||||
{ label: '审批中', value: inProgress, delta: '处理中', trend: inProgress > 0 ? 'up' : 'down', arrow: inProgress > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', color: '#3b82f6' },
|
||||
{ label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' }
|
||||
]
|
||||
})
|
||||
|
||||
const documentKpis = computed(() => {
|
||||
const summary = props.documentSummary ?? {}
|
||||
const total = Number(summary.total ?? 0)
|
||||
const toSubmit = Number(summary.toSubmit ?? 0)
|
||||
const toProcess = Number(summary.toProcess ?? 0)
|
||||
const archived = Number(summary.archived ?? 0)
|
||||
|
||||
return [
|
||||
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
|
||||
{ label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
|
||||
{ label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
|
||||
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' }
|
||||
]
|
||||
})
|
||||
const requestKpis = computed(() => buildRequestKpis(props.requestSummary ?? {}))
|
||||
const documentKpis = computed(() => buildDocumentKpis(props.documentSummary ?? {}))
|
||||
|
||||
const showDigitalEmployeeWorkRecordKpis = computed(() => {
|
||||
const summary = props.digitalEmployeeSummary ?? {}
|
||||
return isDigitalEmployees.value && summary.section === 'workRecords'
|
||||
})
|
||||
|
||||
const digitalEmployeeWorkRecordKpis = computed(() => {
|
||||
const summary = props.digitalEmployeeSummary ?? {}
|
||||
const total = Number(summary.total ?? 0)
|
||||
const succeeded = Number(summary.succeeded ?? 0)
|
||||
const failed = Number(summary.failed ?? 0)
|
||||
const digitalEmployeeWorkRecordKpis = computed(() => buildDigitalEmployeeWorkRecordKpis(props.digitalEmployeeSummary ?? {}))
|
||||
|
||||
return [
|
||||
{
|
||||
label: '日志总数',
|
||||
value: total,
|
||||
delta: '当前',
|
||||
trend: 'up',
|
||||
arrow: 'mdi mdi-minus',
|
||||
color: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '成功数量',
|
||||
value: succeeded,
|
||||
delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据',
|
||||
trend: 'up',
|
||||
arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus',
|
||||
color: 'var(--success)'
|
||||
},
|
||||
{
|
||||
label: '失败数量',
|
||||
value: failed,
|
||||
delta: failed > 0 ? '需要关注' : '暂无失败',
|
||||
trend: failed > 0 ? 'down' : 'up',
|
||||
arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus',
|
||||
color: '#ef4444'
|
||||
}
|
||||
]
|
||||
})
|
||||
const chatKpis = CHAT_KPIS
|
||||
|
||||
const chatKpis = [
|
||||
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
|
||||
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
|
||||
{ label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },
|
||||
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
|
||||
]
|
||||
const approvalKpis = APPROVAL_KPIS
|
||||
|
||||
const approvalKpis = [
|
||||
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
|
||||
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
|
||||
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
|
||||
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
|
||||
]
|
||||
const knowledgeKpis = computed(() => buildKnowledgeKpis(props.knowledgeSummary ?? {}))
|
||||
|
||||
const knowledgeKpis = computed(() => {
|
||||
const summary = props.knowledgeSummary ?? {}
|
||||
const totalDocuments = Number(summary.totalDocuments ?? 0)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '文档总数',
|
||||
value: String(totalDocuments),
|
||||
meta: '',
|
||||
trend: 'up',
|
||||
color: 'var(--theme-primary)'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const employeeKpis = computed(() => {
|
||||
const summary = props.employeeSummary ?? {}
|
||||
const total = Number(summary.total ?? 0)
|
||||
const active = Number(summary.active ?? 0)
|
||||
const onboarding = Number(summary.onboarding ?? 0)
|
||||
const disabled = Number(summary.disabled ?? 0)
|
||||
const followUp = Number(summary.followUp ?? 0)
|
||||
const departments = Number(summary.departments ?? 0)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '员工总数',
|
||||
value: total,
|
||||
unit: '人',
|
||||
meta: `覆盖 ${departments} 个部门`,
|
||||
trend: 'up',
|
||||
color: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '在职账号',
|
||||
value: active,
|
||||
unit: '人',
|
||||
meta: total ? `占比 ${Math.round((active / total) * 100)}%` : '等待数据',
|
||||
trend: 'up',
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
label: '待处理状态',
|
||||
value: onboarding + disabled,
|
||||
unit: '人',
|
||||
meta: `试用 ${onboarding} / 停用 ${disabled}`,
|
||||
trend: onboarding + disabled > 0 ? 'down' : 'up',
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
label: '同步待处理',
|
||||
value: followUp,
|
||||
unit: '人',
|
||||
meta: followUp > 0 ? '存在待同步账号' : '资料已同步',
|
||||
trend: followUp > 0 ? 'down' : 'up',
|
||||
color: '#8b5cf6'
|
||||
}
|
||||
]
|
||||
})
|
||||
const calendarOpen = ref(false)
|
||||
const draftStart = ref(props.customRange.start)
|
||||
const draftEnd = ref(props.customRange.end)
|
||||
const overviewDashboardOptions = [
|
||||
{ label: '财务看板', value: 'finance' },
|
||||
{ label: '风险看板', value: 'risk' },
|
||||
{ label: '数字员工看板', value: 'digitalEmployee' },
|
||||
{ label: '系统看板', value: 'system' }
|
||||
]
|
||||
const overviewDashboardValue = computed({
|
||||
get: () => props.overviewDashboard,
|
||||
set: (value) => emit('update:overviewDashboard', value)
|
||||
})
|
||||
|
||||
const rangeOptions = computed(() =>
|
||||
props.ranges.map((range, index) => ({
|
||||
value: range,
|
||||
label: String(range)
|
||||
}))
|
||||
)
|
||||
|
||||
const activeOption = computed(() =>
|
||||
rangeOptions.value.find((option) => option.value === props.activeRange) ?? rangeOptions.value[0]
|
||||
)
|
||||
|
||||
const isCustomRange = computed(() => props.activeRange === 'custom')
|
||||
const activeDateLabel = computed(() => {
|
||||
if (isCustomRange.value) return formatRangeLabel(props.customRange.start, props.customRange.end)
|
||||
return buildPresetRangeLabel(activeOption.value?.label)
|
||||
})
|
||||
|
||||
const canApplyCustomRange = computed(() =>
|
||||
Boolean(draftStart.value && draftEnd.value && draftStart.value <= draftEnd.value)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.customRange,
|
||||
(range) => {
|
||||
draftStart.value = range.start
|
||||
draftEnd.value = range.end
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
const employeeKpis = computed(() => buildEmployeeKpis(props.employeeSummary ?? {}))
|
||||
const {
|
||||
calendarOpen,
|
||||
draftStart,
|
||||
draftEnd,
|
||||
overviewDashboardOptions,
|
||||
overviewDashboardValue,
|
||||
rangeOptions,
|
||||
activeOption,
|
||||
isCustomRange,
|
||||
activeDateLabel,
|
||||
canApplyCustomRange,
|
||||
setRange,
|
||||
applyCustomRange
|
||||
} = useTopBarOverviewRange(props, emit)
|
||||
|
||||
watch(
|
||||
() => props.activeView,
|
||||
@@ -906,53 +762,6 @@ onBeforeUnmount(() => {
|
||||
stopDocumentInboxPolling()
|
||||
})
|
||||
|
||||
function setRange(range) {
|
||||
emit('update:activeRange', range)
|
||||
calendarOpen.value = false
|
||||
}
|
||||
|
||||
function applyCustomRange() {
|
||||
if (!canApplyCustomRange.value) return
|
||||
emit('update:customRange', { start: draftStart.value, end: draftEnd.value })
|
||||
emit('update:activeRange', 'custom')
|
||||
calendarOpen.value = false
|
||||
}
|
||||
|
||||
function formatRangeLabel(start, end) {
|
||||
if (!start || !end) return '选择时间段'
|
||||
if (start === end) return start
|
||||
return `${start} ~ ${end}`
|
||||
}
|
||||
|
||||
function buildPresetRangeLabel(label) {
|
||||
const now = new Date()
|
||||
const today = formatDateValue(now)
|
||||
|
||||
if (label === '今日') {
|
||||
return today
|
||||
}
|
||||
|
||||
if (label === '近10日') {
|
||||
const start = new Date(now)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
start.setDate(start.getDate() - 9)
|
||||
return `${formatDateValue(start)} ~ ${today}`
|
||||
}
|
||||
|
||||
if (label === '本周') {
|
||||
const start = new Date(now)
|
||||
const day = start.getDay() || 7
|
||||
start.setHours(0, 0, 0, 0)
|
||||
start.setDate(start.getDate() - day + 1)
|
||||
return `${formatDateValue(start)} ~ ${today}`
|
||||
}
|
||||
|
||||
if (label === '本月') {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return today
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/top-bar.css"></style>
|
||||
|
||||
132
web/src/components/layout/topBarKpis.js
Normal file
132
web/src/components/layout/topBarKpis.js
Normal file
@@ -0,0 +1,132 @@
|
||||
export const CHAT_KPIS = [
|
||||
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
|
||||
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
|
||||
{ label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },
|
||||
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
|
||||
]
|
||||
|
||||
export const APPROVAL_KPIS = [
|
||||
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
|
||||
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
|
||||
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
|
||||
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
|
||||
]
|
||||
|
||||
export function buildRequestKpis(summary = {}) {
|
||||
const total = Number(summary.total ?? 0)
|
||||
const draft = Number(summary.draft ?? 0)
|
||||
const inProgress = Number(summary.inProgress ?? 0)
|
||||
const completed = Number(summary.completed ?? 0)
|
||||
|
||||
return [
|
||||
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
|
||||
{ label: '草稿', value: draft, delta: '待提交', trend: draft > 0 ? 'down' : 'up', arrow: draft > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
|
||||
{ label: '审批中', value: inProgress, delta: '处理中', trend: inProgress > 0 ? 'up' : 'down', arrow: inProgress > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', color: '#3b82f6' },
|
||||
{ label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' }
|
||||
]
|
||||
}
|
||||
|
||||
export function buildDocumentKpis(summary = {}) {
|
||||
const total = Number(summary.total ?? 0)
|
||||
const toSubmit = Number(summary.toSubmit ?? 0)
|
||||
const toProcess = Number(summary.toProcess ?? 0)
|
||||
const archived = Number(summary.archived ?? 0)
|
||||
|
||||
return [
|
||||
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
|
||||
{ label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
|
||||
{ label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
|
||||
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' }
|
||||
]
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeWorkRecordKpis(summary = {}) {
|
||||
const total = Number(summary.total ?? 0)
|
||||
const succeeded = Number(summary.succeeded ?? 0)
|
||||
const failed = Number(summary.failed ?? 0)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '日志总数',
|
||||
value: total,
|
||||
delta: '当前',
|
||||
trend: 'up',
|
||||
arrow: 'mdi mdi-minus',
|
||||
color: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '成功数量',
|
||||
value: succeeded,
|
||||
delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据',
|
||||
trend: 'up',
|
||||
arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus',
|
||||
color: 'var(--success)'
|
||||
},
|
||||
{
|
||||
label: '失败数量',
|
||||
value: failed,
|
||||
delta: failed > 0 ? '需要关注' : '暂无失败',
|
||||
trend: failed > 0 ? 'down' : 'up',
|
||||
arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus',
|
||||
color: '#ef4444'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildKnowledgeKpis(summary = {}) {
|
||||
const totalDocuments = Number(summary.totalDocuments ?? 0)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '文档总数',
|
||||
value: String(totalDocuments),
|
||||
meta: '',
|
||||
trend: 'up',
|
||||
color: 'var(--theme-primary)'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildEmployeeKpis(summary = {}) {
|
||||
const total = Number(summary.total ?? 0)
|
||||
const active = Number(summary.active ?? 0)
|
||||
const onboarding = Number(summary.onboarding ?? 0)
|
||||
const disabled = Number(summary.disabled ?? 0)
|
||||
const followUp = Number(summary.followUp ?? 0)
|
||||
const departments = Number(summary.departments ?? 0)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '员工总数',
|
||||
value: total,
|
||||
unit: '人',
|
||||
meta: `覆盖 ${departments} 个部门`,
|
||||
trend: 'up',
|
||||
color: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '在职账号',
|
||||
value: active,
|
||||
unit: '人',
|
||||
meta: total ? `占比 ${Math.round((active / total) * 100)}%` : '等待数据',
|
||||
trend: 'up',
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
label: '待处理状态',
|
||||
value: onboarding + disabled,
|
||||
unit: '人',
|
||||
meta: `试用 ${onboarding} / 停用 ${disabled}`,
|
||||
trend: onboarding + disabled > 0 ? 'down' : 'up',
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
label: '同步待处理',
|
||||
value: followUp,
|
||||
unit: '人',
|
||||
meta: followUp > 0 ? '存在待同步账号' : '资料已同步',
|
||||
trend: followUp > 0 ? 'down' : 'up',
|
||||
color: '#8b5cf6'
|
||||
}
|
||||
]
|
||||
}
|
||||
114
web/src/components/layout/useTopBarOverviewRange.js
Normal file
114
web/src/components/layout/useTopBarOverviewRange.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { formatDateValue } from '../../utils/dateRangeDefaults.js'
|
||||
|
||||
const OVERVIEW_DASHBOARD_OPTIONS = [
|
||||
{ label: '财务看板', value: 'finance' },
|
||||
{ label: '风险看板', value: 'risk' },
|
||||
{ label: '数字员工看板', value: 'digitalEmployee' },
|
||||
{ label: '系统看板', value: 'system' }
|
||||
]
|
||||
|
||||
export function useTopBarOverviewRange(props, emit) {
|
||||
const calendarOpen = ref(false)
|
||||
const draftStart = ref(props.customRange.start)
|
||||
const draftEnd = ref(props.customRange.end)
|
||||
const overviewDashboardOptions = OVERVIEW_DASHBOARD_OPTIONS
|
||||
const overviewDashboardValue = computed({
|
||||
get: () => props.overviewDashboard,
|
||||
set: (value) => emit('update:overviewDashboard', value)
|
||||
})
|
||||
|
||||
const rangeOptions = computed(() =>
|
||||
props.ranges.map((range) => ({
|
||||
value: range,
|
||||
label: String(range)
|
||||
}))
|
||||
)
|
||||
|
||||
const activeOption = computed(() =>
|
||||
rangeOptions.value.find((option) => option.value === props.activeRange) ?? rangeOptions.value[0]
|
||||
)
|
||||
|
||||
const isCustomRange = computed(() => props.activeRange === 'custom')
|
||||
const activeDateLabel = computed(() => {
|
||||
if (isCustomRange.value) return formatRangeLabel(props.customRange.start, props.customRange.end)
|
||||
return buildPresetRangeLabel(activeOption.value?.label)
|
||||
})
|
||||
|
||||
const canApplyCustomRange = computed(() =>
|
||||
Boolean(draftStart.value && draftEnd.value && draftStart.value <= draftEnd.value)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.customRange,
|
||||
(range) => {
|
||||
draftStart.value = range.start
|
||||
draftEnd.value = range.end
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function setRange(range) {
|
||||
emit('update:activeRange', range)
|
||||
calendarOpen.value = false
|
||||
}
|
||||
|
||||
function applyCustomRange() {
|
||||
if (!canApplyCustomRange.value) return
|
||||
emit('update:customRange', { start: draftStart.value, end: draftEnd.value })
|
||||
emit('update:activeRange', 'custom')
|
||||
calendarOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
calendarOpen,
|
||||
draftStart,
|
||||
draftEnd,
|
||||
overviewDashboardOptions,
|
||||
overviewDashboardValue,
|
||||
rangeOptions,
|
||||
activeOption,
|
||||
isCustomRange,
|
||||
activeDateLabel,
|
||||
canApplyCustomRange,
|
||||
setRange,
|
||||
applyCustomRange
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeLabel(start, end) {
|
||||
if (!start || !end) return '选择时间段'
|
||||
if (start === end) return start
|
||||
return `${start} ~ ${end}`
|
||||
}
|
||||
|
||||
function buildPresetRangeLabel(label) {
|
||||
const now = new Date()
|
||||
const today = formatDateValue(now)
|
||||
|
||||
if (label === '今日') {
|
||||
return today
|
||||
}
|
||||
|
||||
if (label === '近10日') {
|
||||
const start = new Date(now)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
start.setDate(start.getDate() - 9)
|
||||
return `${formatDateValue(start)} ~ ${today}`
|
||||
}
|
||||
|
||||
if (label === '本周') {
|
||||
const start = new Date(now)
|
||||
const day = start.getDay() || 7
|
||||
start.setHours(0, 0, 0, 0)
|
||||
start.setDate(start.getDate() - day + 1)
|
||||
return `${formatDateValue(start)} ~ ${today}`
|
||||
}
|
||||
|
||||
if (label === '本月') {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return today
|
||||
}
|
||||
@@ -332,9 +332,13 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
createId,
|
||||
documentHasMeaningfulText,
|
||||
formatFileSize,
|
||||
formatTestError,
|
||||
formatTime
|
||||
formatTime,
|
||||
mergeRecognizedDocuments,
|
||||
normalizeOcrDocuments,
|
||||
toAttachmentPayload
|
||||
} from './riskRuleTestDialogUtils.js'
|
||||
import {
|
||||
buildDocumentBrief,
|
||||
@@ -716,70 +720,6 @@ function buildTraceItems(result) {
|
||||
return buildTraceItemsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function toAttachmentPayload(file) {
|
||||
const document = file.ocrDocument || {}
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
content_type: file.contentType,
|
||||
note: file.error || '',
|
||||
recognition_status: file.status,
|
||||
ocr_text: document.text || '',
|
||||
summary: document.summary || '',
|
||||
document_type: document.document_type || '',
|
||||
document_type_label: document.document_type_label || '',
|
||||
scene_code: document.scene_code || '',
|
||||
scene_label: document.scene_label || '',
|
||||
avg_score: document.avg_score || 0,
|
||||
document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOcrDocuments(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
summary: String(item?.summary || '').trim(),
|
||||
text: String(item?.text || '').trim(),
|
||||
avg_score: Number(item?.avg_score || 0),
|
||||
document_type: String(item?.document_type || 'other').trim() || 'other',
|
||||
document_type_label: String(item?.document_type_label || '').trim(),
|
||||
scene_code: String(item?.scene_code || 'other').trim() || 'other',
|
||||
scene_label: String(item?.scene_label || '').trim(),
|
||||
document_fields: Array.isArray(item?.document_fields)
|
||||
? item.document_fields
|
||||
.map((field) => ({
|
||||
key: String(field?.key || '').trim(),
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.key && field.label && field.value)
|
||||
: [],
|
||||
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
||||
}))
|
||||
}
|
||||
|
||||
function mergeRecognizedDocuments(current, incoming) {
|
||||
const next = [...current]
|
||||
incoming.forEach((document) => {
|
||||
const index = next.findIndex((item) => item.filename === document.filename)
|
||||
if (index >= 0) {
|
||||
next.splice(index, 1, document)
|
||||
} else {
|
||||
next.push(document)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
function documentHasMeaningfulText(document) {
|
||||
return Boolean(
|
||||
String(document?.text || document?.summary || '').trim() ||
|
||||
(Array.isArray(document?.document_fields) && document.document_fields.length)
|
||||
)
|
||||
}
|
||||
|
||||
function buildRecognitionStepDescription() {
|
||||
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
|
||||
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'
|
||||
@@ -839,4 +779,3 @@ function isActiveSession(activeSessionId) {
|
||||
</script>
|
||||
|
||||
<style src="../../assets/styles/components/risk-rule-test-dialog.css"></style>
|
||||
|
||||
|
||||
@@ -26,3 +26,67 @@ export function formatTime() {
|
||||
minute: '2-digit'
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
export function toAttachmentPayload(file) {
|
||||
const document = file.ocrDocument || {}
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
content_type: file.contentType,
|
||||
note: file.error || '',
|
||||
recognition_status: file.status,
|
||||
ocr_text: document.text || '',
|
||||
summary: document.summary || '',
|
||||
document_type: document.document_type || '',
|
||||
document_type_label: document.document_type_label || '',
|
||||
scene_code: document.scene_code || '',
|
||||
scene_label: document.scene_label || '',
|
||||
avg_score: document.avg_score || 0,
|
||||
document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeOcrDocuments(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
summary: String(item?.summary || '').trim(),
|
||||
text: String(item?.text || '').trim(),
|
||||
avg_score: Number(item?.avg_score || 0),
|
||||
document_type: String(item?.document_type || 'other').trim() || 'other',
|
||||
document_type_label: String(item?.document_type_label || '').trim(),
|
||||
scene_code: String(item?.scene_code || 'other').trim() || 'other',
|
||||
scene_label: String(item?.scene_label || '').trim(),
|
||||
document_fields: Array.isArray(item?.document_fields)
|
||||
? item.document_fields
|
||||
.map((field) => ({
|
||||
key: String(field?.key || '').trim(),
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.key && field.label && field.value)
|
||||
: [],
|
||||
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
||||
}))
|
||||
}
|
||||
|
||||
export function mergeRecognizedDocuments(current, incoming) {
|
||||
const next = [...current]
|
||||
incoming.forEach((document) => {
|
||||
const index = next.findIndex((item) => item.filename === document.filename)
|
||||
if (index >= 0) {
|
||||
next.splice(index, 1, document)
|
||||
} else {
|
||||
next.push(document)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
export function documentHasMeaningfulText(document) {
|
||||
return Boolean(
|
||||
String(document?.text || document?.summary || '').trim() ||
|
||||
(Array.isArray(document?.document_fields) && document.document_fields.length)
|
||||
)
|
||||
}
|
||||
|
||||
57
web/src/components/travel/TravelRequestDetailHero.vue
Normal file
57
web/src/components/travel/TravelRequestDetailHero.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<article class="detail-hero panel">
|
||||
<div class="hero-banner">
|
||||
<div class="hero-banner-main">
|
||||
<div class="applicant-card">
|
||||
<div class="portrait">
|
||||
<img src="/assets/person.png" alt="" />
|
||||
</div>
|
||||
<div class="applicant-copy">
|
||||
<div class="applicant-name-row">
|
||||
<h2>{{ profile.name }}</h2>
|
||||
<span class="identity-badge">{{ profile.identity }}</span>
|
||||
</div>
|
||||
<div class="applicant-profile-meta">
|
||||
<div class="applicant-profile-meta__org">
|
||||
<span class="applicant-meta-item">
|
||||
<em>部门</em>
|
||||
<strong>{{ profile.department }}</strong>
|
||||
</span>
|
||||
<span class="applicant-meta-item applicant-meta-item--sub">
|
||||
<em>直属上司</em>
|
||||
<strong>{{ profile.manager }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="applicant-profile-meta__role">
|
||||
<span class="applicant-meta-item">
|
||||
<em>职级</em>
|
||||
<strong>{{ profile.grade }}</strong>
|
||||
</span>
|
||||
<span class="applicant-meta-item">
|
||||
<em>岗位</em>
|
||||
<strong>{{ profile.position }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-fact-grid">
|
||||
<div v-for="item in heroFactItems" :key="item.key" class="hero-fact">
|
||||
<div class="hero-fact-label">
|
||||
<i v-if="item.icon" :class="item.icon"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<strong :class="item.valueClass">{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
profile: { type: Object, required: true },
|
||||
heroFactItems: { type: Array, default: () => [] }
|
||||
})
|
||||
</script>
|
||||
43
web/src/components/travel/TravelRequestProgressCard.vue
Normal file
43
web/src/components/travel/TravelRequestProgressCard.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<article class="progress-card panel">
|
||||
<div class="progress-block">
|
||||
<div class="progress-head">
|
||||
<h3>{{ isApplicationDocument ? '申请进度' : '报销进度' }}</h3>
|
||||
</div>
|
||||
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
|
||||
<div
|
||||
v-for="step in progressSteps"
|
||||
:key="step.label"
|
||||
class="progress-step"
|
||||
:class="{ active: step.active, current: step.current, done: step.done }"
|
||||
>
|
||||
<span>
|
||||
<i
|
||||
v-if="step.current"
|
||||
v-motion
|
||||
class="current-progress-ring"
|
||||
:initial="currentProgressRingMotion.initial"
|
||||
:enter="currentProgressRingMotion.enter"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<i v-if="step.done" class="mdi mdi-check"></i>
|
||||
<template v-else>{{ step.index }}</template>
|
||||
</span>
|
||||
<div class="progress-step-copy" :title="step.title || step.detail || step.time">
|
||||
<strong>{{ step.label }}</strong>
|
||||
<small class="progress-step-status">{{ step.time }}</small>
|
||||
<em v-if="step.detail" class="progress-step-meta">{{ step.detail }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
isApplicationDocument: { type: Boolean, default: false },
|
||||
progressSteps: { type: Array, default: () => [] },
|
||||
currentProgressRingMotion: { type: Object, required: true }
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<article v-if="!isApplicationDocument" class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
<h3>关联单据信息</h3>
|
||||
<p>展示本次报销关联的前置申请,便于核对申请内容、天数、事由和预计金额。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="relatedApplicationFactItems.length" class="application-detail-facts related-application-facts">
|
||||
<div
|
||||
v-for="item in relatedApplicationFactItems"
|
||||
:key="item.key"
|
||||
class="application-detail-fact related-application-fact"
|
||||
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="related-application-empty">
|
||||
<strong>暂未识别到关联申请单</strong>
|
||||
<p>差旅报销应先关联已审批的申请单,请核对本单据是否由申请单生成或已在智能录入中完成关联。</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
isApplicationDocument: { type: Boolean, default: false },
|
||||
relatedApplicationFactItems: { type: Array, default: () => [] }
|
||||
})
|
||||
</script>
|
||||
199
web/src/composables/overviewViewDisplayModel.js
Normal file
199
web/src/composables/overviewViewDisplayModel.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import { formatRiskSignalLabel } from '../utils/riskLabels.js'
|
||||
|
||||
export const emptyFinanceTotals = {
|
||||
reimbursementAmount: 0,
|
||||
reimbursementCount: 0,
|
||||
pendingPaymentAmount: 0,
|
||||
avgClaimAmount: 0,
|
||||
budgetUsageRate: 0,
|
||||
paymentClearanceRate: 0
|
||||
}
|
||||
|
||||
export const emptyFinanceTrend = {
|
||||
labels: [],
|
||||
claimCount: [],
|
||||
claimAmount: [],
|
||||
categoryAmountSeries: [],
|
||||
applications: [],
|
||||
approved: [],
|
||||
avgHours: []
|
||||
}
|
||||
|
||||
export const emptyFinanceDonut = [
|
||||
{ name: '暂无数据', value: 0, color: '#cbd5e1' }
|
||||
]
|
||||
|
||||
export const emptyFinanceBudgetSummary = {
|
||||
ratio: 0,
|
||||
total: '¥0',
|
||||
used: '¥0',
|
||||
left: '¥0'
|
||||
}
|
||||
|
||||
export const emptyFinanceBudgetMetrics = [
|
||||
{ label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
|
||||
{ label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
|
||||
{ label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
|
||||
{ label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
|
||||
{ label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
|
||||
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
|
||||
]
|
||||
|
||||
export const emptySystemDashboardTotals = {
|
||||
toolCalls: 0,
|
||||
modelTokens: 0,
|
||||
onlineUsers: 0,
|
||||
avgOnlineMinutes: 0,
|
||||
executionSuccessRate: 0,
|
||||
positiveFeedback: 0,
|
||||
negativeFeedback: 0,
|
||||
failedRuns: 0,
|
||||
toolCallsChange: 0,
|
||||
modelTokensChange: 0
|
||||
}
|
||||
|
||||
export const emptySystemLoginWave = {
|
||||
labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
|
||||
loginUsers: Array.from({ length: 24 }, () => 0),
|
||||
interactions: Array.from({ length: 24 }, () => 0)
|
||||
}
|
||||
|
||||
export function formatCompact(value) {
|
||||
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
|
||||
return `¥${value}`
|
||||
}
|
||||
|
||||
export function formatCurrency(value) {
|
||||
return formatCompact(value)
|
||||
}
|
||||
|
||||
export function formatNumberCompact(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
|
||||
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
|
||||
return `${Math.round(number)}`
|
||||
}
|
||||
|
||||
export function formatPercent(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`
|
||||
}
|
||||
|
||||
export function formatMetricValue(metric, value) {
|
||||
if (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
|
||||
return formatCurrency(Math.round(value))
|
||||
}
|
||||
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
|
||||
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
export function formatSystemMetricValue(metric, value, totals = emptySystemDashboardTotals) {
|
||||
const numericValue = Number(value || 0)
|
||||
if (metric.key === 'modelTokens') return formatNumberCompact(numericValue)
|
||||
if (metric.key === 'avgOnlineMinutes') return `${numericValue.toFixed(1)} ${metric.unit}`
|
||||
if (metric.key === 'executionSuccessRate') return `${numericValue.toFixed(1)}${metric.unit}`
|
||||
if (metric.key === 'positiveFeedback') {
|
||||
const negativeFeedback = Math.round(Number(totals.negativeFeedback || 0))
|
||||
return `${Math.round(numericValue)} / ${negativeFeedback}`
|
||||
}
|
||||
if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
|
||||
return formatNumberCompact(numericValue)
|
||||
}
|
||||
|
||||
export function resolveSystemMetricMeta(metric, totals = emptySystemDashboardTotals, realDashboardLoaded = false) {
|
||||
if (!realDashboardLoaded) {
|
||||
return {
|
||||
changeText: metric.change,
|
||||
delta: metric.delta,
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'toolCalls' || metric.key === 'modelTokens') {
|
||||
const changeValue = Number(totals[`${metric.key}Change`] || 0)
|
||||
return {
|
||||
changeText: `${changeValue >= 0 ? '+' : ''}${changeValue.toFixed(1)}%`,
|
||||
delta: '较上一周期',
|
||||
trend: changeValue < 0 ? 'down' : 'up'
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'executionSuccessRate') {
|
||||
const errorRate = Math.max(0, 100 - Number(totals.executionSuccessRate || 0))
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: `错误率 ${errorRate.toFixed(1)}%`,
|
||||
trend: 'up'
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'positiveFeedback') {
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: `差评 ${Math.round(Number(totals.negativeFeedback || 0))} 次`,
|
||||
trend: 'up'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: metric.key === 'onlineUsers' ? '活跃会话' : '按最近会话统计',
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFinanceMetricMeta({
|
||||
metric,
|
||||
meta,
|
||||
dashboardLoaded,
|
||||
loading,
|
||||
error
|
||||
}) {
|
||||
if (!dashboardLoaded || !meta) {
|
||||
return {
|
||||
changeText: loading ? '加载中' : '实时',
|
||||
delta: error ? '真实数据加载失败' : '等待真实数据',
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeText: meta.changeText || metric.change,
|
||||
delta: meta.delta || metric.delta,
|
||||
trend: meta.trend || metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
|
||||
const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
|
||||
const entries = Object.entries(distribution || {})
|
||||
.filter(([, value]) => Number(value || 0) > 0)
|
||||
|
||||
if (!entries.length) {
|
||||
return [
|
||||
{
|
||||
name: '暂无数据',
|
||||
value: 1,
|
||||
display: '0项',
|
||||
color: '#cbd5e1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return entries.map(([key, value], index) => ({
|
||||
name: labels[key] || formatter(key),
|
||||
value: Number(value || 0),
|
||||
display: `${Number(value || 0)}项`,
|
||||
color: colors[key] || fallbackColors[index % fallbackColors.length]
|
||||
}))
|
||||
}
|
||||
|
||||
export function formatRiskSignalName(value) {
|
||||
return formatRiskSignalLabel(value)
|
||||
}
|
||||
|
||||
export function isMissingDimension(value) {
|
||||
const text = String(value || '').trim()
|
||||
return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
|
||||
}
|
||||
122
web/src/composables/overviewViewRangeModel.js
Normal file
122
web/src/composables/overviewViewRangeModel.js
Normal file
@@ -0,0 +1,122 @@
|
||||
export const DEFAULT_OVERVIEW_RANGE = '近10日'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const RISK_DAILY_TREND_MAX_BUCKETS = 14
|
||||
|
||||
function parseLocalDate(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function clampWindowDays(value) {
|
||||
const days = Number(value || 0)
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
return 10
|
||||
}
|
||||
return Math.max(1, Math.min(Math.round(days), 90))
|
||||
}
|
||||
|
||||
function resolveCustomRangeDays(customRange = {}) {
|
||||
const start = parseLocalDate(customRange.start)
|
||||
const end = parseLocalDate(customRange.end)
|
||||
if (!start || !end) {
|
||||
return 10
|
||||
}
|
||||
return clampWindowDays(Math.abs(end.getTime() - start.getTime()) / DAY_MS + 1)
|
||||
}
|
||||
|
||||
export function resolveTopRangeDays(range, customRange = {}) {
|
||||
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
|
||||
if (key === 'custom') {
|
||||
return resolveCustomRangeDays(customRange)
|
||||
}
|
||||
if (key === '\u4eca\u65e5') {
|
||||
return 1
|
||||
}
|
||||
if (key === '\u672c\u5468') {
|
||||
const today = new Date()
|
||||
const weekday = today.getDay() || 7
|
||||
return clampWindowDays(weekday)
|
||||
}
|
||||
if (key === '\u672c\u6708') {
|
||||
return clampWindowDays(new Date().getDate())
|
||||
}
|
||||
const match = key.match(/\d+/)
|
||||
return clampWindowDays(match ? Number(match[0]) : 10)
|
||||
}
|
||||
|
||||
export function resolveTopRangeKey(range, customRange = {}) {
|
||||
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
|
||||
if (key === 'custom') {
|
||||
return 'custom'
|
||||
}
|
||||
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
if (/\d+/.test(key)) {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
return key || DEFAULT_OVERVIEW_RANGE
|
||||
}
|
||||
|
||||
function formatRiskTrendDateLabel(value) {
|
||||
const date = parseLocalDate(value)
|
||||
if (!date) {
|
||||
return String(value || '-').trim() || '-'
|
||||
}
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${month}-${day}`
|
||||
}
|
||||
|
||||
function buildRiskTrendBucketLabel(first, last) {
|
||||
const start = String(first?.date || '').trim()
|
||||
const end = String(last?.date || '').trim()
|
||||
if (!start || start === end) {
|
||||
return formatRiskTrendDateLabel(start)
|
||||
}
|
||||
return `${formatRiskTrendDateLabel(start)}~${formatRiskTrendDateLabel(end)}`
|
||||
}
|
||||
|
||||
function normalizeRiskTrendRow(item) {
|
||||
return {
|
||||
date: String(item.date || '').trim() || '-',
|
||||
total: Number(item.total || 0),
|
||||
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
export function aggregateRiskDailyTrendRows(rows, maxBuckets = RISK_DAILY_TREND_MAX_BUCKETS) {
|
||||
const normalizedRows = rows
|
||||
.map(normalizeRiskTrendRow)
|
||||
.filter((item) => item.date !== '-' || item.total > 0 || item.highOrAbove > 0)
|
||||
|
||||
if (normalizedRows.length <= maxBuckets) {
|
||||
return normalizedRows.map((item) => ({
|
||||
...item,
|
||||
date: formatRiskTrendDateLabel(item.date),
|
||||
sourceStartDate: item.date,
|
||||
sourceEndDate: item.date
|
||||
}))
|
||||
}
|
||||
|
||||
const bucketSize = Math.ceil(normalizedRows.length / maxBuckets)
|
||||
const buckets = []
|
||||
for (let index = 0; index < normalizedRows.length; index += bucketSize) {
|
||||
const bucketRows = normalizedRows.slice(index, index + bucketSize)
|
||||
const first = bucketRows[0]
|
||||
const last = bucketRows[bucketRows.length - 1]
|
||||
buckets.push({
|
||||
date: buildRiskTrendBucketLabel(first, last),
|
||||
sourceStartDate: first?.date || '',
|
||||
sourceEndDate: last?.date || '',
|
||||
total: bucketRows.reduce((sum, item) => sum + item.total, 0),
|
||||
highOrAbove: bucketRows.reduce((sum, item) => sum + item.highOrAbove, 0)
|
||||
})
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
133
web/src/composables/requests/requestClaimMapper.js
Normal file
133
web/src/composables/requests/requestClaimMapper.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
buildOccurredDisplay,
|
||||
buildRiskMeta,
|
||||
formatDateTime,
|
||||
parseNumber,
|
||||
parseOptionalAmount,
|
||||
resolveApprovalMeta,
|
||||
resolveDisplayName,
|
||||
resolveDocumentTypeMeta,
|
||||
resolveTypeLabel,
|
||||
resolveWorkflowNode
|
||||
} from './requestShared.js'
|
||||
import { buildExpenseItems } from './requestExpenseItems.js'
|
||||
import { resolveRelatedApplicationInfo } from './requestRelatedApplication.js'
|
||||
import {
|
||||
buildProgressSteps,
|
||||
isApplicationArchivedWorkflow,
|
||||
resolveApplicationLinkedReimbursementNo
|
||||
} from './requestProgressSteps.js'
|
||||
|
||||
export function mapExpenseClaimToRequest(claim) {
|
||||
const typeCode = String(claim?.expense_type || '').trim() || 'other'
|
||||
const typeLabel = resolveTypeLabel(typeCode)
|
||||
const documentTypeMeta = resolveDocumentTypeMeta(claim, typeCode)
|
||||
const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
const approvalMeta = resolveApprovalMeta(claim?.status)
|
||||
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
|
||||
const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
|
||||
const applicationLinkedReimbursementNo = isApplicationDocument ? resolveApplicationLinkedReimbursementNo(claim) : ''
|
||||
const applicationLinkStatusText = applicationLinkedReimbursementNo
|
||||
? `关联中 ${applicationLinkedReimbursementNo}`
|
||||
: '未关联'
|
||||
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
||||
const riskMeta = buildRiskMeta(claim?.risk_flags_json)
|
||||
const riskSummary = riskMeta.summary
|
||||
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
||||
const expenseItems = buildExpenseItems(claim, riskMeta)
|
||||
const visibleExpenseAmount = expenseItems.reduce((sum, item) => {
|
||||
const amount = parseOptionalAmount(item.reimbursableAmount) ?? parseNumber(item.itemAmount)
|
||||
return sum + amount
|
||||
}, 0)
|
||||
const amountValue = relatedApplication
|
||||
? expenseItems.length
|
||||
? visibleExpenseAmount
|
||||
: invoiceCount === 0
|
||||
? 0
|
||||
: parseNumber(claim?.amount)
|
||||
: parseNumber(claim?.amount)
|
||||
const applyDateTime = claim?.submitted_at || claim?.created_at
|
||||
const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim()
|
||||
const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
|
||||
|
||||
return {
|
||||
id: String(claim?.claim_no || claim?.id || '').trim(),
|
||||
claimNo: String(claim?.claim_no || claim?.id || '').trim(),
|
||||
claimId: String(claim?.id || '').trim(),
|
||||
status: String(claim?.status || '').trim(),
|
||||
employeeId,
|
||||
employee_id: employeeId,
|
||||
profileEmployeeId: employeeId || employeeName,
|
||||
person: String(claim?.employee_name || '').trim() || '待补充',
|
||||
dept: String(claim?.department_name || '').trim() || '待补充',
|
||||
departmentName: String(claim?.department_name || '').trim() || '待补充',
|
||||
employeeName: String(claim?.employee_name || '').trim() || '待补充',
|
||||
employeePosition: String(claim?.employee_position || '').trim(),
|
||||
employeeGrade: String(claim?.employee_grade || '').trim(),
|
||||
managerName: resolveDisplayName(claim?.manager_name),
|
||||
financeApproverName: resolveDisplayName(claim?.finance_approver_name, claim?.financeApproverName),
|
||||
financeOwnerName: resolveDisplayName(claim?.finance_owner_name, claim?.financeOwnerName),
|
||||
budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName),
|
||||
budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
|
||||
budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
|
||||
roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [],
|
||||
entity: '',
|
||||
typeCode,
|
||||
typeLabel,
|
||||
...documentTypeMeta,
|
||||
detailVariant: typeCode === 'travel' || typeCode === 'travel_application' ? 'travel' : 'general',
|
||||
title: String(claim?.reason || '').trim() || (isApplicationDocument ? typeLabel : `${typeLabel}报销`),
|
||||
sceneLabel: typeLabel,
|
||||
sceneTarget: String(claim?.location || '').trim() || '待补充',
|
||||
location: String(claim?.location || '').trim() || '待补充',
|
||||
relatedCustomer: '',
|
||||
occurredDisplay: buildOccurredDisplay(claim),
|
||||
occurredAt: claim?.occurred_at || '',
|
||||
applyTime: formatDateTime(applyDateTime) || '待补充',
|
||||
submittedAt: applyDateTime || '',
|
||||
createdAt: claim?.created_at || '',
|
||||
updatedAt: claim?.updated_at || '',
|
||||
amount: amountValue,
|
||||
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
|
||||
riskTone: riskMeta.tone,
|
||||
riskLabel: riskMeta.label,
|
||||
invoiceCount,
|
||||
workflowNode,
|
||||
approvalKey: approvalMeta.key,
|
||||
approvalStatus: approvalMeta.label,
|
||||
approvalTone: approvalMeta.tone,
|
||||
secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
|
||||
secondaryStatusValue: isApplicationDocument
|
||||
? approvalMeta.key === 'supplement'
|
||||
? '领导已退回,待重新提交'
|
||||
: applicationArchived
|
||||
? '已归档'
|
||||
: approvalMeta.key === 'completed'
|
||||
? applicationLinkStatusText
|
||||
: '已进入审批流程'
|
||||
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
|
||||
secondaryStatusTone: isApplicationDocument
|
||||
? approvalMeta.key === 'supplement'
|
||||
? 'warning'
|
||||
: approvalMeta.key === 'completed' && !applicationArchived && !applicationLinkedReimbursementNo
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: (invoiceCount > 0 ? 'success' : 'warning'),
|
||||
riskSummary,
|
||||
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
|
||||
expenseTableSummary: isApplicationDocument
|
||||
? '预计金额已随申请提交'
|
||||
: expenseItems.length
|
||||
? (invoiceCount > 0
|
||||
? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据`
|
||||
: `共 ${expenseItems.length} 条费用明细,待补充票据`)
|
||||
: '暂无费用明细',
|
||||
note: String(claim?.reason || '').trim(),
|
||||
relatedApplication,
|
||||
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
|
||||
documentTypeCode: documentTypeMeta.documentTypeCode
|
||||
}),
|
||||
expenseItems
|
||||
}
|
||||
}
|
||||
306
web/src/composables/requests/requestExpenseItems.js
Normal file
306
web/src/composables/requests/requestExpenseItems.js
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
DOCUMENT_BACKED_EXPENSE_TYPES,
|
||||
HOTEL_DESCRIPTION_EXPENSE_TYPES,
|
||||
LONG_DISTANCE_TRAVEL_EXPENSE_TYPES,
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||
ROUTE_DESCRIPTION_EXPENSE_TYPES,
|
||||
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||
SYSTEM_GENERATED_EXPENSE_TYPES,
|
||||
formatAmount,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
normalizeText,
|
||||
parseNumber,
|
||||
parseOptionalAmount,
|
||||
resolveTypeLabel
|
||||
} from './requestShared.js'
|
||||
import {
|
||||
findRelatedApplicationEvent,
|
||||
resolveRelatedApplicationInfo
|
||||
} from './requestRelatedApplication.js'
|
||||
|
||||
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 normalizeExpenseType(typeCode) {
|
||||
return String(typeCode || '').trim() || 'other'
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(typeCode) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(typeCode))
|
||||
}
|
||||
|
||||
function resolveLocationDisplay(location, typeCode) {
|
||||
const normalized = String(location || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
|
||||
}
|
||||
|
||||
function resolveExpenseDescriptionDetail(itemType, itemLocation) {
|
||||
const normalizedType = normalizeExpenseType(itemType)
|
||||
if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return resolveLocationDisplay(itemLocation, normalizedType)
|
||||
}
|
||||
|
||||
function resolveExpenseItemViewId(item, index, claim) {
|
||||
return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, claim) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
|
||||
return {
|
||||
id: resolveExpenseItemViewId(item, index, claim),
|
||||
index,
|
||||
itemType,
|
||||
itemDate: formatDate(item?.item_date),
|
||||
isSystemGenerated: Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
})
|
||||
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
|
||||
.sort((left, right) => {
|
||||
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
|
||||
return dateCompare || left.index - right.index
|
||||
})
|
||||
|
||||
const labels = new Map()
|
||||
travelItems.forEach((item, index) => {
|
||||
if (index === 0) {
|
||||
labels.set(item.id, '出发时间')
|
||||
} else if (index === travelItems.length - 1) {
|
||||
labels.set(item.id, '返回时间')
|
||||
} else {
|
||||
labels.set(item.id, '中转时间')
|
||||
}
|
||||
})
|
||||
return labels
|
||||
}
|
||||
|
||||
function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, claim, travelTimeLabelMap }) {
|
||||
if (isSystemGenerated) {
|
||||
return '系统自动计算'
|
||||
}
|
||||
if (travelTimeLabelMap?.has(id)) {
|
||||
return travelTimeLabelMap.get(id)
|
||||
}
|
||||
if (itemType === 'ride_ticket') {
|
||||
return '乘车时间'
|
||||
}
|
||||
if (itemType === 'hotel_ticket') {
|
||||
return '住宿时间'
|
||||
}
|
||||
return claim?.expense_type === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
function resolveAttachmentDisplayName(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
function hasRelatedApplicationContext(claim) {
|
||||
return Boolean(findRelatedApplicationEvent(claim))
|
||||
}
|
||||
|
||||
function isDocumentBackedRawExpenseItem(item) {
|
||||
const invoiceId = normalizeText(item?.invoice_id || item?.invoiceId)
|
||||
if (invoiceId) {
|
||||
return true
|
||||
}
|
||||
|
||||
return DOCUMENT_BACKED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
|
||||
}
|
||||
|
||||
function extractTravelDayCount(value) {
|
||||
const matched = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
|
||||
return matched ? parseNumber(matched[1]) : 0
|
||||
}
|
||||
|
||||
function isStaleApplicationAllowanceRawItem(item, claim) {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (itemType !== 'travel_allowance') {
|
||||
return false
|
||||
}
|
||||
|
||||
const related = resolveRelatedApplicationInfo(claim)
|
||||
const applicationDays = extractTravelDayCount(related?.days)
|
||||
const itemDays = extractTravelDayCount(item?.item_reason || item?.itemReason)
|
||||
return applicationDays > 0 && itemDays > 0 && applicationDays !== itemDays
|
||||
}
|
||||
|
||||
function isApplicationLinkPlaceholderRawItem(item, claim) {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const claimType = normalizeExpenseType(claim?.expense_type || claim?.expenseType)
|
||||
if (itemType && claimType && itemType !== claimType) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reason = normalizeText(item?.item_reason || item?.itemReason)
|
||||
if (!reason || reason === '待补充') {
|
||||
return true
|
||||
}
|
||||
|
||||
const related = resolveRelatedApplicationInfo(claim)
|
||||
const linkedReasons = new Set([
|
||||
normalizeText(claim?.reason),
|
||||
normalizeText(related?.reason)
|
||||
].filter(Boolean))
|
||||
return linkedReasons.has(reason)
|
||||
}
|
||||
|
||||
function filterVisibleExpenseRawItems(items, claim) {
|
||||
const rawItems = Array.isArray(items) ? items : []
|
||||
if (!rawItems.length || !hasRelatedApplicationContext(claim)) {
|
||||
return rawItems
|
||||
}
|
||||
|
||||
const hasRealExpenseItem = rawItems.some((item) => (
|
||||
isDocumentBackedRawExpenseItem(item)
|
||||
&& !SYSTEM_GENERATED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
|
||||
))
|
||||
if (!hasRealExpenseItem) {
|
||||
return rawItems.filter((item) => !isApplicationLinkPlaceholderRawItem(item, claim))
|
||||
}
|
||||
|
||||
return rawItems.filter((item) => {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
|
||||
return !isStaleApplicationAllowanceRawItem(item, claim)
|
||||
}
|
||||
return !isApplicationLinkPlaceholderRawItem(item, claim)
|
||||
})
|
||||
}
|
||||
|
||||
function buildExpenseItems(claim, riskMeta) {
|
||||
if (!Array.isArray(claim?.items)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedRiskMeta = typeof riskMeta === 'string'
|
||||
? { summary: riskMeta, tone: riskMeta === '无' ? 'low' : 'medium', label: riskMeta === '无' ? '无' : '待关注' }
|
||||
: {
|
||||
summary: String(riskMeta?.summary || '无').trim() || '无',
|
||||
tone: String(riskMeta?.tone || 'low').trim() || 'low',
|
||||
label: String(riskMeta?.label || '').trim() || (String(riskMeta?.summary || '').trim() === '无' ? '无' : '待关注')
|
||||
}
|
||||
|
||||
const visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
|
||||
const sortedItems = [...visibleItems].sort((left, right) => {
|
||||
const leftType = normalizeExpenseType(left?.item_type)
|
||||
const rightType = normalizeExpenseType(right?.item_type)
|
||||
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
|
||||
})
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
|
||||
const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
|
||||
|
||||
return sortedItems.map((item, index) => {
|
||||
const invoiceId = String(item?.invoice_id || '').trim()
|
||||
const attachmentName = resolveAttachmentDisplayName(invoiceId)
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
|
||||
const isSystemGenerated = Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
const id = resolveExpenseItemViewId(item, index, claim)
|
||||
const itemTypeLabel = resolveTypeLabel(itemType)
|
||||
const itemLocation = String(item?.item_location || '').trim()
|
||||
const itemReason = String(item?.item_reason || '').trim()
|
||||
const itemNote = String(item?.item_note || item?.itemNote || '').trim()
|
||||
const itemAmount = parseNumber(item?.item_amount)
|
||||
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
|
||||
const standardAdjustment = standardAdjustmentMap.get(id) || null
|
||||
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
|
||||
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
|
||||
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
|
||||
|
||||
return {
|
||||
id,
|
||||
time: formatDate(item?.item_date) || '待补充',
|
||||
itemDate: formatDate(item?.item_date) || '',
|
||||
filledAt: formatDateTime(item?.created_at) || '待同步',
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemNote,
|
||||
itemAmount,
|
||||
originalItemAmount,
|
||||
originalAmountDisplay: originalItemAmount > 0 ? formatAmount(originalItemAmount) : itemAmountDisplay,
|
||||
reimbursableAmount,
|
||||
reimbursableAmountDisplay: reimbursableAmount > 0 ? formatAmount(reimbursableAmount) : '待补充',
|
||||
employeeAbsorbedAmount,
|
||||
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatAmount(employeeAbsorbedAmount) : '',
|
||||
hasStandardAdjustment: reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount,
|
||||
standardAdjustmentAccepted: Boolean(standardAdjustment),
|
||||
standardAdjustmentMessage: standardAdjustment?.message || '',
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
claim,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: itemTypeLabel,
|
||||
category: itemTypeLabel,
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
|
||||
amount: itemAmountDisplay,
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: normalizedRiskMeta.summary === '无' ? '无' : normalizedRiskMeta.label,
|
||||
riskText: normalizedRiskMeta.summary === '无' ? '' : normalizedRiskMeta.summary,
|
||||
riskTone: normalizedRiskMeta.summary === '无' ? 'low' : normalizedRiskMeta.tone
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
buildExpenseItems
|
||||
}
|
||||
704
web/src/composables/requests/requestProgressSteps.js
Normal file
704
web/src/composables/requests/requestProgressSteps.js
Normal file
@@ -0,0 +1,704 @@
|
||||
import {
|
||||
APPLICATION_ARCHIVE_STAGE_LABEL,
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
APPLICATION_PROGRESS_LABELS,
|
||||
APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET,
|
||||
ARCHIVED_STEP_LABEL,
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
RELATED_APPLICATION_STEP_LABEL,
|
||||
REIMBURSEMENT_PROGRESS_LABELS,
|
||||
formatDateTime,
|
||||
formatDurationFrom,
|
||||
getLatestEvent,
|
||||
getRiskFlags,
|
||||
normalizeText,
|
||||
parseNumber,
|
||||
resolveDisplayName
|
||||
} from './requestShared.js'
|
||||
import { resolveRelatedApplicationInfo } from './requestRelatedApplication.js'
|
||||
|
||||
function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
const normalizedNode = String(workflowNode || '').trim()
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
return 6
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'pending_payment') {
|
||||
return 4
|
||||
}
|
||||
|
||||
if (normalizedNode.includes('已付款')) {
|
||||
return 5
|
||||
}
|
||||
if (normalizedNode.includes('待付款')) {
|
||||
return 4
|
||||
}
|
||||
if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
|
||||
return 6
|
||||
}
|
||||
if (normalizedNode.includes('财务')) {
|
||||
return 3
|
||||
}
|
||||
if (
|
||||
normalizedNode.includes('直属领导')
|
||||
|| normalizedNode.includes('领导审批')
|
||||
|| normalizedNode.includes('部门负责人')
|
||||
|| normalizedNode.includes('负责人审批')
|
||||
) {
|
||||
return 2
|
||||
}
|
||||
if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) {
|
||||
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? 1 : 2
|
||||
}
|
||||
if (normalizedNode.includes('待提交')) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
const normalizedNode = String(workflowNode || '').trim()
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 3 : 2
|
||||
}
|
||||
|
||||
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
|
||||
return 3
|
||||
}
|
||||
if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
|
||||
return 2
|
||||
}
|
||||
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
|
||||
return 2
|
||||
}
|
||||
if (normalizedNode.includes('预算')) {
|
||||
return 2
|
||||
}
|
||||
if (
|
||||
normalizedNode.includes('直属领导')
|
||||
|| normalizedNode.includes('领导审批')
|
||||
|| normalizedNode.includes('部门负责人')
|
||||
|| normalizedNode.includes('负责人审批')
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
function isApplicationArchivedWorkflow(claim, workflowNode) {
|
||||
const normalizedNode = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
|
||||
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
|
||||
return true
|
||||
}
|
||||
return getRiskFlags(claim).some((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& normalizeText(flag.source) === 'application_archive_sync'
|
||||
))
|
||||
}
|
||||
|
||||
function resolveApplicationLinkedReimbursementNo(claim) {
|
||||
for (const flag of [...getRiskFlags(claim)].reverse()) {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
continue
|
||||
}
|
||||
const generatedNo = normalizeText(
|
||||
flag.generated_draft_claim_no
|
||||
|| flag.generatedDraftClaimNo
|
||||
|| flag.reimbursement_claim_no
|
||||
|| flag.reimbursementClaimNo
|
||||
)
|
||||
if (generatedNo) {
|
||||
return generatedNo
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildApplicationLinkStatusStepMeta(claim) {
|
||||
const reimbursementNo = resolveApplicationLinkedReimbursementNo(claim)
|
||||
const updatedAt = formatDateTime(claim?.updated_at)
|
||||
return reimbursementNo
|
||||
? buildProgressStepMeta(`关联中 ${reimbursementNo}`, updatedAt)
|
||||
: buildProgressStepMeta('未关联', updatedAt)
|
||||
}
|
||||
|
||||
|
||||
function resolveApplicationApproverName(claim) {
|
||||
return resolveDisplayName(
|
||||
claim?.manager_name,
|
||||
claim?.managerName,
|
||||
claim?.profile_manager,
|
||||
claim?.profileManager,
|
||||
claim?.direct_manager_name,
|
||||
claim?.directManagerName
|
||||
) || '直属领导'
|
||||
}
|
||||
|
||||
function resolveReimbursementApproverName(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
if (stepLabel === '直属领导审批') {
|
||||
return resolveDisplayName(
|
||||
claim?.manager_name,
|
||||
claim?.managerName,
|
||||
claim?.profile_manager,
|
||||
claim?.profileManager,
|
||||
claim?.direct_manager_name,
|
||||
claim?.directManagerName
|
||||
) || '直属领导'
|
||||
}
|
||||
|
||||
if (stepLabel === '财务审批') {
|
||||
const routeEvent = findReimbursementFinanceRouteEvent(claim)
|
||||
return resolveDisplayName(
|
||||
claim?.finance_approver_name,
|
||||
claim?.financeApproverName,
|
||||
routeEvent?.next_approver_name,
|
||||
routeEvent?.nextApproverName,
|
||||
routeEvent?.finance_approver_name,
|
||||
routeEvent?.financeApproverName,
|
||||
claim?.finance_owner_name,
|
||||
claim?.financeOwnerName
|
||||
) || '财务'
|
||||
}
|
||||
|
||||
return stepLabel.replace(/审批$/, '') || '审批人'
|
||||
}
|
||||
|
||||
function resolveApplicationBudgetApproverName(claim) {
|
||||
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return resolveDisplayName(
|
||||
claim?.budget_approver_name,
|
||||
claim?.budgetApproverName,
|
||||
routeEvent?.next_approver_name,
|
||||
routeEvent?.nextApproverName,
|
||||
routeEvent?.budget_approver_name,
|
||||
routeEvent?.budgetApproverName
|
||||
) || '预算管理者'
|
||||
}
|
||||
|
||||
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
|
||||
const normalizedLabel = normalizeText(label)
|
||||
const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
|
||||
if (
|
||||
documentTypeCode !== DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '直属领导审批'
|
||||
&& workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
|
||||
) {
|
||||
return '等待批复'
|
||||
}
|
||||
|
||||
if (
|
||||
documentTypeCode !== DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '财务审批'
|
||||
&& workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
|
||||
) {
|
||||
return `等待 ${resolveReimbursementApproverName(claim, normalizedLabel)} 批复`
|
||||
}
|
||||
|
||||
if (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '直属领导审批'
|
||||
&& (
|
||||
workflowNode.includes('直属领导')
|
||||
|| workflowNode.includes('领导审批')
|
||||
|| workflowNode.includes('部门负责人')
|
||||
|| workflowNode.includes('负责人审批')
|
||||
)
|
||||
) {
|
||||
return `等待 ${resolveApplicationApproverName(claim)} 批复`
|
||||
}
|
||||
|
||||
if (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '预算管理者审批'
|
||||
&& workflowNode.includes('预算')
|
||||
) {
|
||||
return `等待 ${resolveApplicationBudgetApproverName(claim)} 批复`
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
|
||||
function findApprovalEventForStep(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
const events = getRiskFlags(claim).filter((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
if (!['manual_approval', 'budget_approval', 'finance_approval'].includes(source)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
|
||||
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
|
||||
|
||||
if (stepLabel === '直属领导审批') {
|
||||
return (
|
||||
previousStage.includes('直属领导')
|
||||
|| previousStage.includes('领导审批')
|
||||
|| nextStage.includes('预算')
|
||||
|| nextStage.includes('财务')
|
||||
)
|
||||
}
|
||||
|
||||
if (stepLabel === '预算管理者审批') {
|
||||
return (
|
||||
source === 'budget_approval'
|
||||
|| previousStage.includes('预算')
|
||||
|| nextStage.includes('审批完成')
|
||||
)
|
||||
}
|
||||
|
||||
if (stepLabel === '财务审批') {
|
||||
return (
|
||||
previousStage.includes('财务')
|
||||
|| nextStage.includes('待付款')
|
||||
|| nextStage.includes('归档')
|
||||
|| nextStage.includes('入账')
|
||||
|| nextStage.includes('完成')
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return getLatestEvent(events)
|
||||
}
|
||||
|
||||
function findReimbursementFinanceRouteEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
if (!['manual_approval', 'budget_approval'].includes(source)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
|
||||
return nextStage.includes('财务')
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function findLatestReturnEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& normalizeText(flag.source) === 'manual_return'
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
function findLatestPaymentEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& (
|
||||
normalizeText(flag.source) === 'payment'
|
||||
|| normalizeText(flag.event_type || flag.eventType) === 'expense_claim_payment_completed'
|
||||
)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function findLatestApplicationReturnEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => {
|
||||
if (!flag || typeof flag !== 'object' || normalizeText(flag.source) !== 'manual_return') {
|
||||
return false
|
||||
}
|
||||
const eventType = normalizeText(flag.event_type || flag.eventType)
|
||||
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
|
||||
const stageKey = normalizeText(flag.return_stage_key || flag.returnStageKey)
|
||||
return (
|
||||
eventType === 'expense_application_return'
|
||||
|| stageKey === 'direct_manager'
|
||||
|| returnStage.includes('直属领导')
|
||||
|| returnStage.includes('领导审批')
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function findMergedApplicationBudgetApprovalEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
const source = normalizeText(flag.source)
|
||||
const eventType = normalizeText(flag.event_type || flag.eventType)
|
||||
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
|
||||
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
|
||||
const mergedFlag = Boolean(flag.budget_approval_merged || flag.budgetApprovalMerged)
|
||||
return (
|
||||
source === 'manual_approval'
|
||||
&& eventType === 'expense_application_approval'
|
||||
&& previousStage.includes('直属领导')
|
||||
&& (
|
||||
nextStage.includes('审批完成')
|
||||
|| nextStage.includes(APPLICATION_LINK_STATUS_STEP_LABEL)
|
||||
|| nextStage.includes('申请完成')
|
||||
)
|
||||
&& mergedFlag
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function resolveBudgetRouteResult(flag, routeDecision = {}) {
|
||||
if (routeDecision && typeof routeDecision === 'object') {
|
||||
const routeBudgetResult = routeDecision.budget_result || routeDecision.budgetResult
|
||||
if (routeBudgetResult && typeof routeBudgetResult === 'object') {
|
||||
return routeBudgetResult
|
||||
}
|
||||
}
|
||||
|
||||
const flagBudgetResult = flag?.budget_result || flag?.budgetResult
|
||||
return flagBudgetResult && typeof flagBudgetResult === 'object' ? flagBudgetResult : {}
|
||||
}
|
||||
|
||||
function applicationBudgetRouteMeetsThreshold(flag, routeDecision = {}) {
|
||||
const budgetResult = resolveBudgetRouteResult(flag, routeDecision)
|
||||
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
|
||||
const overBudgetAmount = parseNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
|
||||
const afterUsageRate = parseNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
|
||||
const claimAmountRatio = parseNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
|
||||
|
||||
return overBudgetAmount > 0 || Math.max(afterUsageRate, claimAmountRatio) >= 90
|
||||
}
|
||||
|
||||
function applicationRequiresBudgetReviewStep(claim, workflowNode) {
|
||||
const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
|
||||
if (node.includes('预算')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return getRiskFlags(claim).some((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
const eventType = normalizeText(flag.event_type || flag.eventType)
|
||||
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
|
||||
const routeDecision = flag.route_decision || flag.routeDecision || {}
|
||||
|
||||
if (source === 'approval_routing' && flag.requires_budget_review === true) {
|
||||
return applicationBudgetRouteMeetsThreshold(flag, flag)
|
||||
}
|
||||
if (
|
||||
routeDecision
|
||||
&& typeof routeDecision === 'object'
|
||||
&& routeDecision.requires_budget_review === true
|
||||
) {
|
||||
return applicationBudgetRouteMeetsThreshold(flag, routeDecision)
|
||||
}
|
||||
return (
|
||||
source === 'budget_approval'
|
||||
|| eventType === 'expense_application_budget_approval'
|
||||
|| previousStage.includes('预算')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function buildProgressStepMeta(time, detail = '', title = '') {
|
||||
return {
|
||||
time,
|
||||
detail,
|
||||
title: title || [time, detail].filter(Boolean).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompletedStepMeta(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
const employeeName = normalizeText(claim?.employee_name) || '申请人'
|
||||
|
||||
if (stepLabel === RELATED_APPLICATION_STEP_LABEL) {
|
||||
const relatedApplication = resolveRelatedApplicationInfo(claim)
|
||||
const createdAt = formatDateTime(claim?.created_at)
|
||||
if (relatedApplication?.claimNo) {
|
||||
return buildProgressStepMeta(`已关联 ${relatedApplication.claimNo}`, createdAt)
|
||||
}
|
||||
return buildProgressStepMeta('待核对关联单据', createdAt)
|
||||
}
|
||||
|
||||
if (stepLabel === APPLICATION_LINK_STATUS_STEP_LABEL) {
|
||||
return buildApplicationLinkStatusStepMeta(claim)
|
||||
}
|
||||
|
||||
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||
const createdAt = formatDateTime(claim?.created_at)
|
||||
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '待提交') {
|
||||
const submittedAt = formatDateTime(claim?.submitted_at)
|
||||
return buildProgressStepMeta(`${employeeName}提交`, submittedAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') {
|
||||
const approvalEvent = findApprovalEventForStep(claim, stepLabel)
|
||||
if (approvalEvent) {
|
||||
const operator = resolveDisplayName(
|
||||
approvalEvent.operator,
|
||||
approvalEvent.operator_name,
|
||||
approvalEvent.operatorName,
|
||||
stepLabel === '直属领导审批' ? claim?.manager_name : '',
|
||||
stepLabel === '预算管理者审批' ? approvalEvent.next_approver_name : ''
|
||||
) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算管理者' : '直属领导')
|
||||
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
|
||||
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
|
||||
}
|
||||
|
||||
if (stepLabel === '财务审批') {
|
||||
const updatedAt = formatDateTime(claim?.updated_at)
|
||||
return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim())
|
||||
}
|
||||
|
||||
if (stepLabel === '直属领导审批') {
|
||||
const returnEvent = findLatestApplicationReturnEvent(claim)
|
||||
if (returnEvent) {
|
||||
const handledAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
|
||||
return buildProgressStepMeta('已处理', handledAt, `直属领导已处理 ${handledAt}`.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stepLabel === '退回') {
|
||||
const returnEvent = findLatestApplicationReturnEvent(claim) || findLatestReturnEvent(claim)
|
||||
if (returnEvent) {
|
||||
const operator = resolveDisplayName(
|
||||
returnEvent.operator,
|
||||
returnEvent.operator_name,
|
||||
returnEvent.operatorName,
|
||||
claim?.manager_name
|
||||
) || '直属领导'
|
||||
const returnedAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
|
||||
return buildProgressStepMeta(`${operator}退回`, returnedAt, `${operator}退回 ${returnedAt}`.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (stepLabel === '待付款') {
|
||||
const approvalEvent = findApprovalEventForStep(claim, '财务审批')
|
||||
const pendingAt = formatDateTime(approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at)
|
||||
return buildProgressStepMeta('待付款', pendingAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '已付款') {
|
||||
const paymentEvent = findLatestPaymentEvent(claim)
|
||||
const paidAt = formatDateTime(paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at)
|
||||
return buildProgressStepMeta('已付款', paidAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '归档入账') {
|
||||
const archivedAt = formatDateTime(claim?.updated_at)
|
||||
return buildProgressStepMeta('归档入账', archivedAt)
|
||||
}
|
||||
|
||||
if (stepLabel === ARCHIVED_STEP_LABEL) {
|
||||
const archivedAt = formatDateTime(claim?.updated_at)
|
||||
return buildProgressStepMeta(ARCHIVED_STEP_LABEL, archivedAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '审批完成') {
|
||||
const completedAt = formatDateTime(claim?.updated_at)
|
||||
return buildProgressStepMeta('审批完成', completedAt)
|
||||
}
|
||||
|
||||
return buildProgressStepMeta('已完成')
|
||||
}
|
||||
|
||||
function resolveCurrentStepStartedAt(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
if (stepLabel === RELATED_APPLICATION_STEP_LABEL || stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||
return claim?.created_at
|
||||
}
|
||||
if (stepLabel === '待提交') {
|
||||
const returnEvent = findLatestReturnEvent(claim)
|
||||
return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at
|
||||
}
|
||||
if (stepLabel === '直属领导审批') {
|
||||
return claim?.submitted_at || claim?.updated_at || claim?.created_at
|
||||
}
|
||||
if (stepLabel === '预算管理者审批') {
|
||||
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '财务审批') {
|
||||
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '待付款') {
|
||||
const approvalEvent = findApprovalEventForStep(claim, '财务审批')
|
||||
return approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '已付款') {
|
||||
const paymentEvent = findLatestPaymentEvent(claim)
|
||||
return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '归档入账' || stepLabel === ARCHIVED_STEP_LABEL || stepLabel === '审批完成') {
|
||||
return claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
|
||||
const documentTypeCode = String(options.documentTypeCode || '').trim()
|
||||
const hasApplicationReturnStep = (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& Boolean(findLatestApplicationReturnEvent(claim))
|
||||
&& approvalMeta.key === 'supplement'
|
||||
)
|
||||
const hasMergedApplicationBudgetApproval = (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& Boolean(findMergedApplicationBudgetApprovalEvent(claim))
|
||||
)
|
||||
const shouldShowApplicationBudgetStep = (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& !hasMergedApplicationBudgetApproval
|
||||
&& applicationRequiresBudgetReviewStep(claim, workflowNode)
|
||||
)
|
||||
const isApplicationDocument = documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
|
||||
const progressLabels =
|
||||
isApplicationDocument
|
||||
? hasApplicationReturnStep
|
||||
? ['创建申请', '直属领导审批', '退回', '待提交']
|
||||
: hasMergedApplicationBudgetApproval
|
||||
? ['创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
|
||||
: shouldShowApplicationBudgetStep
|
||||
? APPLICATION_PROGRESS_LABELS
|
||||
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
|
||||
: REIMBURSEMENT_PROGRESS_LABELS
|
||||
const applicationLinkIndex = progressLabels.indexOf(APPLICATION_LINK_STATUS_STEP_LABEL)
|
||||
const applicationArchiveIndex = progressLabels.indexOf(ARCHIVED_STEP_LABEL)
|
||||
const currentIndex =
|
||||
isApplicationDocument
|
||||
? hasApplicationReturnStep
|
||||
? 3
|
||||
: applicationArchived && applicationArchiveIndex >= 0
|
||||
? applicationArchiveIndex
|
||||
: approvalMeta.key === 'completed' && applicationLinkIndex >= 0
|
||||
? applicationLinkIndex
|
||||
: Math.min(
|
||||
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
|
||||
Math.max(0, progressLabels.length - 1)
|
||||
)
|
||||
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
|
||||
const currentTime =
|
||||
approvalMeta.key === 'completed'
|
||||
? '已完成'
|
||||
: approvalMeta.key === 'pending_payment'
|
||||
? '待付款'
|
||||
: approvalMeta.key === 'supplement'
|
||||
? '待补充'
|
||||
: approvalMeta.key === 'rejected'
|
||||
? '已退回'
|
||||
: '进行中'
|
||||
|
||||
return progressLabels.map((label, index) => {
|
||||
const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta)
|
||||
if (approvalMeta.key === 'completed' && (!isApplicationDocument || applicationArchived)) {
|
||||
const stepMeta = buildCompletedStepMeta(claim, label)
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stepMeta.time,
|
||||
detail: stepMeta.detail,
|
||||
title: stepMeta.title,
|
||||
done: true,
|
||||
active: true,
|
||||
current: false
|
||||
}
|
||||
}
|
||||
|
||||
if (index < currentIndex) {
|
||||
const stepMeta = buildCompletedStepMeta(claim, label)
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stepMeta.time,
|
||||
detail: stepMeta.detail,
|
||||
title: stepMeta.title,
|
||||
done: true,
|
||||
active: true,
|
||||
current: false
|
||||
}
|
||||
}
|
||||
|
||||
if (index === currentIndex) {
|
||||
if (isApplicationDocument && label === APPLICATION_LINK_STATUS_STEP_LABEL) {
|
||||
const stepMeta = buildApplicationLinkStatusStepMeta(claim)
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stepMeta.time,
|
||||
detail: stepMeta.detail,
|
||||
title: stepMeta.title,
|
||||
done: false,
|
||||
active: true,
|
||||
current: true
|
||||
}
|
||||
}
|
||||
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stayDuration ? `停留 ${stayDuration}` : currentTime,
|
||||
detail: '',
|
||||
title: stayDuration ? `当前${displayLabel}已停留 ${stayDuration}` : currentTime,
|
||||
done: false,
|
||||
active: true,
|
||||
current: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: '待处理',
|
||||
detail: '',
|
||||
title: '待处理',
|
||||
done: false,
|
||||
active: false,
|
||||
current: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
buildProgressSteps,
|
||||
isApplicationArchivedWorkflow,
|
||||
resolveApplicationLinkedReimbursementNo
|
||||
}
|
||||
227
web/src/composables/requests/requestRelatedApplication.js
Normal file
227
web/src/composables/requests/requestRelatedApplication.js
Normal file
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
formatAmount,
|
||||
formatDate,
|
||||
getLatestEvent,
|
||||
getRiskFlags,
|
||||
normalizeText,
|
||||
parseNumber
|
||||
} from './requestShared.js'
|
||||
|
||||
function normalizeApplicationHandoffDetail(flag = {}) {
|
||||
const detail = flag?.application_detail || flag?.applicationDetail || {}
|
||||
const reviewValues = flag?.review_form_values || flag?.reviewFormValues || {}
|
||||
const sceneSelection = flag?.expense_scene_selection || flag?.expenseSceneSelection || {}
|
||||
return [sceneSelection, reviewValues, detail]
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.reduce((acc, item) => ({ ...acc, ...item }), {})
|
||||
}
|
||||
|
||||
function resolveApplicationField(flag = {}, detail = {}, snakeKey, camelKey = '') {
|
||||
return normalizeText(
|
||||
flag?.[snakeKey]
|
||||
|| (camelKey ? flag?.[camelKey] : '')
|
||||
|| detail?.[snakeKey]
|
||||
|| (camelKey ? detail?.[camelKey] : '')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplicationValue(flag = {}, detail = {}, keys = []) {
|
||||
for (const key of keys) {
|
||||
const detailValue = normalizeText(detail?.[key])
|
||||
if (detailValue) {
|
||||
return detailValue
|
||||
}
|
||||
const flagValue = normalizeText(flag?.[key])
|
||||
if (flagValue) {
|
||||
return flagValue
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractDateRange(value) {
|
||||
const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
|
||||
if (!dates.length) {
|
||||
return { startDate: '', endDate: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: dates[0],
|
||||
endDate: dates[dates.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelatedApplicationClaimNo(flag = {}) {
|
||||
const detail = normalizeApplicationHandoffDetail(flag)
|
||||
return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
|
||||
}
|
||||
|
||||
function findRelatedApplicationEvent(claim) {
|
||||
const events = getRiskFlags(claim).filter((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& resolveRelatedApplicationClaimNo(flag)
|
||||
))
|
||||
return getLatestEvent(events) || events[events.length - 1] || null
|
||||
}
|
||||
|
||||
function resolveRelatedApplicationAmountLabel(flag, detail, claim) {
|
||||
const explicitLabel = normalizeText(
|
||||
flag?.application_amount_label
|
||||
|| flag?.applicationAmountLabel
|
||||
|| detail?.application_amount_label
|
||||
|| detail?.applicationAmountLabel
|
||||
)
|
||||
if (explicitLabel) return explicitLabel
|
||||
|
||||
const rawAmount = normalizeText(
|
||||
flag?.application_amount
|
||||
|| flag?.applicationAmount
|
||||
|| flag?.application_budget_amount
|
||||
|| flag?.applicationBudgetAmount
|
||||
|| detail?.application_amount
|
||||
|| detail?.applicationAmount
|
||||
|| detail?.amount
|
||||
|| claim?.amount
|
||||
)
|
||||
const amountValue = parseNumber(rawAmount)
|
||||
return amountValue > 0 ? formatAmount(amountValue) : rawAmount
|
||||
}
|
||||
|
||||
function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|
||||
const relatedEvent = findRelatedApplicationEvent(claim)
|
||||
if (!relatedEvent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const detail = normalizeApplicationHandoffDetail(relatedEvent)
|
||||
const claimNo = resolveRelatedApplicationClaimNo(relatedEvent)
|
||||
const applicationType = normalizeText(
|
||||
detail.application_type
|
||||
|| detail.applicationType
|
||||
|| relatedEvent.application_type
|
||||
|| relatedEvent.applicationType
|
||||
|| typeLabel
|
||||
)
|
||||
const location = normalizeText(
|
||||
detail.application_location
|
||||
|| detail.applicationLocation
|
||||
|| detail.location
|
||||
|| relatedEvent.application_location
|
||||
|| relatedEvent.applicationLocation
|
||||
|| claim?.location
|
||||
)
|
||||
const reason = normalizeText(
|
||||
detail.application_reason
|
||||
|| detail.applicationReason
|
||||
|| detail.reason
|
||||
|| relatedEvent.application_reason
|
||||
|| relatedEvent.applicationReason
|
||||
|| claim?.reason
|
||||
)
|
||||
const content = normalizeText(
|
||||
detail.application_content
|
||||
|| detail.applicationContent
|
||||
|| relatedEvent.application_content
|
||||
|| relatedEvent.applicationContent
|
||||
) || [applicationType, location].filter(Boolean).join(' / ')
|
||||
const rawTime = normalizeText(
|
||||
detail.application_time
|
||||
|| detail.applicationTime
|
||||
|| detail.application_business_time
|
||||
|| detail.applicationBusinessTime
|
||||
|| detail.business_time
|
||||
|| detail.businessTime
|
||||
|| detail.time_range
|
||||
|| detail.timeRange
|
||||
|| detail.time
|
||||
|| detail.application_date
|
||||
|| detail.applicationDate
|
||||
|| relatedEvent.application_time
|
||||
|| relatedEvent.applicationTime
|
||||
|| relatedEvent.application_business_time
|
||||
|| relatedEvent.applicationBusinessTime
|
||||
|| relatedEvent.business_time
|
||||
|| relatedEvent.businessTime
|
||||
|| relatedEvent.time_range
|
||||
|| relatedEvent.timeRange
|
||||
|| relatedEvent.application_date
|
||||
|| relatedEvent.applicationDate
|
||||
|| claim?.occurred_at
|
||||
)
|
||||
const displayTime = formatDate(rawTime) || rawTime
|
||||
const dateRange = extractDateRange(rawTime || displayTime)
|
||||
const ruleName = resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_rule_name',
|
||||
'applicationRuleName',
|
||||
'rule_name',
|
||||
'ruleName'
|
||||
])
|
||||
const ruleVersion = resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_rule_version',
|
||||
'applicationRuleVersion',
|
||||
'rule_version',
|
||||
'ruleVersion'
|
||||
])
|
||||
|
||||
return {
|
||||
id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
|
||||
claimNo,
|
||||
content,
|
||||
reason,
|
||||
days: normalizeText(
|
||||
detail.application_days
|
||||
|| detail.applicationDays
|
||||
|| detail.days
|
||||
|| relatedEvent.application_days
|
||||
|| relatedEvent.applicationDays
|
||||
),
|
||||
location,
|
||||
time: displayTime,
|
||||
tripStartDate: dateRange.startDate,
|
||||
tripEndDate: dateRange.endDate,
|
||||
amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
|
||||
statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
|
||||
transportMode: normalizeText(
|
||||
detail.application_transport_mode
|
||||
|| detail.applicationTransportMode
|
||||
|| detail.transport_mode
|
||||
|| relatedEvent.application_transport_mode
|
||||
|| relatedEvent.applicationTransportMode
|
||||
),
|
||||
lodgingDailyCap: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_lodging_daily_cap',
|
||||
'applicationLodgingDailyCap',
|
||||
'lodging_daily_cap',
|
||||
'lodgingDailyCap'
|
||||
]),
|
||||
subsidyDailyCap: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_subsidy_daily_cap',
|
||||
'applicationSubsidyDailyCap',
|
||||
'subsidy_daily_cap',
|
||||
'subsidyDailyCap'
|
||||
]),
|
||||
transportPolicy: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_transport_policy',
|
||||
'applicationTransportPolicy',
|
||||
'transport_policy',
|
||||
'transportPolicy'
|
||||
]),
|
||||
policyEstimate: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_policy_estimate',
|
||||
'applicationPolicyEstimate',
|
||||
'policy_estimate',
|
||||
'policyEstimate'
|
||||
]),
|
||||
ruleName,
|
||||
ruleVersion,
|
||||
ruleLabel: [ruleName, ruleVersion].filter(Boolean).join(' / ')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
findRelatedApplicationEvent,
|
||||
resolveRelatedApplicationInfo
|
||||
}
|
||||
426
web/src/composables/requests/requestShared.js
Normal file
426
web/src/composables/requests/requestShared.js
Normal file
@@ -0,0 +1,426 @@
|
||||
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
travel_application: '差旅费用申请',
|
||||
expense_application: '费用申请',
|
||||
purchase_application: '采购费用申请',
|
||||
meeting_application: '会务费用申请',
|
||||
train_ticket: '火车票',
|
||||
flight_ticket: '机票',
|
||||
ship_ticket: '轮船票',
|
||||
ferry_ticket: '轮船票',
|
||||
hotel_ticket: '住宿票',
|
||||
ride_ticket: '乘车',
|
||||
travel_allowance: '出差补贴',
|
||||
entertainment: '业务招待费',
|
||||
marketing: '市场推广费',
|
||||
office: '办公用品费',
|
||||
meeting: '会务费',
|
||||
training: '培训费',
|
||||
software: '软件服务费',
|
||||
hotel: '住宿费',
|
||||
transport: '交通费',
|
||||
meal: '业务招待费',
|
||||
other: '其他费用'
|
||||
}
|
||||
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'train_ticket',
|
||||
'flight_ticket',
|
||||
'hotel_ticket',
|
||||
'ride_ticket',
|
||||
'meeting',
|
||||
'entertainment'
|
||||
])
|
||||
|
||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
|
||||
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
||||
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
|
||||
'train_ticket',
|
||||
'flight_ticket',
|
||||
'ship_ticket',
|
||||
'ferry_ticket',
|
||||
'hotel_ticket',
|
||||
'ride_ticket'
|
||||
])
|
||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
||||
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
|
||||
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
|
||||
const ARCHIVED_STEP_LABEL = '已归档'
|
||||
|
||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||
RELATED_APPLICATION_STEP_LABEL,
|
||||
'待提交',
|
||||
'直属领导审批',
|
||||
'财务审批',
|
||||
'待付款',
|
||||
'已付款',
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
const APPLICATION_PROGRESS_LABELS = [
|
||||
'创建申请',
|
||||
'直属领导审批',
|
||||
'预算管理者审批',
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
|
||||
'创建申请',
|
||||
'直属领导审批',
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
function parseNumber(value) {
|
||||
const nextValue = Number(value)
|
||||
return Number.isFinite(nextValue) ? nextValue : 0
|
||||
}
|
||||
|
||||
function parseOptionalAmount(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') {
|
||||
return null
|
||||
}
|
||||
const amount = Number(value)
|
||||
return Number.isFinite(amount) && amount >= 0 ? amount : null
|
||||
}
|
||||
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextDate = new Date(value)
|
||||
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const nextDate = toDate(value)
|
||||
if (!nextDate) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const year = nextDate.getFullYear()
|
||||
const month = String(nextDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(nextDate.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const nextDate = toDate(value)
|
||||
if (!nextDate) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const hours = String(nextDate.getHours()).padStart(2, '0')
|
||||
const minutes = String(nextDate.getMinutes()).padStart(2, '0')
|
||||
return `${formatDate(nextDate)} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function formatDurationFrom(value, now = Date.now()) {
|
||||
const startAt = toDate(value)
|
||||
if (!startAt) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const diffMs = Math.max(0, Number(now) - startAt.getTime())
|
||||
const totalMinutes = Math.floor(diffMs / (60 * 1000))
|
||||
if (totalMinutes < 1) {
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
|
||||
if (days > 0) {
|
||||
return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||
}
|
||||
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
|
||||
function formatAmount(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||||
}).format(parseNumber(value))
|
||||
}
|
||||
|
||||
function resolveTypeLabel(typeCode) {
|
||||
return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
function resolveDocumentTypeMeta(claim, typeCode) {
|
||||
const explicitType = String(
|
||||
claim?.document_type_code
|
||||
|| claim?.documentTypeCode
|
||||
|| claim?.document_type
|
||||
|| claim?.documentType
|
||||
|| ''
|
||||
).trim()
|
||||
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
|
||||
const normalizedType = String(typeCode || '').trim()
|
||||
const isApplication =
|
||||
explicitType === DOCUMENT_TYPE_APPLICATION
|
||||
|| explicitType === 'expense_application'
|
||||
|| isApplicationDocumentNo(claimNo)
|
||||
|| normalizedType === 'application'
|
||||
|| normalizedType.endsWith('_application')
|
||||
|
||||
return isApplication
|
||||
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
|
||||
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
|
||||
}
|
||||
|
||||
function normalizeExpenseType(typeCode) {
|
||||
return String(typeCode || '').trim() || 'other'
|
||||
}
|
||||
|
||||
function resolveApprovalMeta(status) {
|
||||
const normalized = String(status || '').trim().toLowerCase()
|
||||
|
||||
if (normalized === 'draft') {
|
||||
return { key: 'draft', label: '草稿', tone: 'draft' }
|
||||
}
|
||||
|
||||
if (normalized === 'returned') {
|
||||
return { key: 'supplement', label: '待提交', tone: 'warning' }
|
||||
}
|
||||
|
||||
if (normalized === 'supplement') {
|
||||
return { key: 'supplement', label: '待补充', tone: 'warning' }
|
||||
}
|
||||
|
||||
if (normalized === 'pending_payment') {
|
||||
return { key: 'pending_payment', label: '待付款', tone: 'warning' }
|
||||
}
|
||||
|
||||
if (normalized === 'paid') {
|
||||
return { key: 'completed', label: '已付款', tone: 'success' }
|
||||
}
|
||||
|
||||
if (['approved', 'completed', 'paid'].includes(normalized)) {
|
||||
return { key: 'completed', label: '已完成', tone: 'success' }
|
||||
}
|
||||
|
||||
if (['rejected', 'cancelled'].includes(normalized)) {
|
||||
return { key: 'rejected', label: '已退回', tone: 'danger' }
|
||||
}
|
||||
|
||||
return { key: 'in_progress', label: '审批中', tone: 'info' }
|
||||
}
|
||||
|
||||
function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) {
|
||||
if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
|
||||
return '待提交'
|
||||
}
|
||||
|
||||
const rawNode = String(claim?.approval_stage || '').trim()
|
||||
|
||||
if (rawNode) {
|
||||
if (
|
||||
isApplicationDocument
|
||||
&& approvalMeta.key === 'completed'
|
||||
&& (
|
||||
rawNode === '审批完成'
|
||||
|| rawNode.includes('审批完成')
|
||||
|| rawNode.includes('申请完成')
|
||||
)
|
||||
) {
|
||||
return APPLICATION_LINK_STATUS_STEP_LABEL
|
||||
}
|
||||
if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
|
||||
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
|
||||
}
|
||||
if (rawNode === '待补充') {
|
||||
return '待提交'
|
||||
}
|
||||
return rawNode
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
|
||||
return '待提交'
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'pending_payment') {
|
||||
return '待付款'
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
const normalizedStatus = String(claim?.status || '').trim().toLowerCase()
|
||||
return isApplicationDocument ? APPLICATION_LINK_STATUS_STEP_LABEL : normalizedStatus === 'paid' ? '已付款' : '归档入账'
|
||||
}
|
||||
|
||||
return '直属领导审批'
|
||||
}
|
||||
|
||||
function stringifyRiskFlag(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
for (const key of ['message', 'label', 'reason', 'name']) {
|
||||
const nextValue = String(value[key] || '').trim()
|
||||
if (nextValue) {
|
||||
return nextValue
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const RISK_TONE_LABELS = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险'
|
||||
}
|
||||
|
||||
function resolveHighestRiskTone(flags) {
|
||||
const tones = flags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
|
||||
if (tones.includes('high')) {
|
||||
return 'high'
|
||||
}
|
||||
if (tones.includes('medium')) {
|
||||
return 'medium'
|
||||
}
|
||||
if (tones.includes('low')) {
|
||||
return 'low'
|
||||
}
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function buildRiskMeta(riskFlags) {
|
||||
if (!Array.isArray(riskFlags) || !riskFlags.length) {
|
||||
return { summary: '无', tone: 'low', label: '无' }
|
||||
}
|
||||
|
||||
const actionableFlags = filterActionableRiskFlags(riskFlags)
|
||||
const items = actionableFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
|
||||
if (!items.length) {
|
||||
return { summary: '无', tone: 'low', label: '无' }
|
||||
}
|
||||
|
||||
const tone = resolveHighestRiskTone(actionableFlags)
|
||||
return {
|
||||
summary: items.join(';'),
|
||||
tone,
|
||||
label: RISK_TONE_LABELS[tone] || '待关注'
|
||||
}
|
||||
}
|
||||
|
||||
function buildRiskSummary(riskFlags) {
|
||||
return buildRiskMeta(riskFlags).summary
|
||||
}
|
||||
|
||||
function buildOccurredDisplay(claim) {
|
||||
const itemDates = Array.isArray(claim?.items)
|
||||
? claim.items.map((item) => formatDate(item?.item_date)).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (!itemDates.length) {
|
||||
return formatDate(claim?.occurred_at) || '待补充'
|
||||
}
|
||||
|
||||
const sortedDates = [...new Set(itemDates)].sort()
|
||||
if (sortedDates.length === 1) {
|
||||
return sortedDates[0]
|
||||
}
|
||||
|
||||
return `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}`
|
||||
}
|
||||
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isEmailLike(value) {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
|
||||
}
|
||||
|
||||
function resolveDisplayName(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = normalizeText(value)
|
||||
if (normalized && !isEmailLike(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
function getRiskFlags(claim) {
|
||||
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
|
||||
}
|
||||
|
||||
function getLatestEvent(events) {
|
||||
const sortedEvents = events
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
|
||||
.filter((item) => item.eventDate)
|
||||
.sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime())
|
||||
|
||||
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
|
||||
}
|
||||
|
||||
export {
|
||||
APPLICATION_ARCHIVE_STAGE_LABEL,
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
APPLICATION_PROGRESS_LABELS,
|
||||
APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET,
|
||||
ARCHIVED_STEP_LABEL,
|
||||
DOCUMENT_BACKED_EXPENSE_TYPES,
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
DOCUMENT_TYPE_REIMBURSEMENT,
|
||||
EXPENSE_TYPE_LABELS,
|
||||
HOTEL_DESCRIPTION_EXPENSE_TYPES,
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||
LONG_DISTANCE_TRAVEL_EXPENSE_TYPES,
|
||||
RELATED_APPLICATION_STEP_LABEL,
|
||||
REIMBURSEMENT_PROGRESS_LABELS,
|
||||
ROUTE_DESCRIPTION_EXPENSE_TYPES,
|
||||
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||
SYSTEM_GENERATED_EXPENSE_TYPES,
|
||||
buildOccurredDisplay,
|
||||
buildRiskMeta,
|
||||
buildRiskSummary,
|
||||
formatAmount,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatDurationFrom,
|
||||
getLatestEvent,
|
||||
getRiskFlags,
|
||||
isEmailLike,
|
||||
normalizeExpenseType,
|
||||
normalizeText,
|
||||
parseNumber,
|
||||
parseOptionalAmount,
|
||||
resolveApprovalMeta,
|
||||
resolveDisplayName,
|
||||
resolveDocumentTypeMeta,
|
||||
resolveTypeLabel,
|
||||
resolveWorkflowNode,
|
||||
toDate
|
||||
}
|
||||
@@ -29,6 +29,12 @@ import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
|
||||
const SESSION_TYPE_EXPENSE = 'expense'
|
||||
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
|
||||
const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar'
|
||||
const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation'])
|
||||
|
||||
function resolveDocumentDetailReturnTarget(value) {
|
||||
const target = String(value || '').trim()
|
||||
return DOCUMENT_DETAIL_RETURN_TARGETS.has(target) ? target : ''
|
||||
}
|
||||
|
||||
export function useAppShell() {
|
||||
const route = useRoute()
|
||||
@@ -99,10 +105,13 @@ export function useAppShell() {
|
||||
})
|
||||
|
||||
const detailMode = computed(() => route.name === 'app-document-detail')
|
||||
const detailReturnTarget = computed(() => String(route.query.returnTo || '').trim())
|
||||
const detailBackLabel = computed(() => (
|
||||
detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心'
|
||||
))
|
||||
const detailReturnTarget = computed(() => resolveDocumentDetailReturnTarget(route.query.returnTo))
|
||||
const detailBackLabel = computed(() => {
|
||||
if (detailReturnTarget.value === 'conversation') {
|
||||
return '返回对话'
|
||||
}
|
||||
return detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心'
|
||||
})
|
||||
const detailAlerts = computed(() => (
|
||||
detailMode.value
|
||||
? buildDetailAlerts(selectedRequest.value, { currentUser: currentUser.value })
|
||||
@@ -553,9 +562,9 @@ export function useAppShell() {
|
||||
|
||||
function buildDocumentDetailQuery(options = {}) {
|
||||
const nextQuery = { ...route.query }
|
||||
const returnTo = String(options.returnTo || '').trim()
|
||||
if (returnTo === 'workbench') {
|
||||
nextQuery.returnTo = 'workbench'
|
||||
const returnTo = resolveDocumentDetailReturnTarget(options.returnTo)
|
||||
if (returnTo) {
|
||||
nextQuery.returnTo = returnTo
|
||||
} else {
|
||||
delete nextQuery.returnTo
|
||||
}
|
||||
@@ -583,12 +592,15 @@ export function useAppShell() {
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
if (detailReturnTarget.value === 'workbench') {
|
||||
router.push({ name: 'app-workbench' })
|
||||
return
|
||||
if (detailReturnTarget.value === 'conversation') {
|
||||
return router.push({ name: 'app-workbench' })
|
||||
}
|
||||
|
||||
router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
|
||||
if (detailReturnTarget.value === 'workbench') {
|
||||
return router.push({ name: 'app-workbench' })
|
||||
}
|
||||
|
||||
return router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
|
||||
}
|
||||
|
||||
async function handleRequestUpdated(payload = {}) {
|
||||
@@ -655,6 +667,7 @@ export function useAppShell() {
|
||||
smartEntrySessionId,
|
||||
detailAlerts,
|
||||
detailBackLabel,
|
||||
detailReturnTarget,
|
||||
toast,
|
||||
topBarView
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
fetchSystemDashboard
|
||||
} from '../services/analytics.js'
|
||||
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
|
||||
import { formatRiskSignalLabel } from '../utils/riskLabels.js'
|
||||
import {
|
||||
buildDigitalEmployeeCategoryRows,
|
||||
buildDigitalEmployeeDailyRows,
|
||||
@@ -14,6 +13,32 @@ import {
|
||||
buildDigitalEmployeeTaskRanking,
|
||||
emptyDigitalEmployeeDashboard
|
||||
} from '../views/scripts/overviewDigitalEmployeeDashboardModel.js'
|
||||
import {
|
||||
buildRiskDistributionLegend,
|
||||
emptyFinanceBudgetMetrics,
|
||||
emptyFinanceBudgetSummary,
|
||||
emptyFinanceDonut,
|
||||
emptyFinanceTotals,
|
||||
emptyFinanceTrend,
|
||||
emptySystemDashboardTotals,
|
||||
emptySystemLoginWave,
|
||||
formatCompact,
|
||||
formatCurrency,
|
||||
formatMetricValue,
|
||||
formatNumberCompact,
|
||||
formatPercent,
|
||||
formatRiskSignalName,
|
||||
formatSystemMetricValue as formatSystemMetricValueModel,
|
||||
isMissingDimension,
|
||||
resolveFinanceMetricMeta as resolveFinanceMetricMetaModel,
|
||||
resolveSystemMetricMeta as resolveSystemMetricMetaModel
|
||||
} from './overviewViewDisplayModel.js'
|
||||
import {
|
||||
aggregateRiskDailyTrendRows,
|
||||
DEFAULT_OVERVIEW_RANGE,
|
||||
resolveTopRangeDays,
|
||||
resolveTopRangeKey
|
||||
} from './overviewViewRangeModel.js'
|
||||
|
||||
import {
|
||||
metricBlueprints,
|
||||
@@ -33,186 +58,6 @@ import {
|
||||
systemToolDetailRows as fallbackSystemToolDetailRows
|
||||
} from '../data/metrics.js'
|
||||
|
||||
const DEFAULT_OVERVIEW_RANGE = '近10日'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const RISK_DAILY_TREND_MAX_BUCKETS = 14
|
||||
|
||||
const emptyFinanceTotals = {
|
||||
reimbursementAmount: 0,
|
||||
reimbursementCount: 0,
|
||||
pendingPaymentAmount: 0,
|
||||
avgClaimAmount: 0,
|
||||
budgetUsageRate: 0,
|
||||
paymentClearanceRate: 0
|
||||
}
|
||||
|
||||
const emptyFinanceTrend = {
|
||||
labels: [],
|
||||
claimCount: [],
|
||||
claimAmount: [],
|
||||
categoryAmountSeries: [],
|
||||
applications: [],
|
||||
approved: [],
|
||||
avgHours: []
|
||||
}
|
||||
|
||||
const emptyFinanceDonut = [
|
||||
{ name: '暂无数据', value: 0, color: '#cbd5e1' }
|
||||
]
|
||||
|
||||
const emptyFinanceBudgetSummary = {
|
||||
ratio: 0,
|
||||
total: '¥0',
|
||||
used: '¥0',
|
||||
left: '¥0'
|
||||
}
|
||||
|
||||
const emptyFinanceBudgetMetrics = [
|
||||
{ label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
|
||||
{ label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
|
||||
{ label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
|
||||
{ label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
|
||||
{ label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
|
||||
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
|
||||
]
|
||||
|
||||
const emptySystemDashboardTotals = {
|
||||
toolCalls: 0,
|
||||
modelTokens: 0,
|
||||
onlineUsers: 0,
|
||||
avgOnlineMinutes: 0,
|
||||
executionSuccessRate: 0,
|
||||
positiveFeedback: 0,
|
||||
negativeFeedback: 0,
|
||||
failedRuns: 0,
|
||||
toolCallsChange: 0,
|
||||
modelTokensChange: 0
|
||||
}
|
||||
|
||||
const emptySystemLoginWave = {
|
||||
labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
|
||||
loginUsers: Array.from({ length: 24 }, () => 0),
|
||||
interactions: Array.from({ length: 24 }, () => 0)
|
||||
}
|
||||
|
||||
function parseLocalDate(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function clampWindowDays(value) {
|
||||
const days = Number(value || 0)
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
return 10
|
||||
}
|
||||
return Math.max(1, Math.min(Math.round(days), 90))
|
||||
}
|
||||
|
||||
function resolveCustomRangeDays(customRange = {}) {
|
||||
const start = parseLocalDate(customRange.start)
|
||||
const end = parseLocalDate(customRange.end)
|
||||
if (!start || !end) {
|
||||
return 10
|
||||
}
|
||||
return clampWindowDays(Math.abs(end.getTime() - start.getTime()) / DAY_MS + 1)
|
||||
}
|
||||
|
||||
function resolveTopRangeDays(range, customRange = {}) {
|
||||
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
|
||||
if (key === 'custom') {
|
||||
return resolveCustomRangeDays(customRange)
|
||||
}
|
||||
if (key === '\u4eca\u65e5') {
|
||||
return 1
|
||||
}
|
||||
if (key === '\u672c\u5468') {
|
||||
const today = new Date()
|
||||
const weekday = today.getDay() || 7
|
||||
return clampWindowDays(weekday)
|
||||
}
|
||||
if (key === '\u672c\u6708') {
|
||||
return clampWindowDays(new Date().getDate())
|
||||
}
|
||||
const match = key.match(/\d+/)
|
||||
return clampWindowDays(match ? Number(match[0]) : 10)
|
||||
}
|
||||
|
||||
function resolveTopRangeKey(range, customRange = {}) {
|
||||
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
|
||||
if (key === 'custom') {
|
||||
return 'custom'
|
||||
}
|
||||
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
if (/\d+/.test(key)) {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
return key || DEFAULT_OVERVIEW_RANGE
|
||||
}
|
||||
|
||||
function formatRiskTrendDateLabel(value) {
|
||||
const date = parseLocalDate(value)
|
||||
if (!date) {
|
||||
return String(value || '-').trim() || '-'
|
||||
}
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${month}-${day}`
|
||||
}
|
||||
|
||||
function buildRiskTrendBucketLabel(first, last) {
|
||||
const start = String(first?.date || '').trim()
|
||||
const end = String(last?.date || '').trim()
|
||||
if (!start || start === end) {
|
||||
return formatRiskTrendDateLabel(start)
|
||||
}
|
||||
return `${formatRiskTrendDateLabel(start)}~${formatRiskTrendDateLabel(end)}`
|
||||
}
|
||||
|
||||
function normalizeRiskTrendRow(item) {
|
||||
return {
|
||||
date: String(item.date || '').trim() || '-',
|
||||
total: Number(item.total || 0),
|
||||
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateRiskDailyTrendRows(rows, maxBuckets = RISK_DAILY_TREND_MAX_BUCKETS) {
|
||||
const normalizedRows = rows
|
||||
.map(normalizeRiskTrendRow)
|
||||
.filter((item) => item.date !== '-' || item.total > 0 || item.highOrAbove > 0)
|
||||
|
||||
if (normalizedRows.length <= maxBuckets) {
|
||||
return normalizedRows.map((item) => ({
|
||||
...item,
|
||||
date: formatRiskTrendDateLabel(item.date),
|
||||
sourceStartDate: item.date,
|
||||
sourceEndDate: item.date
|
||||
}))
|
||||
}
|
||||
|
||||
const bucketSize = Math.ceil(normalizedRows.length / maxBuckets)
|
||||
const buckets = []
|
||||
for (let index = 0; index < normalizedRows.length; index += bucketSize) {
|
||||
const bucketRows = normalizedRows.slice(index, index + bucketSize)
|
||||
const first = bucketRows[0]
|
||||
const last = bucketRows[bucketRows.length - 1]
|
||||
buckets.push({
|
||||
date: buildRiskTrendBucketLabel(first, last),
|
||||
sourceStartDate: first?.date || '',
|
||||
sourceEndDate: last?.date || '',
|
||||
total: bucketRows.reduce((sum, item) => sum + item.total, 0),
|
||||
highOrAbove: bucketRows.reduce((sum, item) => sum + item.highOrAbove, 0)
|
||||
})
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
export function useOverviewView(options = {}) {
|
||||
const activeDashboardKey = computed(() => {
|
||||
const dashboard = String(options.dashboard || '').trim()
|
||||
@@ -254,44 +99,9 @@ export function useOverviewView(options = {}) {
|
||||
const digitalEmployeeDashboardError = ref(null)
|
||||
const digitalEmployeeDashboardLoaded = computed(() => Boolean(digitalEmployeeDashboardPayload.value))
|
||||
|
||||
const formatCompact = (value) => {
|
||||
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
|
||||
return `¥${value}`
|
||||
}
|
||||
|
||||
const formatCurrency = (value) => formatCompact(value)
|
||||
|
||||
const formatNumberCompact = (value) => {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
|
||||
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
|
||||
return `${Math.round(number)}`
|
||||
}
|
||||
|
||||
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
|
||||
|
||||
const formatMetricValue = (metric, value) => {
|
||||
if (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
|
||||
return formatCurrency(Math.round(value))
|
||||
}
|
||||
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
|
||||
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
const formatSystemMetricValue = (metric, value) => {
|
||||
const numericValue = Number(value || 0)
|
||||
if (metric.key === 'modelTokens') return formatNumberCompact(numericValue)
|
||||
if (metric.key === 'avgOnlineMinutes') return `${numericValue.toFixed(1)} ${metric.unit}`
|
||||
if (metric.key === 'executionSuccessRate') return `${numericValue.toFixed(1)}${metric.unit}`
|
||||
if (metric.key === 'positiveFeedback') {
|
||||
const negativeFeedback = Math.round(Number(systemDashboardTotals.value.negativeFeedback || 0))
|
||||
return `${Math.round(numericValue)} / ${negativeFeedback}`
|
||||
}
|
||||
if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
|
||||
return formatNumberCompact(numericValue)
|
||||
}
|
||||
const formatSystemMetricValue = (metric, value) => (
|
||||
formatSystemMetricValueModel(metric, value, systemDashboardTotals.value)
|
||||
)
|
||||
|
||||
const getFinanceRangeParams = () => {
|
||||
const activeRange = activeRangeValue.value
|
||||
@@ -556,68 +366,17 @@ export function useOverviewView(options = {}) {
|
||||
financeDashboardPayload.value?.budgetMetrics || emptyFinanceBudgetMetrics
|
||||
))
|
||||
|
||||
const resolveSystemMetricMeta = (metric) => {
|
||||
const totals = systemDashboardTotals.value
|
||||
const realDashboardLoaded = Boolean(systemDashboardPayload.value)
|
||||
const resolveSystemMetricMeta = (metric) => (
|
||||
resolveSystemMetricMetaModel(metric, systemDashboardTotals.value, Boolean(systemDashboardPayload.value))
|
||||
)
|
||||
|
||||
if (!realDashboardLoaded) {
|
||||
return {
|
||||
changeText: metric.change,
|
||||
delta: metric.delta,
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'toolCalls' || metric.key === 'modelTokens') {
|
||||
const changeValue = Number(totals[`${metric.key}Change`] || 0)
|
||||
return {
|
||||
changeText: `${changeValue >= 0 ? '+' : ''}${changeValue.toFixed(1)}%`,
|
||||
delta: '较上一周期',
|
||||
trend: changeValue < 0 ? 'down' : 'up'
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'executionSuccessRate') {
|
||||
const errorRate = Math.max(0, 100 - Number(totals.executionSuccessRate || 0))
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: `错误率 ${errorRate.toFixed(1)}%`,
|
||||
trend: 'up'
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'positiveFeedback') {
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: `差评 ${Math.round(Number(totals.negativeFeedback || 0))} 次`,
|
||||
trend: 'up'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: metric.key === 'onlineUsers' ? '活跃会话' : '按最近会话统计',
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
const resolveFinanceMetricMeta = (metric) => {
|
||||
const meta = financeMetricMeta.value[metric.key]
|
||||
|
||||
if (!financeDashboardPayload.value || !meta) {
|
||||
return {
|
||||
changeText: financeDashboardLoading.value ? '加载中' : '实时',
|
||||
delta: financeDashboardError.value ? '真实数据加载失败' : '等待真实数据',
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeText: meta.changeText || metric.change,
|
||||
delta: meta.delta || metric.delta,
|
||||
trend: meta.trend || metric.trend
|
||||
}
|
||||
}
|
||||
const resolveFinanceMetricMeta = (metric) => resolveFinanceMetricMetaModel({
|
||||
metric,
|
||||
meta: financeMetricMeta.value[metric.key],
|
||||
dashboardLoaded: Boolean(financeDashboardPayload.value),
|
||||
loading: financeDashboardLoading.value,
|
||||
error: financeDashboardError.value
|
||||
})
|
||||
|
||||
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
|
||||
const rawValue = Number(financeDashboardTotals.value[metric.key] || 0)
|
||||
@@ -919,39 +678,6 @@ export function useOverviewView(options = {}) {
|
||||
const digitalEmployeeTaskRanking = computed(() => buildDigitalEmployeeTaskRanking(digitalEmployeeDashboard.value))
|
||||
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
|
||||
|
||||
function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
|
||||
const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
|
||||
const entries = Object.entries(distribution || {})
|
||||
.filter(([, value]) => Number(value || 0) > 0)
|
||||
|
||||
if (!entries.length) {
|
||||
return [
|
||||
{
|
||||
name: '暂无数据',
|
||||
value: 1,
|
||||
display: '0项',
|
||||
color: '#cbd5e1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return entries.map(([key, value], index) => ({
|
||||
name: labels[key] || formatter(key),
|
||||
value: Number(value || 0),
|
||||
display: `${Number(value || 0)}项`,
|
||||
color: colors[key] || fallbackColors[index % fallbackColors.length]
|
||||
}))
|
||||
}
|
||||
|
||||
function formatRiskSignalName(value) {
|
||||
return formatRiskSignalLabel(value)
|
||||
}
|
||||
|
||||
function isMissingDimension(value) {
|
||||
const text = String(value || '').trim()
|
||||
return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
|
||||
}
|
||||
|
||||
const bottlenecks = financeBottlenecks
|
||||
const budgetSummary = financeBudgetSummary
|
||||
const budgetMetrics = financeBudgetMetrics
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,787 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../useSystemState.js'
|
||||
import { useToast } from '../useToast.js'
|
||||
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
|
||||
import { fetchSettings } from '../../services/settings.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
|
||||
import {
|
||||
deleteAiWorkbenchConversation,
|
||||
loadAiWorkbenchConversationHistory,
|
||||
saveAiWorkbenchConversation
|
||||
} from '../../utils/aiWorkbenchConversationStore.js'
|
||||
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
|
||||
import {
|
||||
buildAiDocumentDetailRequest,
|
||||
parseAiApplicationDetailHref,
|
||||
parseAiDocumentDetailHref
|
||||
} from '../../utils/aiDocumentDetailReference.js'
|
||||
import {
|
||||
AI_MODE_ACTION_ITEMS,
|
||||
buildSelectedFileCards,
|
||||
shouldRunAiAttachmentAutoAssociation
|
||||
} from './workbenchAiComposerModel.js'
|
||||
import {
|
||||
createWorkbenchAiMessageRuntime,
|
||||
formatMessageTime,
|
||||
normalizeInlineAttachmentOcrDetails
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
|
||||
import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
|
||||
import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicationPreviewFlow.js'
|
||||
import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
|
||||
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
|
||||
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
|
||||
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
|
||||
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
|
||||
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
||||
|
||||
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
|
||||
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
|
||||
const INLINE_ANSWER_STREAM_DELAY_MS = 24
|
||||
const INLINE_AUTO_SCROLL_THRESHOLD = 96
|
||||
const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260
|
||||
|
||||
export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const assistantDraft = ref('')
|
||||
const assistantInputRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const conversationScrollRef = ref(null)
|
||||
const inlineConversationAutoScrollPinned = ref(true)
|
||||
const selectedFiles = ref([])
|
||||
const systemSettings = ref(null)
|
||||
const conversationStarted = ref(false)
|
||||
const conversationMessages = ref([])
|
||||
const conversationId = ref('')
|
||||
const activeConversationTitle = ref('')
|
||||
const sending = ref(false)
|
||||
const stewardState = ref(null)
|
||||
const aiExpenseDraft = ref(null)
|
||||
const thinkingExpandedMessageIds = ref(new Set())
|
||||
const thinkingCollapsedMessageIds = ref(new Set())
|
||||
const attachmentOcrExpandedMessageIds = ref(new Set())
|
||||
const deleteDialogOpen = ref(false)
|
||||
const applicationSubmitConfirmOpen = ref(false)
|
||||
const applicationSubmitConfirmContext = ref(null)
|
||||
const aiAttachmentAssociationRuntime = new Map()
|
||||
const messageRuntime = createWorkbenchAiMessageRuntime()
|
||||
const {
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
normalizeRuntimeMessage,
|
||||
serializeRuntimeMessage
|
||||
} = messageRuntime
|
||||
|
||||
const {
|
||||
applicationPreviewEditor,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
refreshApplicationPreviewEstimate,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
} = useApplicationPreviewEditor({
|
||||
persistSessionState: () => persistCurrentConversation(),
|
||||
toast,
|
||||
calculateTravelReimbursement,
|
||||
currentUser
|
||||
})
|
||||
|
||||
const {
|
||||
workbenchDatePickerOpen,
|
||||
workbenchDateMode,
|
||||
workbenchSingleDate,
|
||||
workbenchRangeStartDate,
|
||||
workbenchRangeEndDate,
|
||||
workbenchDateTagLabel,
|
||||
workbenchCanApplyDateSelection,
|
||||
clearWorkbenchDateSelection,
|
||||
toggleWorkbenchDatePicker,
|
||||
closeWorkbenchDatePicker,
|
||||
setWorkbenchDateMode,
|
||||
handleWorkbenchDatePickerOutside,
|
||||
applyWorkbenchDateSelection,
|
||||
handleWorkbenchDateInputChange,
|
||||
removeWorkbenchDateTag,
|
||||
buildWorkbenchPromptText
|
||||
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
|
||||
|
||||
const aiModeActionItems = AI_MODE_ACTION_ITEMS
|
||||
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
|
||||
const displayUserName = computed(() => {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.name || user.username || '同事').trim() || '同事'
|
||||
})
|
||||
const displayModelName = computed(() => {
|
||||
const llmForm = systemSettings.value?.llmForm
|
||||
if (!llmForm) return 'Axiom Ultra 3.1'
|
||||
const model = llmForm.mainModel || ''
|
||||
const provider = llmForm.mainProvider || ''
|
||||
if (!model) return 'Axiom Ultra 3.1'
|
||||
return provider ? `${provider} / ${model}` : model
|
||||
})
|
||||
const modelSelectorTitle = computed(() => {
|
||||
const llmForm = systemSettings.value?.llmForm
|
||||
if (!llmForm) return '当前模型:Axiom Ultra 3.1'
|
||||
const model = llmForm.mainModel || 'Axiom Ultra 3.1'
|
||||
const provider = llmForm.mainProvider || ''
|
||||
return provider ? `当前模型:${provider} / ${model}` : `当前模型:${model}`
|
||||
})
|
||||
|
||||
const filesFlow = useWorkbenchAiComposerFiles({
|
||||
fileInputRef,
|
||||
focusAiModeInput,
|
||||
isInputLocked: () => isAiModeInputLocked.value,
|
||||
selectedFiles,
|
||||
toast
|
||||
})
|
||||
|
||||
const documentQueryFlow = useWorkbenchAiDocumentQueryFlow({
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
scrollInlineConversationToBottom
|
||||
})
|
||||
|
||||
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
|
||||
aiAttachmentAssociationRuntime,
|
||||
conversationMessages,
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
streamOrSetInlineAssistantContent,
|
||||
toast
|
||||
})
|
||||
|
||||
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
|
||||
activateInlineConversation,
|
||||
applicationPreviewEditor,
|
||||
applicationSubmitConfirmContext,
|
||||
applicationSubmitConfirmOpen,
|
||||
assistantDraft,
|
||||
cancelApplicationPreviewEditor,
|
||||
clearAiModeFiles: filesFlow.clearAiModeFiles,
|
||||
closeWorkbenchDatePicker,
|
||||
commitApplicationPreviewEditor,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
handleApplicationPreviewEditorKeydown,
|
||||
inlineConversationAutoScrollPinned,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
persistCurrentConversation,
|
||||
pushInlineApplicationActionUserMessage,
|
||||
pushInlineUserMessage,
|
||||
refreshApplicationPreviewEstimate,
|
||||
removeWorkbenchDateTag,
|
||||
replaceInlineMessage,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineThinkingEvents,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
toast
|
||||
})
|
||||
|
||||
const expenseFlow = useWorkbenchAiExpenseFlow({
|
||||
activateInlineConversation,
|
||||
aiExpenseDraft,
|
||||
assistantDraft,
|
||||
clearAiModeFiles: filesFlow.clearAiModeFiles,
|
||||
closeWorkbenchDatePicker,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
persistCurrentConversation,
|
||||
pushInlineUserMessage,
|
||||
removeWorkbenchDateTag,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
startAiApplicationPreview: applicationFlow.startAiApplicationPreview
|
||||
})
|
||||
|
||||
const actionRouter = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft,
|
||||
applicationFlow,
|
||||
assistantDraft,
|
||||
attachmentFlow,
|
||||
emit,
|
||||
expenseFlow,
|
||||
focusAiModeInput,
|
||||
hasInlineAttachmentOcrDetails,
|
||||
resolveLatestInlineUserPrompt,
|
||||
selectedFiles,
|
||||
startInlineConversation,
|
||||
toast,
|
||||
toggleInlineAttachmentOcrDetails
|
||||
})
|
||||
|
||||
const sessionCommands = useWorkbenchAiSessionCommands({
|
||||
activeConversationTitle,
|
||||
attachmentOcrExpandedMessageIds,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
deleteAiWorkbenchConversation,
|
||||
emit,
|
||||
focusAiModeInput,
|
||||
inlineConversationAutoScrollPinned,
|
||||
normalizeRuntimeMessage,
|
||||
refreshConversationHistory,
|
||||
resetInlineConversationState,
|
||||
scrollInlineConversationToBottom,
|
||||
stewardState,
|
||||
thinkingCollapsedMessageIds,
|
||||
thinkingExpandedMessageIds,
|
||||
toast
|
||||
})
|
||||
|
||||
const messageActions = useWorkbenchAiMessageActions({
|
||||
assistantDraft,
|
||||
focusAiModeInput,
|
||||
persistCurrentConversation,
|
||||
toast
|
||||
})
|
||||
|
||||
const stewardFlow = useWorkbenchAiStewardFlow({
|
||||
activeConversationTitle,
|
||||
collectAiModeReceiptContext: attachmentFlow.collectAiModeReceiptContext,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
deleteAiWorkbenchConversation,
|
||||
emit,
|
||||
handleAiDocumentQueryIntent: documentQueryFlow.handleAiDocumentQueryIntent,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
resolveInlineThinkingEvents,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
stewardState,
|
||||
streamInlineAssistantContent,
|
||||
updateInlineMessageContent,
|
||||
appendInlineMessageContent,
|
||||
toast
|
||||
})
|
||||
|
||||
const applicationPreviewEstimatePending = computed(() => (
|
||||
conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
|
||||
))
|
||||
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value)
|
||||
const canSubmitAiModePrompt = computed(() => (
|
||||
!isAiModeInputLocked.value && (
|
||||
Boolean(assistantDraft.value.trim()) ||
|
||||
selectedFiles.value.length > 0 ||
|
||||
Boolean(workbenchDateTagLabel.value)
|
||||
)
|
||||
))
|
||||
|
||||
async function loadSystemSettings() {
|
||||
try {
|
||||
systemSettings.value = await fetchSettings()
|
||||
} catch {
|
||||
systemSettings.value = { llmForm: {} }
|
||||
}
|
||||
}
|
||||
|
||||
function focusAiModeInput() {
|
||||
nextTick(() => {
|
||||
assistantInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function isInlineConversationNearBottom() {
|
||||
const el = conversationScrollRef.value
|
||||
if (!el) {
|
||||
return true
|
||||
}
|
||||
return el.scrollHeight - el.clientHeight - el.scrollTop <= INLINE_AUTO_SCROLL_THRESHOLD
|
||||
}
|
||||
|
||||
function handleInlineConversationScroll() {
|
||||
inlineConversationAutoScrollPinned.value = isInlineConversationNearBottom()
|
||||
}
|
||||
|
||||
function forceInlineConversationToBottom() {
|
||||
const el = conversationScrollRef.value
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
inlineConversationAutoScrollPinned.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function scrollInlineConversationToBottom(options = {}) {
|
||||
const shouldScroll = options.force !== false
|
||||
nextTick(() => {
|
||||
if (!shouldScroll) {
|
||||
return
|
||||
}
|
||||
forceInlineConversationToBottom()
|
||||
window.requestAnimationFrame(() => {
|
||||
forceInlineConversationToBottom()
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
if (inlineConversationAutoScrollPinned.value) {
|
||||
forceInlineConversationToBottom()
|
||||
}
|
||||
}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function scrollInlineConversationToTop() {
|
||||
nextTick(() => {
|
||||
const el = conversationScrollRef.value
|
||||
if (el) {
|
||||
inlineConversationAutoScrollPinned.value = false
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateInlineMessageContent(message, content) {
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
message.content = String(content || '')
|
||||
message.paragraphs = String(message.content || '')
|
||||
.split(/\n{2,}|\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function appendInlineMessageContent(message, delta) {
|
||||
const nextDelta = String(delta || '')
|
||||
if (!nextDelta) {
|
||||
return
|
||||
}
|
||||
updateInlineMessageContent(message, `${message.content || ''}${nextDelta}`)
|
||||
}
|
||||
|
||||
function waitInlineAnswerStreamFrame() {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, INLINE_ANSWER_STREAM_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
async function streamInlineAssistantContent(messageId, content) {
|
||||
const targetContent = String(content || '').trim()
|
||||
let streamedContent = ''
|
||||
|
||||
for (let index = 0; index < targetContent.length; index += INLINE_ANSWER_STREAM_CHUNK_SIZE) {
|
||||
const message = conversationMessages.value.find((item) => item.id === messageId)
|
||||
if (!message || !message.pending) {
|
||||
return
|
||||
}
|
||||
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
|
||||
streamedContent += targetContent.slice(index, index + INLINE_ANSWER_STREAM_CHUNK_SIZE)
|
||||
updateInlineMessageContent(message, streamedContent)
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScroll })
|
||||
await waitInlineAnswerStreamFrame()
|
||||
}
|
||||
}
|
||||
|
||||
async function streamOrSetInlineAssistantContent(messageId, content) {
|
||||
const targetContent = String(content || '').trim()
|
||||
if (/<!--\s*ai-trusted-html:start\s*-->/.test(targetContent)) {
|
||||
const message = conversationMessages.value.find((item) => item.id === messageId)
|
||||
if (message?.pending) {
|
||||
updateInlineMessageContent(message, targetContent)
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
}
|
||||
return
|
||||
}
|
||||
await streamInlineAssistantContent(messageId, targetContent)
|
||||
}
|
||||
|
||||
function refreshConversationHistory() {
|
||||
const history = loadAiWorkbenchConversationHistory(currentUser.value || {})
|
||||
emit('conversation-history-change', history)
|
||||
return history
|
||||
}
|
||||
|
||||
function isPersistableInlineConversation() {
|
||||
return Boolean(
|
||||
conversationId.value &&
|
||||
conversationId.value !== AI_SEARCH_CONVERSATION_ID &&
|
||||
conversationMessages.value.length
|
||||
)
|
||||
}
|
||||
|
||||
function persistCurrentConversation() {
|
||||
if (!isPersistableInlineConversation()) {
|
||||
refreshConversationHistory()
|
||||
return []
|
||||
}
|
||||
|
||||
const history = saveAiWorkbenchConversation(currentUser.value || {}, {
|
||||
id: conversationId.value,
|
||||
conversationId: conversationId.value,
|
||||
title: activeConversationTitle.value,
|
||||
source: 'workbench',
|
||||
sessionType: 'steward',
|
||||
stewardState: stewardState.value,
|
||||
messages: conversationMessages.value.map((message) => serializeRuntimeMessage(message))
|
||||
})
|
||||
emit('conversation-history-change', history)
|
||||
return history
|
||||
}
|
||||
|
||||
function resetInlineConversationState() {
|
||||
conversationStarted.value = false
|
||||
conversationMessages.value = []
|
||||
conversationId.value = ''
|
||||
stewardState.value = null
|
||||
activeConversationTitle.value = ''
|
||||
assistantDraft.value = ''
|
||||
thinkingExpandedMessageIds.value = new Set()
|
||||
thinkingCollapsedMessageIds.value = new Set()
|
||||
attachmentOcrExpandedMessageIds.value = new Set()
|
||||
deleteDialogOpen.value = false
|
||||
applicationSubmitConfirmOpen.value = false
|
||||
applicationSubmitConfirmContext.value = null
|
||||
clearWorkbenchDateSelection()
|
||||
filesFlow.clearAiModeFiles()
|
||||
}
|
||||
|
||||
function replaceInlineMessage(id, nextMessage) {
|
||||
const index = conversationMessages.value.findIndex((item) => item.id === id)
|
||||
if (index === -1) {
|
||||
conversationMessages.value.push(nextMessage)
|
||||
return
|
||||
}
|
||||
conversationMessages.value.splice(index, 1, nextMessage)
|
||||
}
|
||||
|
||||
function activateInlineConversation(options = {}) {
|
||||
conversationStarted.value = true
|
||||
if (!conversationId.value) {
|
||||
conversationId.value = options.id || `inline-${Date.now()}`
|
||||
}
|
||||
activeConversationTitle.value = options.title || activeConversationTitle.value || '新对话'
|
||||
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
|
||||
}
|
||||
|
||||
function renderInlineConversationHtml(content) {
|
||||
return renderAiConversationHtml(content)
|
||||
}
|
||||
|
||||
function resolveInlineThinkingEvents(message) {
|
||||
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
|
||||
}
|
||||
|
||||
function hasInlineThinking(message) {
|
||||
return resolveInlineThinkingEvents(message).length > 0
|
||||
}
|
||||
|
||||
function isInlineThinkingExpanded(message) {
|
||||
if (!message?.id) {
|
||||
return Boolean(message?.pending)
|
||||
}
|
||||
if (thinkingCollapsedMessageIds.value.has(message.id)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
|
||||
}
|
||||
|
||||
function toggleInlineThinking(message) {
|
||||
if (!message?.id) {
|
||||
return
|
||||
}
|
||||
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
|
||||
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
|
||||
if (isInlineThinkingExpanded(message)) {
|
||||
nextExpandedIds.delete(message.id)
|
||||
nextCollapsedIds.add(message.id)
|
||||
} else {
|
||||
nextCollapsedIds.delete(message.id)
|
||||
nextExpandedIds.add(message.id)
|
||||
}
|
||||
thinkingExpandedMessageIds.value = nextExpandedIds
|
||||
thinkingCollapsedMessageIds.value = nextCollapsedIds
|
||||
}
|
||||
|
||||
function hasInlineAttachmentOcrDetails(message = {}) {
|
||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
||||
return Boolean(details?.documents?.length || details?.fileNames?.length)
|
||||
}
|
||||
|
||||
function resolveInlineAttachmentOcrDocuments(message = {}) {
|
||||
return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
|
||||
}
|
||||
|
||||
function resolveInlineAttachmentOcrFileCount(message = {}) {
|
||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
||||
return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
|
||||
}
|
||||
|
||||
function isInlineAttachmentOcrExpanded(message = {}) {
|
||||
return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
|
||||
}
|
||||
|
||||
function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
|
||||
if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
|
||||
return
|
||||
}
|
||||
const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
|
||||
const shouldExpand = forceExpanded === null
|
||||
? !nextExpandedIds.has(message.id)
|
||||
: Boolean(forceExpanded)
|
||||
if (shouldExpand) {
|
||||
nextExpandedIds.add(message.id)
|
||||
} else {
|
||||
nextExpandedIds.delete(message.id)
|
||||
}
|
||||
attachmentOcrExpandedMessageIds.value = nextExpandedIds
|
||||
nextTick(() => {
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
})
|
||||
}
|
||||
|
||||
function buildInlinePromptText(rawPrompt, files = []) {
|
||||
const prompt = buildWorkbenchPromptText(rawPrompt)
|
||||
if (prompt) {
|
||||
return prompt
|
||||
}
|
||||
return files.length ? '请帮我处理已上传的附件。' : ''
|
||||
}
|
||||
|
||||
function handleAiAnswerMarkdownClick(event) {
|
||||
const target = event?.target
|
||||
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
||||
if (!link) {
|
||||
return
|
||||
}
|
||||
const href = link.getAttribute('href')
|
||||
const detailReference = parseAiDocumentDetailHref(href) || parseAiApplicationDetailHref(href)
|
||||
if (!detailReference) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('open-document', buildAiDocumentDetailRequest(detailReference))
|
||||
}
|
||||
|
||||
function startInlineConversation(prompt, entry = {}, files = []) {
|
||||
if (isAiModeInputLocked.value) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
const cleanPrompt = buildInlinePromptText(prompt, files)
|
||||
if (!cleanPrompt || sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
|
||||
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
|
||||
return
|
||||
}
|
||||
|
||||
if (applicationFlow.handleInlineApplicationPreviewTextAction(cleanPrompt, applicationPreviewEstimatePending)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
|
||||
conversationId.value = ''
|
||||
conversationMessages.value = []
|
||||
activeConversationTitle.value = ''
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
activateInlineConversation({
|
||||
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
|
||||
})
|
||||
inlineConversationAutoScrollPinned.value = true
|
||||
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
filesFlow.clearAiModeFiles()
|
||||
scrollInlineConversationToBottom()
|
||||
persistCurrentConversation()
|
||||
if (shouldRunAiAttachmentAutoAssociation(entry, files, cleanPrompt)) {
|
||||
void attachmentFlow.requestAiAttachmentAssociationReply(cleanPrompt, entry, files)
|
||||
return
|
||||
}
|
||||
void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files)
|
||||
}
|
||||
|
||||
function submitAiModePrompt() {
|
||||
if (!canSubmitAiModePrompt.value) {
|
||||
toast('请输入需求后再发送。')
|
||||
focusAiModeInput()
|
||||
return
|
||||
}
|
||||
startInlineConversation(assistantDraft.value, { source: 'workbench', sessionType: 'steward' }, Array.from(selectedFiles.value))
|
||||
}
|
||||
|
||||
function runAiModeAction(item) {
|
||||
if (String(item?.label || '').trim() === '发起报销') {
|
||||
expenseFlow.pushInlineExpenseSceneSelectionPrompt(item.prompt, item.label)
|
||||
return
|
||||
}
|
||||
startInlineConversation(item.prompt, item, Array.from(selectedFiles.value))
|
||||
}
|
||||
|
||||
function regenerateLastReply() {
|
||||
const lastUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
|
||||
if (!lastUserMessage || sending.value) {
|
||||
return
|
||||
}
|
||||
const lastAssistantIndex = conversationMessages.value.map((message) => message.role).lastIndexOf('assistant')
|
||||
if (lastAssistantIndex >= 0) {
|
||||
conversationMessages.value.splice(lastAssistantIndex, 1)
|
||||
}
|
||||
sending.value = true
|
||||
void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
|
||||
}
|
||||
|
||||
function pushInlineUserMessage(text) {
|
||||
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
|
||||
}
|
||||
|
||||
function pushInlineApplicationActionUserMessage(text) {
|
||||
pushInlineUserMessage(text)
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
filesFlow.clearAiModeFiles()
|
||||
}
|
||||
|
||||
function resolveLatestInlineUserPrompt() {
|
||||
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
|
||||
return String(latestUserMessage?.content || '').trim()
|
||||
}
|
||||
|
||||
function handleVoiceInput() {
|
||||
if (isAiModeInputLocked.value) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
toast('语音输入正在准备中,您可以先输入文字需求。')
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.sidebarCommand?.seq,
|
||||
() => {
|
||||
const command = props.sidebarCommand || {}
|
||||
if (command.type === 'new-chat') {
|
||||
sessionCommands.startNewInlineConversation()
|
||||
return
|
||||
}
|
||||
if (command.type === 'search-chat') {
|
||||
sessionCommands.openInlineSearchConversation(activateInlineConversation)
|
||||
return
|
||||
}
|
||||
if (command.type === 'open-recent') {
|
||||
sessionCommands.openInlineRecentConversation(command.payload || {})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadSystemSettings()
|
||||
refreshConversationHistory()
|
||||
document.addEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
})
|
||||
|
||||
return {
|
||||
activeConversationTitle,
|
||||
aiModeActionItems,
|
||||
applicationPreviewEditor,
|
||||
applicationSubmitConfirmOpen,
|
||||
assistantInputRef,
|
||||
assistantDraft,
|
||||
canShowInlineSuggestedActions: applicationFlow.canShowInlineSuggestedActions,
|
||||
canSubmitAiModePrompt,
|
||||
cancelDeleteConversation: () => sessionCommands.cancelDeleteConversation(deleteDialogOpen),
|
||||
cancelInlineApplicationSubmitConfirm: applicationFlow.cancelInlineApplicationSubmitConfirm,
|
||||
clearWorkbenchDateSelection,
|
||||
commitInlineApplicationPreviewEditor: applicationFlow.commitInlineApplicationPreviewEditor,
|
||||
confirmDeleteConversation: () => sessionCommands.confirmDeleteConversation(deleteDialogOpen),
|
||||
confirmInlineApplicationSubmit: applicationFlow.confirmInlineApplicationSubmit,
|
||||
conversationMessages,
|
||||
conversationScrollRef,
|
||||
conversationStarted,
|
||||
deleteDialogOpen,
|
||||
displayModelName,
|
||||
displayUserName,
|
||||
fileInputRef,
|
||||
handleAiAnswerMarkdownClick,
|
||||
handleAiModeFilesChange: filesFlow.handleAiModeFilesChange,
|
||||
handleInlineApplicationPreviewEditorKeydown: applicationFlow.handleInlineApplicationPreviewEditorKeydown,
|
||||
handleInlineConversationScroll,
|
||||
handleInlineSuggestedAction: actionRouter.handleInlineSuggestedAction,
|
||||
handleVoiceInput,
|
||||
hasInlineAttachmentOcrDetails,
|
||||
hasInlineThinking,
|
||||
isAiModeInputLocked,
|
||||
isApplicationPreviewEditing: applicationFlow.isApplicationPreviewEditing,
|
||||
isApplicationPreviewEstimatePending: applicationFlow.isApplicationPreviewEstimatePending,
|
||||
isInlineAttachmentOcrExpanded,
|
||||
isInlineSuggestedActionDisabled: applicationFlow.isInlineSuggestedActionDisabled,
|
||||
isInlineThinkingExpanded,
|
||||
markInlineMessageFeedback: messageActions.markInlineMessageFeedback,
|
||||
modelSelectorTitle,
|
||||
openApplicationPreviewEditor: applicationFlow.openApplicationPreviewEditor,
|
||||
quoteInlineMessage: messageActions.quoteInlineMessage,
|
||||
regenerateLastReply,
|
||||
removeAiModeFile: filesFlow.removeAiModeFile,
|
||||
removeWorkbenchDateTag,
|
||||
renderInlineConversationHtml,
|
||||
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
|
||||
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
|
||||
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
|
||||
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
|
||||
resolveInlineAttachmentOcrDocuments,
|
||||
resolveInlineAttachmentOcrFileCount,
|
||||
resolveInlineThinkingEvents,
|
||||
runAiModeAction,
|
||||
scrollInlineConversationToTop,
|
||||
selectedFileCards,
|
||||
sending,
|
||||
setWorkbenchDateMode,
|
||||
submitAiModePrompt,
|
||||
toggleInlineAttachmentOcrDetails,
|
||||
toggleInlineThinking,
|
||||
toggleWorkbenchDatePicker,
|
||||
triggerAiModeFileUpload: filesFlow.triggerAiModeFileUpload,
|
||||
workbenchCanApplyDateSelection,
|
||||
workbenchDateMode,
|
||||
workbenchDatePickerOpen,
|
||||
workbenchDateTagLabel,
|
||||
workbenchRangeEndDate,
|
||||
workbenchRangeStartDate,
|
||||
workbenchSingleDate,
|
||||
applyWorkbenchDateSelection,
|
||||
buildInlineApplicationPreviewFooterText: applicationFlow.buildInlineApplicationPreviewFooterText,
|
||||
copyInlineMessage: messageActions.copyInlineMessage,
|
||||
formatMessageTime,
|
||||
handleWorkbenchDateInputChange
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
} from '../../utils/assistantSuggestedActionPrefill.js'
|
||||
import {
|
||||
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
|
||||
AI_ATTACHMENT_OCR_DETAIL_ACTION
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
|
||||
|
||||
export function useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft,
|
||||
applicationFlow,
|
||||
assistantDraft,
|
||||
attachmentFlow,
|
||||
emit,
|
||||
expenseFlow,
|
||||
focusAiModeInput,
|
||||
hasInlineAttachmentOcrDetails,
|
||||
resolveLatestInlineUserPrompt,
|
||||
selectedFiles,
|
||||
startInlineConversation,
|
||||
toast,
|
||||
toggleInlineAttachmentOcrDetails
|
||||
}) {
|
||||
function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
|
||||
const prefillText = resolveSuggestedActionPrefill(action)
|
||||
if (prefillText) {
|
||||
assistantDraft.value = mergeComposerPrefill(assistantDraft.value, prefillText)
|
||||
focusAiModeInput()
|
||||
return
|
||||
}
|
||||
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
if (actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION) {
|
||||
if (!hasInlineAttachmentOcrDetails(sourceMessage)) {
|
||||
toast('当前消息没有可查看的附件识别明细。')
|
||||
return
|
||||
}
|
||||
toggleInlineAttachmentOcrDetails(sourceMessage, true)
|
||||
return
|
||||
}
|
||||
if (actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION) {
|
||||
if (applicationFlow.isInlineSuggestedActionDisabled(action, sourceMessage)) {
|
||||
return
|
||||
}
|
||||
void attachmentFlow.confirmAiAttachmentAssociation(actionPayload, sourceMessage)
|
||||
return
|
||||
}
|
||||
if ([AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType)) {
|
||||
if (applicationFlow.isInlineSuggestedActionDisabled(action, sourceMessage)) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
void applicationFlow.executeInlineApplicationPreviewAction(actionType, sourceMessage, {
|
||||
userText: action.label,
|
||||
draftPayload: actionPayload.draftPayload || actionPayload.draft_payload || null
|
||||
})
|
||||
return
|
||||
}
|
||||
if (actionType === 'open_application_detail') {
|
||||
const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
|
||||
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
|
||||
emit('open-document', buildAiDocumentDetailRequest({
|
||||
reference: claimNo || claimId,
|
||||
claimId,
|
||||
claimNo
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
|
||||
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
|
||||
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
|
||||
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true)
|
||||
return
|
||||
}
|
||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
|
||||
aiExpenseDraft.value = null
|
||||
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
|
||||
return
|
||||
}
|
||||
if (actionType === 'select_expense_type') {
|
||||
const expenseType = String(action?.payload?.expense_type || '').trim()
|
||||
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
|
||||
const requiresApplicationBeforeReimbursement = Boolean(action?.payload?.requires_application_before_reimbursement)
|
||||
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'select_required_application') {
|
||||
expenseFlow.linkAiExpenseApplication(action?.payload || {})
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'ai_application_start_inline') {
|
||||
aiExpenseDraft.value = null
|
||||
void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
|
||||
return
|
||||
}
|
||||
|
||||
const carryText = String(action?.payload?.carry_text || action?.label || '').trim()
|
||||
if (!carryText) {
|
||||
return
|
||||
}
|
||||
if (String(action?.payload?.session_type || '').trim() === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
||||
expenseFlow.pushInlineExpenseSceneSelectionPrompt(carryText, action.label)
|
||||
return
|
||||
}
|
||||
startInlineConversation(carryText, {
|
||||
label: action.label,
|
||||
source: 'steward-action',
|
||||
sessionType: action?.payload?.session_type || 'steward'
|
||||
}, action?.payload?.carry_files ? Array.from(selectedFiles.value) : [])
|
||||
}
|
||||
|
||||
return {
|
||||
handleInlineSuggestedAction
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import {
|
||||
buildApplicationPreviewFooterMessage,
|
||||
buildApplicationPreviewRows,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
buildAiApplicationPrecheck,
|
||||
buildAiApplicationSubmitConflictMessage,
|
||||
isAiApplicationPrecheckBlocking
|
||||
} from '../../utils/aiApplicationPrecheckModel.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
runAiApplicationPreviewAction
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
import {
|
||||
buildFailedInlineApplicationSubmitThinkingEvents,
|
||||
buildInitialInlineApplicationSubmitThinkingEvents,
|
||||
buildInlineApplicationDetailAction,
|
||||
buildInlineApplicationPreview,
|
||||
buildInlineApplicationPreviewActionResultText,
|
||||
buildInlineApplicationSubmitPrecheckPayload,
|
||||
buildInlineApplicationSubmitThinkingEvents,
|
||||
completeInlineThinkingEvents,
|
||||
extractInlineApplicationDraftPayload,
|
||||
resolveInlineApplicationPreviewActionFromText
|
||||
} from './workbenchAiApplicationPreviewModel.js'
|
||||
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
|
||||
|
||||
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
|
||||
const fields = normalizeApplicationPreview(applicationPreview).fields || {}
|
||||
return [
|
||||
fields.transportPolicy,
|
||||
fields.policyEstimate,
|
||||
fields.transportEstimatedAmount,
|
||||
fields.amount
|
||||
].some((value) => /正在|查询中/.test(String(value || '')))
|
||||
}
|
||||
|
||||
function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
|
||||
return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim())
|
||||
}
|
||||
|
||||
export function useWorkbenchAiApplicationPreviewFlow({
|
||||
activateInlineConversation,
|
||||
applicationPreviewEditor,
|
||||
applicationSubmitConfirmContext,
|
||||
applicationSubmitConfirmOpen,
|
||||
assistantDraft,
|
||||
cancelApplicationPreviewEditor,
|
||||
clearAiModeFiles,
|
||||
closeWorkbenchDatePicker,
|
||||
commitApplicationPreviewEditor: commitBaseApplicationPreviewEditor,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
handleApplicationPreviewEditorKeydown,
|
||||
inlineConversationAutoScrollPinned,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
persistCurrentConversation,
|
||||
pushInlineApplicationActionUserMessage,
|
||||
pushInlineUserMessage,
|
||||
refreshApplicationPreviewEstimate,
|
||||
removeWorkbenchDateTag,
|
||||
replaceInlineMessage,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineThinkingEvents,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
toast
|
||||
}) {
|
||||
function isApplicationPreviewEstimatePending(message = {}) {
|
||||
return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
|
||||
}
|
||||
|
||||
function canShowInlineSuggestedActions(message = {}) {
|
||||
return Boolean(message?.suggestedActions?.length) && !isApplicationPreviewEstimatePending(message)
|
||||
}
|
||||
|
||||
function isInlineSuggestedActionDisabled(action = {}, message = {}) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
return (
|
||||
Boolean(action?.disabled) ||
|
||||
(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION && sending.value) ||
|
||||
(
|
||||
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
|
||||
isApplicationPreviewEstimatePending(message)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewRows(message) {
|
||||
return buildApplicationPreviewRows(message?.applicationPreview || {})
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewMissingFields(message) {
|
||||
return normalizeApplicationPreview(message?.applicationPreview || {}).missingFields || []
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
|
||||
const control = resolveApplicationPreviewEditorControl(fieldKey)
|
||||
return control === 'date' ? 'text' : control
|
||||
}
|
||||
|
||||
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
|
||||
if (isApplicationPreviewEstimatePendingPreview(applicationPreview)) {
|
||||
return []
|
||||
}
|
||||
const normalized = normalizeApplicationPreview(applicationPreview)
|
||||
const actions = [{
|
||||
label: '保存草稿',
|
||||
description: '先保存当前申请表,后续可以继续补充或提交。',
|
||||
icon: 'mdi mdi-content-save-outline',
|
||||
action_type: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
payload: { draftPayload }
|
||||
}]
|
||||
if (normalized.readyToSubmit) {
|
||||
actions.push({
|
||||
label: '直接提交',
|
||||
description: '提交前先核查相同日期申请单,确认通过后进入审批流程。',
|
||||
icon: 'mdi mdi-send-check-outline',
|
||||
action_type: AI_APPLICATION_ACTION_SUBMIT,
|
||||
payload: { draftPayload }
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
function syncInlineApplicationPreviewMessageContent(message) {
|
||||
if (!message?.applicationPreview) {
|
||||
return
|
||||
}
|
||||
const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview)
|
||||
message.content = nextContent
|
||||
message.text = nextContent
|
||||
message.suggestedActions = buildInlineApplicationPreviewSuggestedActions(message.applicationPreview, message.draftPayload)
|
||||
}
|
||||
|
||||
async function commitInlineApplicationPreviewEditor(message) {
|
||||
const shouldLockForEstimate = shouldRefreshInlineApplicationPreviewEstimate(applicationPreviewEditor.value.fieldKey)
|
||||
if (shouldLockForEstimate) {
|
||||
message.suggestedActions = []
|
||||
persistCurrentConversation()
|
||||
}
|
||||
const committed = await commitBaseApplicationPreviewEditor(message)
|
||||
syncInlineApplicationPreviewMessageContent(message)
|
||||
persistCurrentConversation()
|
||||
return committed
|
||||
}
|
||||
|
||||
function handleInlineApplicationPreviewEditorKeydown(event, message) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void commitInlineApplicationPreviewEditor(message)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelApplicationPreviewEditor()
|
||||
return
|
||||
}
|
||||
handleApplicationPreviewEditorKeydown(event, message)
|
||||
}
|
||||
|
||||
function buildInlineApplicationPreviewFooterText(message) {
|
||||
const normalized = normalizeApplicationPreview(message?.applicationPreview || {})
|
||||
if (isApplicationPreviewEstimatePending(message)) {
|
||||
return '费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。'
|
||||
}
|
||||
if (normalized.validationIssues?.length || normalized.missingFields?.length) {
|
||||
return buildApplicationPreviewFooterMessage(normalized)
|
||||
}
|
||||
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
||||
}
|
||||
|
||||
function resolveLatestApplicationPreviewMessage() {
|
||||
return [...conversationMessages.value]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant' && message.applicationPreview)
|
||||
}
|
||||
|
||||
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
|
||||
applicationSubmitConfirmContext.value = {
|
||||
messageId: String(targetMessage?.id || '').trim(),
|
||||
draftPayload: targetMessage?.draftPayload || options.draftPayload || null,
|
||||
userText: String(options.userText || '直接提交').trim() || '直接提交'
|
||||
}
|
||||
applicationSubmitConfirmOpen.value = true
|
||||
persistCurrentConversation()
|
||||
}
|
||||
|
||||
function cancelInlineApplicationSubmitConfirm() {
|
||||
applicationSubmitConfirmOpen.value = false
|
||||
applicationSubmitConfirmContext.value = null
|
||||
}
|
||||
|
||||
function confirmInlineApplicationSubmit() {
|
||||
const context = applicationSubmitConfirmContext.value || {}
|
||||
applicationSubmitConfirmOpen.value = false
|
||||
applicationSubmitConfirmContext.value = null
|
||||
const sourceMessage = conversationMessages.value.find((message) => message.id === context.messageId)
|
||||
if (!sourceMessage?.applicationPreview) {
|
||||
toast('当前申请表已变化,请重新点击直接提交。')
|
||||
return
|
||||
}
|
||||
void executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, sourceMessage, {
|
||||
confirmed: true,
|
||||
skipUserMessage: false,
|
||||
draftPayload: context.draftPayload || null,
|
||||
userText: context.userText || '直接提交'
|
||||
})
|
||||
}
|
||||
|
||||
async function runInlineApplicationSubmitPrecheck(targetMessage, pendingMessage, normalizedPreview, options = {}) {
|
||||
try {
|
||||
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
const precheck = buildAiApplicationPrecheck(normalizedPreview, {
|
||||
claimsPayload: buildInlineApplicationSubmitPrecheckPayload(
|
||||
claimsPayload,
|
||||
targetMessage.draftPayload || options.draftPayload || null
|
||||
),
|
||||
currentUser: currentUser.value || {},
|
||||
expenseType: 'travel'
|
||||
})
|
||||
const thinkingEvents = buildInlineApplicationSubmitThinkingEvents(precheck)
|
||||
const blocked = isAiApplicationPrecheckBlocking(precheck)
|
||||
|
||||
if (blocked) {
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', buildAiApplicationSubmitConflictMessage(normalizedPreview, precheck), {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents
|
||||
}
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
return false
|
||||
}
|
||||
|
||||
const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
|
||||
message.content = '提交前核查通过,正在提交申请并进入审批流程...'
|
||||
message.paragraphs = ['提交前核查通过,正在提交申请并进入审批流程...']
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || {}),
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
return true
|
||||
} catch (error) {
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', [
|
||||
'### 提交前核查失败',
|
||||
'系统未能完成相同日期申请单查询,所以本次申请没有提交。',
|
||||
'请稍后重试;如果仍然失败,请先到单据中心核对是否已有同日期申请单。'
|
||||
].join('\n\n'), {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildFailedInlineApplicationSubmitThinkingEvents(error)
|
||||
}
|
||||
})
|
||||
)
|
||||
toast('提交前核查失败,已暂停提交。')
|
||||
persistCurrentConversation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
|
||||
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
|
||||
if (!targetMessage?.applicationPreview) {
|
||||
toast('当前没有可提交的申请表。')
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
|
||||
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
|
||||
const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim()
|
||||
|
||||
if (isSubmit && !normalizedPreview.readyToSubmit) {
|
||||
if (!options.skipUserMessage) {
|
||||
pushInlineApplicationActionUserMessage(userText)
|
||||
}
|
||||
const missingText = normalizedPreview.missingFields?.length
|
||||
? `当前还缺少:${normalizedPreview.missingFields.join('、')}。`
|
||||
: ''
|
||||
const validationText = normalizedPreview.validationIssues?.length
|
||||
? normalizedPreview.validationIssues.map((item) => item.message).join(';')
|
||||
: ''
|
||||
conversationMessages.value.push(createInlineMessage('assistant', [
|
||||
'### 暂不能提交申请',
|
||||
missingText || validationText || '当前申请表还未通过提交校验。',
|
||||
'请先点击表格中的字段补充或修正,补齐后我会开放“直接提交”入口。'
|
||||
].filter(Boolean).join('\n\n')))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return true
|
||||
}
|
||||
|
||||
if (isSubmit && !options.confirmed) {
|
||||
requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
|
||||
return true
|
||||
}
|
||||
|
||||
if (!options.skipUserMessage) {
|
||||
pushInlineApplicationActionUserMessage(userText)
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
const pendingMessage = createInlineMessage(
|
||||
'assistant',
|
||||
isSubmit ? '正在提交前核查相同日期申请单...' : '正在保存申请草稿...',
|
||||
{
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: isSubmit
|
||||
? buildInitialInlineApplicationSubmitThinkingEvents()
|
||||
: [
|
||||
{
|
||||
eventId: 'application-save-draft',
|
||||
title: '保存申请草稿',
|
||||
content: '正在按当前申请表内容保存草稿。',
|
||||
status: 'running'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
if (isSubmit) {
|
||||
const precheckPassed = await runInlineApplicationSubmitPrecheck(
|
||||
targetMessage,
|
||||
pendingMessage,
|
||||
normalizedPreview,
|
||||
options
|
||||
)
|
||||
if (!precheckPassed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await runAiApplicationPreviewAction({
|
||||
actionType,
|
||||
applicationPreview: normalizedPreview,
|
||||
currentUser: currentUser.value || {},
|
||||
conversationId: conversationId.value,
|
||||
draftPayload: targetMessage.draftPayload || options.draftPayload || null
|
||||
})
|
||||
const draftPayload = extractInlineApplicationDraftPayload(payload)
|
||||
if (draftPayload) {
|
||||
targetMessage.draftPayload = draftPayload
|
||||
}
|
||||
targetMessage.suggestedActions = []
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||
},
|
||||
suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : []
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
return true
|
||||
} catch (error) {
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||
...item,
|
||||
status: 'failed'
|
||||
}))
|
||||
}
|
||||
})
|
||||
)
|
||||
toast(error?.message || (isSubmit ? '申请提交失败。' : '申请草稿保存失败。'))
|
||||
persistCurrentConversation()
|
||||
return true
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
}
|
||||
}
|
||||
|
||||
function handleInlineApplicationPreviewTextAction(prompt, applicationPreviewEstimatePending) {
|
||||
if (applicationPreviewEstimatePending.value) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return true
|
||||
}
|
||||
const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
|
||||
if (!actionType || !resolveLatestApplicationPreviewMessage()) {
|
||||
return false
|
||||
}
|
||||
void executeInlineApplicationPreviewAction(actionType, null, { userText: prompt })
|
||||
return true
|
||||
}
|
||||
|
||||
async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceText = '', options = {}) {
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({ title: String(expenseTypeLabel || expenseType || '申请').trim().slice(0, 18) || '申请' })
|
||||
}
|
||||
const previewSourceText = String(sourceText || resolveLatestInlineUserPrompt()).trim()
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
clearAiModeFiles()
|
||||
if (options.pushUserMessage !== false) {
|
||||
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
|
||||
}
|
||||
|
||||
const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: [
|
||||
{
|
||||
eventId: 'application-preview-build',
|
||||
title: '整理申请表字段',
|
||||
content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。',
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
eventId: 'application-preview-estimate',
|
||||
title: '同步费用测算',
|
||||
content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
const preview = await refreshApplicationPreviewEstimate(
|
||||
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {})
|
||||
)
|
||||
const content = buildLocalApplicationPreviewMessage(preview)
|
||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, {
|
||||
id: pendingMessage.id,
|
||||
applicationPreview: preview,
|
||||
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||
},
|
||||
text: content
|
||||
}))
|
||||
} catch (error) {
|
||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||
...item,
|
||||
status: 'failed'
|
||||
}))
|
||||
}
|
||||
}))
|
||||
toast(error?.message || '申请核对表生成失败。')
|
||||
} finally {
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildInlineApplicationPreviewFooterText,
|
||||
buildInlineApplicationPreviewSuggestedActions,
|
||||
canShowInlineSuggestedActions,
|
||||
cancelInlineApplicationSubmitConfirm,
|
||||
commitInlineApplicationPreviewEditor,
|
||||
confirmInlineApplicationSubmit,
|
||||
executeInlineApplicationPreviewAction,
|
||||
handleInlineApplicationPreviewEditorKeydown,
|
||||
handleInlineApplicationPreviewTextAction,
|
||||
isApplicationPreviewEditing,
|
||||
isApplicationPreviewEstimatePending,
|
||||
isInlineSuggestedActionDisabled,
|
||||
openApplicationPreviewEditor,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineApplicationPreviewEditorControl,
|
||||
resolveInlineApplicationPreviewMissingFields,
|
||||
resolveInlineApplicationPreviewRows,
|
||||
startAiApplicationPreview
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
||||
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
|
||||
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
createExpenseClaimItem,
|
||||
extractExpenseClaimItems,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaims,
|
||||
uploadExpenseClaimItemAttachment
|
||||
} from '../../services/reimbursements.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import {
|
||||
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
|
||||
buildInlineAttachmentOcrDetails
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
|
||||
|
||||
function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
|
||||
const completed = status === 'completed'
|
||||
const failed = status === 'failed'
|
||||
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
|
||||
return [
|
||||
{
|
||||
eventId: 'attachment-ocr',
|
||||
title: '识别上传票据',
|
||||
content: '提取票据里的日期、地点和行程信息。',
|
||||
status: eventStatus
|
||||
},
|
||||
{
|
||||
eventId: 'claim-lookup',
|
||||
title: '查询可关联报销单',
|
||||
content: '查找草稿、待补充和退回状态的可归集单据。',
|
||||
status: eventStatus
|
||||
},
|
||||
{
|
||||
eventId: 'claim-match',
|
||||
title: '匹配票据与报销单',
|
||||
content: '根据票据时间、城市和报销事由判断最可能的关联单据。',
|
||||
status: eventStatus
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveAiAttachmentAssociationClaimNo(payload = {}) {
|
||||
return String(payload?.claim_no || payload?.claimNo || '').trim()
|
||||
}
|
||||
|
||||
function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
|
||||
const completed = status === 'completed'
|
||||
const failed = status === 'failed'
|
||||
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
|
||||
return [
|
||||
{
|
||||
eventId: 'attachment-confirm',
|
||||
title: '确认自动归集',
|
||||
content: '正在读取匹配单据并准备写入附件。',
|
||||
status: eventStatus
|
||||
},
|
||||
{
|
||||
eventId: 'attachment-upload',
|
||||
title: '归集票据附件',
|
||||
content: '把本次上传的票据写入报销单明细。',
|
||||
status: eventStatus
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
aiAttachmentAssociationRuntime,
|
||||
conversationMessages,
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
streamOrSetInlineAssistantContent,
|
||||
toast
|
||||
}) {
|
||||
async function collectAiModeReceiptContext(files = []) {
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
const attachmentNames = safeFiles
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
const ocrSourceFileNames = ocrFiles
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const baseContext = {
|
||||
attachmentNames,
|
||||
attachmentCount: attachmentNames.length,
|
||||
ocrSourceFileNames,
|
||||
ocrSummary: '',
|
||||
ocrDocuments: []
|
||||
}
|
||||
|
||||
if (!ocrFiles.length) {
|
||||
return baseContext
|
||||
}
|
||||
|
||||
try {
|
||||
const collected = await collectReceiptFiles({
|
||||
files: ocrFiles,
|
||||
recognizeOcrFiles
|
||||
})
|
||||
return {
|
||||
...baseContext,
|
||||
ocrSummary: String(collected.ocrSummary || '').trim(),
|
||||
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AI mode OCR request failed:', error)
|
||||
return {
|
||||
...baseContext,
|
||||
ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findAiAttachmentAssociationRuntime(options = {}) {
|
||||
const normalizedAssociationId = String(options.associationId || options.association_id || '').trim()
|
||||
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
|
||||
if (normalizedAssociationId) {
|
||||
const runtime = aiAttachmentAssociationRuntime.get(normalizedAssociationId)
|
||||
if (runtime) {
|
||||
return { associationId: normalizedAssociationId, runtime }
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedClaimNo) {
|
||||
for (const [runtimeId, runtime] of aiAttachmentAssociationRuntime.entries()) {
|
||||
if (String(runtime?.claimNo || '').trim() === normalizedClaimNo && runtime?.files?.length) {
|
||||
return { associationId: runtimeId, runtime }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { associationId: '', runtime: null }
|
||||
}
|
||||
|
||||
function updateAiAttachmentAssociationActionState(message = {}, associationId = '', state = {}, options = {}) {
|
||||
const normalizedAssociationId = String(associationId || '').trim()
|
||||
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
|
||||
if (!message || !Array.isArray(message.suggestedActions) || (!normalizedAssociationId && !normalizedClaimNo)) {
|
||||
return
|
||||
}
|
||||
message.suggestedActions = message.suggestedActions.map((action) => {
|
||||
const actionAssociationId = String(action?.payload?.association_id || action?.payload?.associationId || '').trim()
|
||||
const actionClaimNo = resolveAiAttachmentAssociationClaimNo(action?.payload || {})
|
||||
const isSameAssociation = normalizedAssociationId && actionAssociationId === normalizedAssociationId
|
||||
const isSameClaim = normalizedClaimNo && actionClaimNo === normalizedClaimNo
|
||||
if (String(action?.action_type || '').trim() !== AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION || (!isSameAssociation && !isSameClaim)) {
|
||||
return action
|
||||
}
|
||||
return {
|
||||
...action,
|
||||
...state
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildAiAttachmentAssociationDetailActions(runtime = {}) {
|
||||
const claimNo = String(runtime.claimNo || '').trim()
|
||||
const claimId = String(runtime.claimId || '').trim()
|
||||
if (!claimNo && !claimId) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
label: '查看单据',
|
||||
description: '打开已归集票据的报销单。',
|
||||
icon: 'mdi mdi-open-in-new',
|
||||
action_type: 'open_application_detail',
|
||||
payload: {
|
||||
claim_id: claimId,
|
||||
claim_no: claimNo,
|
||||
document_type: 'expense'
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
|
||||
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
|
||||
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
|
||||
const runtimeResult = findAiAttachmentAssociationRuntime({
|
||||
associationId: requestedAssociationId,
|
||||
claimNo: payloadClaimNo
|
||||
})
|
||||
const associationId = runtimeResult.associationId
|
||||
const runtime = runtimeResult.runtime
|
||||
const actionClaimNo = payloadClaimNo || String(runtime?.claimNo || '').trim()
|
||||
if (!associationId || !runtime?.files?.length) {
|
||||
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
|
||||
return
|
||||
}
|
||||
|
||||
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
||||
label: '正在归集...',
|
||||
disabled: true
|
||||
}, { claimNo: actionClaimNo })
|
||||
persistCurrentConversation()
|
||||
sending.value = true
|
||||
|
||||
const pendingMessage = createInlineMessage('assistant', '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
const syncResult = await syncExpenseClaimFilesToDraft({
|
||||
claimId: runtime.claimId,
|
||||
files: runtime.files,
|
||||
fetchExpenseClaimDetail,
|
||||
createExpenseClaimItem,
|
||||
uploadExpenseClaimItemAttachment
|
||||
})
|
||||
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
|
||||
claimNo: runtime.claimNo,
|
||||
fileNames: runtime.fileNames,
|
||||
uploadedCount: syncResult.uploadedCount,
|
||||
skippedCount: syncResult.skippedCount
|
||||
})
|
||||
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('completed')
|
||||
},
|
||||
suggestedActions: buildAiAttachmentAssociationDetailActions(runtime)
|
||||
})
|
||||
)
|
||||
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
||||
label: '已自动关联',
|
||||
disabled: true
|
||||
}, { claimNo: actionClaimNo })
|
||||
aiAttachmentAssociationRuntime.delete(associationId)
|
||||
persistCurrentConversation()
|
||||
} catch (error) {
|
||||
const finalMessageText = error?.message || '自动归集失败,请稍后重试。'
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('failed')
|
||||
}
|
||||
})
|
||||
)
|
||||
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
||||
label: '重新自动关联',
|
||||
disabled: false
|
||||
}, { claimNo: actionClaimNo })
|
||||
toast(finalMessageText)
|
||||
persistCurrentConversation()
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAiAttachmentAssociationReply(prompt, entry = {}, files = []) {
|
||||
let shouldAutoScrollOnFinish = true
|
||||
const pendingMessage = createInlineMessage('assistant', '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('running')
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
const collected = await collectReceiptFiles({
|
||||
files,
|
||||
recognizeOcrFiles
|
||||
})
|
||||
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
||||
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
const claims = extractExpenseClaimItems(claimsPayload)
|
||||
const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments)
|
||||
const associationRecord = match.best?.record || match.recommended?.record || null
|
||||
const associationId = associationRecord?.claimId
|
||||
? createAiAttachmentAssociationId()
|
||||
: ''
|
||||
if (associationId) {
|
||||
aiAttachmentAssociationRuntime.set(associationId, {
|
||||
files,
|
||||
fileNames: files.map((file) => file?.name || '').filter(Boolean),
|
||||
claimId: String(associationRecord.claimId || '').trim(),
|
||||
claimNo: String(associationRecord.claimNo || '').trim(),
|
||||
ocrPayload: collected.ocrPayload,
|
||||
ocrSummary: collected.ocrSummary,
|
||||
ocrDocuments: collected.ocrDocuments,
|
||||
ocrFilePreviews: collected.ocrFilePreviews
|
||||
})
|
||||
}
|
||||
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
|
||||
match,
|
||||
fileNames: files.map((file) => file?.name || ''),
|
||||
ocrDocuments: collected.ocrDocuments
|
||||
})
|
||||
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
|
||||
},
|
||||
attachmentOcrDetails,
|
||||
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
|
||||
includeOcrDetails: Boolean(attachmentOcrDetails)
|
||||
})
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
} catch (error) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('failed')
|
||||
}
|
||||
})
|
||||
)
|
||||
toast(finalMessageText)
|
||||
persistCurrentConversation()
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
collectAiModeReceiptContext,
|
||||
confirmAiAttachmentAssociation,
|
||||
requestAiAttachmentAssociationReply,
|
||||
resolveAiAttachmentAssociationClaimNo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
buildFileIdentity,
|
||||
MAX_ATTACHMENTS,
|
||||
mergeFilesWithLimit
|
||||
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
|
||||
export function useWorkbenchAiComposerFiles({
|
||||
fileInputRef,
|
||||
focusAiModeInput,
|
||||
isInputLocked,
|
||||
selectedFiles,
|
||||
toast
|
||||
}) {
|
||||
function triggerAiModeFileUpload() {
|
||||
if (isInputLocked()) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleAiModeFilesChange(event) {
|
||||
const fileMergeResult = mergeFilesWithLimit(selectedFiles.value, Array.from(event.target.files || []), MAX_ATTACHMENTS)
|
||||
selectedFiles.value = fileMergeResult.files
|
||||
if (fileMergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
function removeAiModeFile(fileKey) {
|
||||
selectedFiles.value = selectedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey)
|
||||
if (!selectedFiles.value.length && fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
function clearAiModeFiles() {
|
||||
selectedFiles.value = []
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clearAiModeFiles,
|
||||
handleAiModeFilesChange,
|
||||
removeAiModeFile,
|
||||
triggerAiModeFileUpload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import {
|
||||
buildAiDocumentQueryConditionSummary,
|
||||
buildAiDocumentQueryMessage,
|
||||
filterAiDocumentQueryRecords,
|
||||
mergeAiDocumentQueryPayloads,
|
||||
resolveAiDocumentQueryIntent
|
||||
} from '../../utils/aiDocumentQueryModel.js'
|
||||
import {
|
||||
extractExpenseClaimItems,
|
||||
fetchApprovalExpenseClaims,
|
||||
fetchExpenseClaims
|
||||
} from '../../services/reimbursements.js'
|
||||
|
||||
const AI_DOCUMENT_QUERY_STEP_DELAY_MS = 320
|
||||
|
||||
function waitForAiDocumentQueryStep() {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, AI_DOCUMENT_QUERY_STEP_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function completeAiDocumentQueryEvent(events, eventId, content = '') {
|
||||
return events.map((event) => (
|
||||
event.eventId === eventId
|
||||
? {
|
||||
...event,
|
||||
content: content || event.content,
|
||||
status: 'completed'
|
||||
}
|
||||
: event
|
||||
))
|
||||
}
|
||||
|
||||
function failAiDocumentQueryEvents(events) {
|
||||
return events.map((event) => ({
|
||||
...event,
|
||||
status: event.status === 'completed' ? 'completed' : 'failed'
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveAiDocumentQueryFetchPendingText(intent = {}) {
|
||||
if (intent.source === 'approval') {
|
||||
return '等待调用待我审核单据接口。'
|
||||
}
|
||||
if (intent.source === 'mine') {
|
||||
return '等待调用我名下单据接口。'
|
||||
}
|
||||
return '等待同时调用我名下单据和待我审核单据接口。'
|
||||
}
|
||||
|
||||
function resolveAiDocumentQueryFetchRunningText(intent = {}) {
|
||||
if (intent.source === 'approval') {
|
||||
return '正在查询待我审核的单据,接口范围为待办/待审单据列表。'
|
||||
}
|
||||
if (intent.source === 'mine') {
|
||||
return '正在查询我名下的单据,接口范围为当前用户本人单据列表。'
|
||||
}
|
||||
return '正在查询我可见的单据,接口范围包含我名下单据和待我审核单据列表。'
|
||||
}
|
||||
|
||||
async function fetchAiDocumentQueryPayload(intent = {}) {
|
||||
const requestParams = { page: 1, pageSize: 100 }
|
||||
if (intent.source === 'approval') {
|
||||
return fetchApprovalExpenseClaims(requestParams)
|
||||
}
|
||||
if (intent.source === 'mine') {
|
||||
return fetchExpenseClaims(requestParams)
|
||||
}
|
||||
const [ownPayload, approvalPayload] = await Promise.all([
|
||||
fetchExpenseClaims(requestParams),
|
||||
fetchApprovalExpenseClaims(requestParams)
|
||||
])
|
||||
return mergeAiDocumentQueryPayloads(
|
||||
ownPayload,
|
||||
{
|
||||
items: extractExpenseClaimItems(approvalPayload),
|
||||
querySource: 'approval'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function useWorkbenchAiDocumentQueryFlow({
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
scrollInlineConversationToBottom
|
||||
}) {
|
||||
async function updateAiDocumentQueryThinking(pendingMessage, thinkingEvents, streamStatus = 'streaming') {
|
||||
const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || {}),
|
||||
streamStatus,
|
||||
thinkingEvents
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
|
||||
const intent = resolveAiDocumentQueryIntent(prompt)
|
||||
if (!intent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
||||
let thinkingEvents = [
|
||||
{
|
||||
eventId: 'document-query-parse',
|
||||
title: '解析自然语言筛选条件',
|
||||
content: `正在从你的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
eventId: 'document-query-fetch',
|
||||
title: '查询业务单据接口',
|
||||
content: resolveAiDocumentQueryFetchPendingText(intent),
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
eventId: 'document-query-filter',
|
||||
title: '组合筛选单据',
|
||||
content: '等待接口返回后,再按已识别条件做二次筛选。',
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
await waitForAiDocumentQueryStep()
|
||||
|
||||
thinkingEvents = completeAiDocumentQueryEvent(thinkingEvents, 'document-query-parse')
|
||||
thinkingEvents = thinkingEvents.map((event) => (
|
||||
event.eventId === 'document-query-fetch'
|
||||
? {
|
||||
...event,
|
||||
content: resolveAiDocumentQueryFetchRunningText(intent),
|
||||
status: 'running'
|
||||
}
|
||||
: event
|
||||
))
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
|
||||
try {
|
||||
const payload = await fetchAiDocumentQueryPayload(intent)
|
||||
const rawCount = extractExpenseClaimItems(payload).length
|
||||
const filteredRecords = filterAiDocumentQueryRecords(payload, intent)
|
||||
thinkingEvents = completeAiDocumentQueryEvent(
|
||||
thinkingEvents,
|
||||
'document-query-fetch',
|
||||
`接口返回 ${rawCount} 张候选单据,开始按自然语言条件筛选。`
|
||||
)
|
||||
thinkingEvents = thinkingEvents.map((event) => (
|
||||
event.eventId === 'document-query-filter'
|
||||
? {
|
||||
...event,
|
||||
content: `按“${conditionSummary}”组合筛选,当前命中 ${filteredRecords.length} 张。`,
|
||||
status: 'running'
|
||||
}
|
||||
: event
|
||||
))
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
await waitForAiDocumentQueryStep()
|
||||
|
||||
const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
|
||||
thinkingEvents = completeAiDocumentQueryEvent(
|
||||
thinkingEvents,
|
||||
'document-query-filter',
|
||||
`筛选完成,命中 ${filteredRecords.length} 张单据,已生成卡片结果。`
|
||||
)
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents
|
||||
},
|
||||
suggestedActions: []
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
const finalMessageText = error?.message || '查询单据时出现异常,请稍后再试。'
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: failAiDocumentQueryEvents(thinkingEvents)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
persistCurrentConversation()
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
handleAiDocumentQueryIntent
|
||||
}
|
||||
}
|
||||
186
web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
Normal file
186
web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
applyAiExpenseAnswer,
|
||||
buildAiExpenseStepPrompt,
|
||||
buildAiExpenseSummary,
|
||||
createAiExpenseDraft,
|
||||
isAiExpenseDraftComplete
|
||||
} from '../../utils/aiExpenseDraftModel.js'
|
||||
import {
|
||||
buildExpenseSceneSelectionMessage,
|
||||
SESSION_TYPE_EXPENSE
|
||||
} from '../../views/scripts/travelReimbursementConversationModel.js'
|
||||
import { buildExpenseSceneSelectionActions } from '../../utils/expenseAssistantActions.js'
|
||||
import {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates
|
||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||
|
||||
export { SESSION_TYPE_EXPENSE }
|
||||
|
||||
export function useWorkbenchAiExpenseFlow({
|
||||
activateInlineConversation,
|
||||
aiExpenseDraft,
|
||||
assistantDraft,
|
||||
clearAiModeFiles,
|
||||
closeWorkbenchDatePicker,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
persistCurrentConversation,
|
||||
pushInlineUserMessage,
|
||||
removeWorkbenchDateTag,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
startAiApplicationPreview
|
||||
}) {
|
||||
function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') {
|
||||
const sourceText = String(originalMessage || '我要报销').trim()
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({
|
||||
title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销'
|
||||
})
|
||||
}
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
conversationMessages.value.push(createInlineMessage('user', String(selectedLabel || sourceText).trim()))
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), {
|
||||
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function startAiApplicationPreviewFromAction(payload = {}, fallbackLabel = '') {
|
||||
const expenseType = String(payload.expense_type || '').trim()
|
||||
const expenseTypeLabel = String(payload.expense_type_label || fallbackLabel || '').trim()
|
||||
return startAiApplicationPreview(
|
||||
expenseType,
|
||||
expenseTypeLabel,
|
||||
payload.carry_text || resolveLatestInlineUserPrompt()
|
||||
)
|
||||
}
|
||||
|
||||
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
|
||||
}
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
clearAiModeFiles()
|
||||
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
|
||||
|
||||
if (requiresApplicationBeforeReimbursement) {
|
||||
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel)
|
||||
return
|
||||
}
|
||||
|
||||
const draft = createAiExpenseDraft(expenseType, expenseTypeLabel)
|
||||
aiExpenseDraft.value = draft
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function advanceAiExpenseDraft(answer, files = []) {
|
||||
const fileNames = Array.from(files || [])
|
||||
pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : ''))
|
||||
assistantDraft.value = ''
|
||||
clearAiModeFiles()
|
||||
|
||||
const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames)
|
||||
aiExpenseDraft.value = next
|
||||
|
||||
if (isAiExpenseDraftComplete(next)) {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮你生成报销草稿。`))
|
||||
aiExpenseDraft.value = null
|
||||
} else {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
|
||||
}
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
|
||||
let claims = null
|
||||
try {
|
||||
claims = await fetchExpenseClaims()
|
||||
} catch {
|
||||
aiExpenseDraft.value = null
|
||||
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {})
|
||||
aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel)
|
||||
|
||||
if (!candidates.length) {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
|
||||
suggestedActions: [{
|
||||
label: '确认发起出差申请',
|
||||
description: '生成完整申请表,并预填已识别的时间、地点和事由',
|
||||
icon: 'mdi mdi-file-plus-outline',
|
||||
action_type: 'ai_application_start_inline',
|
||||
payload: {
|
||||
expense_type: expenseType,
|
||||
expense_type_label: expenseTypeLabel
|
||||
}
|
||||
}]
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationSelectionText(expenseType, candidates), {
|
||||
suggestedActions: buildRequiredApplicationActions(candidates, 'select_required_application')
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function linkAiExpenseApplication(application = {}) {
|
||||
const draft = aiExpenseDraft.value
|
||||
if (!draft) {
|
||||
return
|
||||
}
|
||||
const claimNo = String(application.application_claim_no || '').trim()
|
||||
pushInlineUserMessage(`关联申请单 ${claimNo}`.trim())
|
||||
|
||||
const linked = {
|
||||
...draft,
|
||||
applicationClaim: application,
|
||||
values: {
|
||||
...draft.values,
|
||||
reason: String(application.application_reason || '').trim(),
|
||||
location: String(application.application_location || '').trim(),
|
||||
time_range: String(application.application_business_time || '').trim(),
|
||||
amount: String(application.application_amount_label || application.application_amount || '').trim()
|
||||
},
|
||||
stepKey: 'attachments'
|
||||
}
|
||||
aiExpenseDraft.value = linked
|
||||
conversationMessages.value.push(createInlineMessage('assistant', [
|
||||
`已关联申请单${claimNo ? ` ${claimNo}` : ''},事由、时间、地点、金额我先用申请单的内容预填了。`,
|
||||
'',
|
||||
'再确认一下票据:可以现在上传,或回复“稍后上传”。'
|
||||
].join('\n')))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
return {
|
||||
advanceAiExpenseDraft,
|
||||
linkAiExpenseApplication,
|
||||
pushInlineExpenseSceneSelectionPrompt,
|
||||
startAiApplicationPreviewFromAction,
|
||||
startAiExpenseDraft
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function useWorkbenchAiMessageActions({
|
||||
assistantDraft,
|
||||
focusAiModeInput,
|
||||
persistCurrentConversation,
|
||||
toast
|
||||
}) {
|
||||
async function copyInlineMessage(message) {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(message.content)
|
||||
toast('已复制内容。')
|
||||
} catch {
|
||||
toast('当前浏览器暂不支持自动复制。')
|
||||
}
|
||||
}
|
||||
|
||||
function quoteInlineMessage(message) {
|
||||
const quote = `> ${message.content}\n\n`
|
||||
assistantDraft.value = assistantDraft.value ? assistantDraft.value + '\n' + quote : quote
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
function markInlineMessageFeedback(message, feedback) {
|
||||
message.feedback = feedback
|
||||
persistCurrentConversation()
|
||||
toast(feedback === 'up' ? '已记录有帮助反馈。' : '已记录需要改进反馈。')
|
||||
}
|
||||
|
||||
return {
|
||||
copyInlineMessage,
|
||||
markInlineMessageFeedback,
|
||||
quoteInlineMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export function useWorkbenchAiSessionCommands({
|
||||
activeConversationTitle,
|
||||
attachmentOcrExpandedMessageIds,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
deleteAiWorkbenchConversation,
|
||||
emit,
|
||||
focusAiModeInput,
|
||||
inlineConversationAutoScrollPinned,
|
||||
normalizeRuntimeMessage,
|
||||
refreshConversationHistory,
|
||||
resetInlineConversationState,
|
||||
scrollInlineConversationToBottom,
|
||||
stewardState,
|
||||
thinkingCollapsedMessageIds,
|
||||
thinkingExpandedMessageIds,
|
||||
toast
|
||||
}) {
|
||||
function startNewInlineConversation() {
|
||||
resetInlineConversationState()
|
||||
emit('conversation-change', { id: '', title: '' })
|
||||
refreshConversationHistory()
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
function openInlineSearchConversation(activateInlineConversation) {
|
||||
conversationMessages.value = [
|
||||
createInlineMessage('assistant', '你可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
|
||||
]
|
||||
stewardState.value = null
|
||||
thinkingExpandedMessageIds.value = new Set()
|
||||
thinkingCollapsedMessageIds.value = new Set()
|
||||
attachmentOcrExpandedMessageIds.value = new Set()
|
||||
conversationId.value = 'ai-search'
|
||||
activateInlineConversation({ id: 'ai-search', title: '查询对话' })
|
||||
focusAiModeInput()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function openInlineRecentConversation(item = {}) {
|
||||
const title = String(item.title || '最近对话').trim()
|
||||
conversationId.value = String(item.id || `recent-${Date.now()}`).trim()
|
||||
activeConversationTitle.value = title
|
||||
stewardState.value = item.stewardState || null
|
||||
thinkingExpandedMessageIds.value = new Set()
|
||||
thinkingCollapsedMessageIds.value = new Set()
|
||||
attachmentOcrExpandedMessageIds.value = new Set()
|
||||
inlineConversationAutoScrollPinned.value = true
|
||||
conversationMessages.value = Array.isArray(item.messages) && item.messages.length
|
||||
? item.messages.map((message) => normalizeRuntimeMessage(message))
|
||||
: [
|
||||
createInlineMessage(
|
||||
'assistant',
|
||||
'这条历史对话没有保存完整消息。你可以继续输入新的问题,小财管家会接着处理。'
|
||||
)
|
||||
]
|
||||
conversationStarted.value = true
|
||||
emit('conversation-change', { id: conversationId.value, title })
|
||||
focusAiModeInput()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function requestDeleteCurrentConversation(deleteDialogOpen) {
|
||||
if (!conversationMessages.value.length) {
|
||||
return
|
||||
}
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function cancelDeleteConversation(deleteDialogOpen) {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
|
||||
function confirmDeleteConversation(deleteDialogOpen) {
|
||||
const nextHistory = conversationId.value
|
||||
? deleteAiWorkbenchConversation(currentUser.value || {}, conversationId.value)
|
||||
: refreshConversationHistory()
|
||||
emit('conversation-history-change', nextHistory)
|
||||
resetInlineConversationState()
|
||||
deleteDialogOpen.value = false
|
||||
emit('conversation-change', { id: '', title: '' })
|
||||
toast('已删除当前对话。')
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
return {
|
||||
cancelDeleteConversation,
|
||||
confirmDeleteConversation,
|
||||
openInlineRecentConversation,
|
||||
openInlineSearchConversation,
|
||||
requestDeleteCurrentConversation,
|
||||
startNewInlineConversation
|
||||
}
|
||||
}
|
||||
371
web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js
Normal file
371
web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js
Normal file
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
fetchStewardPlan,
|
||||
fetchStewardPlanStream
|
||||
} from '../../services/steward.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
buildStewardPlanMessageText,
|
||||
buildStewardPlanRequest,
|
||||
buildStewardSuggestedActions,
|
||||
normalizeStewardPlan
|
||||
} from '../../views/scripts/stewardPlanModel.js'
|
||||
import {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates
|
||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||
|
||||
function shouldCheckAiRequiredApplicationGate(prompt) {
|
||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||
if (!compact || !/(出差|差旅|部署|实施|支撑|支持|协助|拜访|调研|驻场|上线|验收)/.test(compact)) {
|
||||
return false
|
||||
}
|
||||
if (!/\d{1,2}月\d{1,2}|昨天|前天|上周|上月/.test(compact)) {
|
||||
return false
|
||||
}
|
||||
return !/(申请|报销|草稿|提交|审批|保存|发起|创建)/.test(compact)
|
||||
}
|
||||
|
||||
function serializeRequiredApplicationCandidate(candidate = {}) {
|
||||
return {
|
||||
id: String(candidate.id || '').trim(),
|
||||
claim_no: String(candidate.claim_no || '').trim(),
|
||||
reason: String(candidate.reason || '').trim(),
|
||||
location: String(candidate.location || '').trim(),
|
||||
business_time: String(candidate.business_time || '').trim(),
|
||||
status_label: String(candidate.status_label || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequiredApplicationGateContinuationFlow(normalizedPlan) {
|
||||
if (String(normalizedPlan?.pendingFlowConfirmation?.status || '').trim() !== 'pending') {
|
||||
return null
|
||||
}
|
||||
const flows = Array.isArray(normalizedPlan?.candidateFlows) ? normalizedPlan.candidateFlows : []
|
||||
const applicationFlow = flows.find((flow) => flow.flowId === 'travel_application')
|
||||
if (flows.length === 1 && applicationFlow && /先发起出差申请/.test(applicationFlow.label)) {
|
||||
return applicationFlow
|
||||
}
|
||||
return flows.find((flow) => (
|
||||
flow.flowId === 'travel_reimbursement' &&
|
||||
/关联已有申请单/.test(flow.label)
|
||||
)) || null
|
||||
}
|
||||
|
||||
function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
|
||||
const baseText = buildStewardPlanMessageText({
|
||||
planStatus: normalizedPlan?.planStatus,
|
||||
nextAction: normalizedPlan?.nextAction,
|
||||
summary: normalizedPlan?.summary,
|
||||
pendingFlowConfirmation: normalizedPlan?.pendingFlowConfirmation,
|
||||
candidateFlows: normalizedPlan?.candidateFlows
|
||||
})
|
||||
const contextText = String(baseText || '')
|
||||
.split(/\n\n1\. \*\*/)[0]
|
||||
.trim()
|
||||
.replace('### 需要先确认流程方向', '### 我已先查询申请单')
|
||||
if (flow?.flowId === 'travel_application') {
|
||||
return [
|
||||
contextText || baseText,
|
||||
'这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
if (flow?.flowId === 'travel_reimbursement') {
|
||||
return [
|
||||
contextText || baseText,
|
||||
'这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
return baseText
|
||||
}
|
||||
|
||||
function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
|
||||
if (flow.flowId === 'travel_application') {
|
||||
return [{
|
||||
label: '确认发起出差申请',
|
||||
description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。',
|
||||
icon: 'mdi mdi-file-plus-outline',
|
||||
action_type: 'ai_application_start_inline',
|
||||
payload: {
|
||||
expense_type: 'travel',
|
||||
expense_type_label: '差旅费',
|
||||
carry_text: prompt
|
||||
}
|
||||
}]
|
||||
}
|
||||
if (flow.flowId === 'travel_reimbursement') {
|
||||
return [{
|
||||
label: '确认关联已有申请单',
|
||||
description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。',
|
||||
icon: 'mdi mdi-link-variant',
|
||||
action_type: 'steward_confirm_flow',
|
||||
payload: {
|
||||
steward_confirm_flow: true,
|
||||
flow_id: 'travel_reimbursement',
|
||||
expense_type: 'travel',
|
||||
expense_type_label: '差旅费',
|
||||
carry_text: prompt
|
||||
}
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeStreamThinkingEvent(event = {}) {
|
||||
const data = event?.data && typeof event.data === 'object' ? event.data : {}
|
||||
const eventId = String(data.event_id || data.eventId || data.stage || `thinking-${Date.now()}`).trim()
|
||||
return {
|
||||
eventId,
|
||||
stage: String(data.stage || '').trim(),
|
||||
title: String(data.title || '小财管家正在分析').trim(),
|
||||
content: String(data.content || '').trim(),
|
||||
status: String(data.status || 'running').trim() || 'running'
|
||||
}
|
||||
}
|
||||
|
||||
export function useWorkbenchAiStewardFlow({
|
||||
activeConversationTitle,
|
||||
collectAiModeReceiptContext,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
deleteAiWorkbenchConversation,
|
||||
emit,
|
||||
handleAiDocumentQueryIntent,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
resolveInlineThinkingEvents,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
stewardState,
|
||||
streamInlineAssistantContent,
|
||||
updateInlineMessageContent,
|
||||
appendInlineMessageContent,
|
||||
toast
|
||||
}) {
|
||||
async function attachAiRequiredApplicationGate(planRequest, prompt) {
|
||||
if (!shouldCheckAiRequiredApplicationGate(prompt)) {
|
||||
return planRequest
|
||||
}
|
||||
|
||||
try {
|
||||
const claims = await fetchExpenseClaims()
|
||||
const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
|
||||
planRequest.context_json = {
|
||||
...(planRequest.context_json || {}),
|
||||
required_application_gate: {
|
||||
...((planRequest.context_json || {}).required_application_gate || {}),
|
||||
travel: {
|
||||
checked: true,
|
||||
candidate_count: candidates.length,
|
||||
candidates: candidates.slice(0, 5).map((candidate) => serializeRequiredApplicationCandidate(candidate))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AI mode required application lookup failed:', error)
|
||||
planRequest.context_json = {
|
||||
...(planRequest.context_json || {}),
|
||||
required_application_gate: {
|
||||
...((planRequest.context_json || {}).required_application_gate || {}),
|
||||
travel: {
|
||||
checked: false,
|
||||
query_failed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return planRequest
|
||||
}
|
||||
|
||||
function handleInlineStewardStreamEvent(messageId, event) {
|
||||
const message = conversationMessages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event?.event === 'answer_delta') {
|
||||
const data = event?.data && typeof event.data === 'object' ? event.data : {}
|
||||
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
|
||||
appendInlineMessageContent(message, data.delta || data.content || data.text || '')
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || {}),
|
||||
streamStatus: 'streaming'
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScroll })
|
||||
return
|
||||
}
|
||||
|
||||
if (event?.event !== 'thinking') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextEvent = normalizeStreamThinkingEvent(event)
|
||||
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
|
||||
const currentPlan = message.stewardPlan || {}
|
||||
const currentEvents = Array.isArray(currentPlan.thinkingEvents) ? currentPlan.thinkingEvents : []
|
||||
const eventIndex = currentEvents.findIndex((item) => item.eventId && item.eventId === nextEvent.eventId)
|
||||
const nextEvents = eventIndex >= 0
|
||||
? currentEvents.map((item, index) => (index === eventIndex ? { ...item, ...nextEvent } : item))
|
||||
: [...currentEvents, nextEvent]
|
||||
|
||||
message.stewardPlan = {
|
||||
...currentPlan,
|
||||
thinkingEvents: nextEvents,
|
||||
streamStatus: 'streaming'
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScroll })
|
||||
}
|
||||
|
||||
async function fetchInlineStewardPlan(messageId, payload) {
|
||||
try {
|
||||
return await fetchStewardPlanStream(
|
||||
payload,
|
||||
{
|
||||
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event)
|
||||
},
|
||||
{
|
||||
idleTimeoutMs: 90000,
|
||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('流式服务')) {
|
||||
return fetchStewardPlan(payload, {
|
||||
timeoutMs: 75000,
|
||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
|
||||
let shouldAutoScrollOnFinish = true
|
||||
const pendingMessage = createInlineMessage('assistant', '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: [
|
||||
{
|
||||
eventId: 'init',
|
||||
title: '小财管家正在接入业务流程',
|
||||
content: '正在识别你的意图、上下文和附件信息。',
|
||||
status: 'running'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
return
|
||||
}
|
||||
|
||||
const receiptContext = await collectAiModeReceiptContext(files)
|
||||
const planRequest = buildStewardPlanRequest({
|
||||
rawText: prompt,
|
||||
files,
|
||||
currentUser: currentUser.value || {},
|
||||
conversationId: conversationId.value,
|
||||
stewardState: stewardState.value
|
||||
})
|
||||
planRequest.context_json = {
|
||||
...planRequest.context_json,
|
||||
entry_source: 'workbench_ai_inline',
|
||||
source: entry.source || 'workbench',
|
||||
attachment_names: receiptContext.attachmentNames,
|
||||
attachment_count: receiptContext.attachmentCount,
|
||||
ocr_summary: receiptContext.ocrSummary,
|
||||
ocr_documents: receiptContext.ocrDocuments,
|
||||
ocr_source_file_names: receiptContext.ocrSourceFileNames,
|
||||
...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {})
|
||||
}
|
||||
await attachAiRequiredApplicationGate(planRequest, prompt)
|
||||
|
||||
const plan = await fetchInlineStewardPlan(pendingMessage.id, planRequest)
|
||||
const normalizedPlan = normalizeStewardPlan(plan, {
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true
|
||||
})
|
||||
const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
|
||||
const nextThinkingEvents = normalizedPlan.thinkingEvents.length
|
||||
? normalizedPlan.thinkingEvents
|
||||
: previousThinkingEvents.map((item) => ({ ...item, status: 'completed' }))
|
||||
const previousConversationId = conversationId.value
|
||||
const nextConversationId = String(normalizedPlan.conversationId || '').trim()
|
||||
if (nextConversationId) {
|
||||
conversationId.value = nextConversationId
|
||||
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
|
||||
if (previousConversationId && previousConversationId !== nextConversationId) {
|
||||
deleteAiWorkbenchConversation(currentUser.value || {}, previousConversationId)
|
||||
}
|
||||
}
|
||||
if (normalizedPlan.stewardState) {
|
||||
stewardState.value = normalizedPlan.stewardState
|
||||
}
|
||||
const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
|
||||
const finalMessageText = requiredApplicationContinuationFlow
|
||||
? buildAiRequiredApplicationGateAutoMessage(normalizedPlan, requiredApplicationContinuationFlow)
|
||||
: buildStewardPlanMessageText(plan)
|
||||
const hasServerStreamedContent = Boolean(String(pendingMessage.content || '').trim())
|
||||
if (!hasServerStreamedContent) {
|
||||
await streamInlineAssistantContent(pendingMessage.id, finalMessageText)
|
||||
}
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
...normalizedPlan,
|
||||
thinkingEvents: nextThinkingEvents,
|
||||
streamStatus: 'completed'
|
||||
},
|
||||
suggestedActions: requiredApplicationContinuationFlow
|
||||
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
|
||||
: buildStewardSuggestedActions(plan)
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
} catch (error) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage(
|
||||
'assistant',
|
||||
error?.message || '小财管家暂时无法完成规划,请稍后再试。',
|
||||
{
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||
...item,
|
||||
status: 'failed'
|
||||
}))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
toast(error?.message || '小财管家暂时无法完成规划。')
|
||||
persistCurrentConversation()
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates,
|
||||
requestInlineAssistantReply
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
buildApplicationTemplatePreview,
|
||||
buildLocalApplicationPreview,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { AI_APPLICATION_DETAIL_HREF_PREFIX } from '../../utils/aiDocumentDetailReference.js'
|
||||
import {
|
||||
buildAiApplicationPrecheckThinkingEvents,
|
||||
isAiApplicationPrecheckBlocking
|
||||
} from '../../utils/aiApplicationPrecheckModel.js'
|
||||
import { extractExpenseClaimItems } from '../../services/reimbursements.js'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
|
||||
const INLINE_APPLICATION_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
submitted: '审批中',
|
||||
pending: '待处理',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
returned: '已退回',
|
||||
rejected: '已驳回',
|
||||
pending_payment: '待付款',
|
||||
paid: '已付款'
|
||||
}
|
||||
|
||||
function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
|
||||
const text = String(value || '')
|
||||
.replace(/\s*\n+\s*/g, ' ')
|
||||
.replace(/\|/g, '|')
|
||||
.trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
export function normalizeInlineApplicationStatusLabel(value, fallback = '') {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return fallback
|
||||
}
|
||||
return INLINE_APPLICATION_STATUS_LABELS[text.toLowerCase()] || text
|
||||
}
|
||||
|
||||
export function buildInlineApplicationActionDetailHref(reference = '') {
|
||||
const source = reference && typeof reference === 'object' ? reference : { reference }
|
||||
const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
|
||||
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
|
||||
const fallback = String(source.reference || '').trim()
|
||||
if (claimId || claimNo) {
|
||||
const params = new URLSearchParams()
|
||||
if (claimId) {
|
||||
params.set('claim_id', claimId)
|
||||
}
|
||||
if (claimNo) {
|
||||
params.set('claim_no', claimNo)
|
||||
}
|
||||
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
|
||||
}
|
||||
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
|
||||
}
|
||||
|
||||
export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
|
||||
const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
|
||||
const body = String(source.body || source.markdown || '').trim()
|
||||
const resolveBodyField = (labels = []) => {
|
||||
for (const label of labels) {
|
||||
const pattern = new RegExp(`${label}\\s*[::]\\s*([^\\n|]+)`, 'u')
|
||||
const match = body.match(pattern)
|
||||
if (match?.[1]) {
|
||||
return String(match[1]).replace(/\*\*/g, '').trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const startDate = String(source.start_date || source.startDate || source.trip_start_date || source.tripStartDate || '').trim()
|
||||
const endDate = String(source.end_date || source.endDate || source.trip_end_date || source.tripEndDate || '').trim()
|
||||
const dateText = String(
|
||||
source.business_time ||
|
||||
source.businessTime ||
|
||||
source.time ||
|
||||
source.occurred_at ||
|
||||
source.occurredAt ||
|
||||
source.apply_time ||
|
||||
source.applyTime ||
|
||||
''
|
||||
).trim()
|
||||
const rangeText = startDate && endDate && startDate !== endDate
|
||||
? `${startDate} 至 ${endDate}`
|
||||
: startDate || endDate
|
||||
return {
|
||||
claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim(),
|
||||
claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
|
||||
statusLabel: normalizeInlineApplicationStatusLabel(source.status_label || source.statusLabel || source.status),
|
||||
approvalStage: String(source.approval_stage || source.approvalStage || '').trim(),
|
||||
dateLabel: rangeText || dateText || resolveBodyField(['时间', '日期', '申请时间']) || '待补充',
|
||||
locationLabel: String(
|
||||
source.location ||
|
||||
source.application_location ||
|
||||
source.applicationLocation ||
|
||||
source.destination ||
|
||||
source.destination_city ||
|
||||
source.destinationCity ||
|
||||
''
|
||||
).trim() || resolveBodyField(['地点', '目的地']) || '待补充',
|
||||
reasonLabel: String(
|
||||
source.reason ||
|
||||
source.business_reason ||
|
||||
source.businessReason ||
|
||||
source.description ||
|
||||
source.title ||
|
||||
''
|
||||
).trim() || resolveBodyField(['事由', '事件', '申请事由']) || '待补充',
|
||||
amountLabel: String(
|
||||
source.amount ||
|
||||
source.application_amount ||
|
||||
source.applicationAmount ||
|
||||
source.estimated_amount ||
|
||||
source.estimatedAmount ||
|
||||
''
|
||||
).trim() || resolveBodyField(['金额', '预计金额', '申请金额']) || '-',
|
||||
documentTypeLabel: String(
|
||||
source.document_type_label ||
|
||||
source.documentTypeLabel ||
|
||||
source.application_type_label ||
|
||||
source.applicationTypeLabel ||
|
||||
source.expense_type_label ||
|
||||
source.expenseTypeLabel ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
|
||||
const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
|
||||
const reference = info.claimNo || info.claimId
|
||||
const href = buildInlineApplicationActionDetailHref(info)
|
||||
const actionText = href ? `[查看](${href})` : '-'
|
||||
const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel)
|
||||
return [
|
||||
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |',
|
||||
'| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
|
||||
`| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} | ${actionText} |`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function extractInlineApplicationDraftPayload(payload = {}) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
return result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: payload?.draft_payload && typeof payload.draft_payload === 'object'
|
||||
? payload.draft_payload
|
||||
: null
|
||||
}
|
||||
|
||||
export function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) {
|
||||
const draftPayload = extractInlineApplicationDraftPayload(payload) || {}
|
||||
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||
const approvalStage = String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim()
|
||||
if (actionType === AI_APPLICATION_ACTION_SUBMIT) {
|
||||
return [
|
||||
'### 申请单据已生成,并已进入审批流程',
|
||||
approvalStage ? `系统已推送到 **${approvalStage}**,当前节点:${approvalStage}。` : '系统已推送到审批流程,当前节点:审批中。',
|
||||
buildInlineApplicationResultTable(draftPayload, {
|
||||
statusLabel: '审批中',
|
||||
stageLabel: approvalStage || '直属领导审批',
|
||||
documentTypeLabel: '出差申请'
|
||||
}),
|
||||
'需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
return [
|
||||
'### 申请草稿已保存',
|
||||
claimNo ? `系统已保存当前申请草稿,草稿单号:**${claimNo}**。` : '系统已保存当前申请草稿。',
|
||||
buildInlineApplicationResultTable(draftPayload, {
|
||||
statusLabel: '草稿',
|
||||
stageLabel: '待提交',
|
||||
documentTypeLabel: '出差申请'
|
||||
}),
|
||||
'后续请点击卡片“操作”行的“查看”进入详情页继续核对。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export function buildInlineApplicationDetailAction(draftPayload = {}) {
|
||||
const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
|
||||
if (!claimNo) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
label: '查看单据详情',
|
||||
description: '打开刚生成的申请单详情。',
|
||||
icon: 'mdi mdi-open-in-new',
|
||||
action_type: 'open_application_detail',
|
||||
payload: {
|
||||
claim_no: claimNo,
|
||||
claim_id: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
|
||||
document_type: 'application'
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
export function resolveInlineApplicationPreviewActionFromText(text = '') {
|
||||
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
|
||||
return AI_APPLICATION_ACTION_SAVE_DRAFT
|
||||
}
|
||||
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
|
||||
return AI_APPLICATION_ACTION_SUBMIT
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {
|
||||
const label = String(expenseTypeLabel || '').trim()
|
||||
if (!label) {
|
||||
return fallback
|
||||
}
|
||||
if (label.endsWith('费用申请') || label.endsWith('申请')) {
|
||||
return label
|
||||
}
|
||||
if (label.endsWith('费用')) {
|
||||
return `${label}申请`
|
||||
}
|
||||
if (label.endsWith('费')) {
|
||||
return `${label.slice(0, -1)}费用申请`
|
||||
}
|
||||
return `${label}申请`
|
||||
}
|
||||
|
||||
export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}) {
|
||||
const rawText = String(sourceText || '').trim()
|
||||
const preview = rawText
|
||||
? buildLocalApplicationPreview(rawText, currentUser)
|
||||
: buildApplicationTemplatePreview(currentUser)
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
return normalizeApplicationPreview({
|
||||
...normalized,
|
||||
fields: {
|
||||
...(normalized.fields || {}),
|
||||
applicationType: normalizeInlineApplicationTypeLabel(
|
||||
expenseTypeLabel,
|
||||
normalized.fields?.applicationType || '费用申请'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveInlineApplicationDraftIdentity(payload = {}) {
|
||||
const source = payload && typeof payload === 'object' ? payload : {}
|
||||
return {
|
||||
claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
|
||||
claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function isSameInlineApplicationDraftClaim(claim = {}, draftPayload = {}) {
|
||||
const draftIdentity = resolveInlineApplicationDraftIdentity(draftPayload)
|
||||
if (!draftIdentity.claimId && !draftIdentity.claimNo) {
|
||||
return false
|
||||
}
|
||||
const claimIdentity = resolveInlineApplicationDraftIdentity(claim)
|
||||
return Boolean(
|
||||
(draftIdentity.claimId && claimIdentity.claimId && draftIdentity.claimId === claimIdentity.claimId) ||
|
||||
(draftIdentity.claimNo && claimIdentity.claimNo && draftIdentity.claimNo === claimIdentity.claimNo)
|
||||
)
|
||||
}
|
||||
|
||||
export function buildInlineApplicationSubmitPrecheckPayload(claimsPayload, draftPayload = {}) {
|
||||
const items = extractExpenseClaimItems(claimsPayload)
|
||||
.filter((claim) => !isSameInlineApplicationDraftClaim(claim, draftPayload))
|
||||
return { items }
|
||||
}
|
||||
|
||||
export function completeInlineThinkingEvents(events = []) {
|
||||
return events.map((event) => ({
|
||||
...event,
|
||||
status: event.status === 'failed' ? 'failed' : 'completed'
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildInitialInlineApplicationSubmitThinkingEvents() {
|
||||
return [
|
||||
{
|
||||
eventId: 'application-precheck-overlap',
|
||||
title: '核查同时间段申请单',
|
||||
content: '正在查询你名下可见申请单,检查是否存在相同或重叠日期。',
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
eventId: 'application-precheck-budget',
|
||||
title: '评估预算与审批影响',
|
||||
content: '等待单据重叠核查完成后,继续评估预算占用和审批影响。',
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
eventId: 'application-submit',
|
||||
title: '提交申请单据',
|
||||
content: '等待提交前核查完成。',
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildInlineApplicationSubmitThinkingEvents(precheck = {}) {
|
||||
const blocked = isAiApplicationPrecheckBlocking(precheck)
|
||||
return buildAiApplicationPrecheckThinkingEvents(precheck).map((event) => {
|
||||
if (event.eventId !== 'application-precheck-form') {
|
||||
return event
|
||||
}
|
||||
return {
|
||||
eventId: 'application-submit',
|
||||
title: blocked ? '暂停提交申请' : '提交申请单据',
|
||||
content: blocked
|
||||
? '发现相同或重叠日期已有申请单,已暂停本次提交。'
|
||||
: '提交前核查通过,正在生成申请单据并推送审批流程。',
|
||||
status: blocked ? 'completed' : 'running'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildFailedInlineApplicationSubmitThinkingEvents(error) {
|
||||
return [
|
||||
{
|
||||
eventId: 'application-precheck-overlap',
|
||||
title: '核查同时间段申请单',
|
||||
content: `查询已有申请单失败:${String(error?.message || error || '未知错误')}`,
|
||||
status: 'failed'
|
||||
},
|
||||
{
|
||||
eventId: 'application-submit',
|
||||
title: '暂停提交申请',
|
||||
content: '因为未能完成提交前重复日期核查,系统没有提交本次申请。',
|
||||
status: 'failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
100
web/src/composables/workbenchAiMode/workbenchAiComposerModel.js
Normal file
100
web/src/composables/workbenchAiMode/workbenchAiComposerModel.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
|
||||
export const AI_COMPOSER_FILE_TYPE_META = {
|
||||
pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' },
|
||||
image: { label: '图片', icon: 'mdi mdi-file-image-outline', tone: 'image' },
|
||||
spreadsheet: { label: '表格', icon: 'mdi mdi-file-excel-outline', tone: 'spreadsheet' },
|
||||
document: { label: '文档', icon: 'mdi mdi-file-document-outline', tone: 'document' },
|
||||
archive: { label: '压缩包', icon: 'mdi mdi-folder-zip-outline', tone: 'archive' },
|
||||
file: { label: '文件', icon: 'mdi mdi-file-outline', tone: 'file' }
|
||||
}
|
||||
|
||||
export const AI_MODE_ACTION_ITEMS = [
|
||||
{
|
||||
label: '发起报销',
|
||||
icon: 'mdi mdi-file-document-plus-outline',
|
||||
prompt: '帮我发起一笔报销,并检查需要准备哪些票据材料。',
|
||||
source: 'workbench',
|
||||
sessionType: 'expense'
|
||||
},
|
||||
{
|
||||
label: '查询预算',
|
||||
icon: 'mdi mdi-chart-pie-outline',
|
||||
prompt: '帮我查询当前预算余额和近期费用占用情况。',
|
||||
source: 'budget',
|
||||
sessionType: 'budget'
|
||||
},
|
||||
{
|
||||
label: '解释制度',
|
||||
icon: 'mdi mdi-book-open-page-variant-outline',
|
||||
prompt: '帮我解释公司报销制度,并列出这次需要注意的条款。',
|
||||
source: 'workbench',
|
||||
sessionType: 'knowledge'
|
||||
},
|
||||
{
|
||||
label: '催办审批',
|
||||
icon: 'mdi mdi-bell-ring-outline',
|
||||
prompt: '帮我查询待审批单据,并生成一段礼貌的催办说明。',
|
||||
source: 'workbench',
|
||||
sessionType: 'approval'
|
||||
}
|
||||
]
|
||||
|
||||
export function resolveAiComposerFileName(file) {
|
||||
return String(file?.name || '未命名附件').trim() || '未命名附件'
|
||||
}
|
||||
|
||||
export function resolveAiComposerFileType(file) {
|
||||
const fileName = resolveAiComposerFileName(file).toLowerCase()
|
||||
const mimeType = String(file?.type || '').toLowerCase()
|
||||
const extension = fileName.includes('.') ? fileName.split('.').pop() : ''
|
||||
if (extension === 'pdf' || mimeType.includes('pdf')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.pdf
|
||||
}
|
||||
if (/^(png|jpe?g|gif|webp|bmp|svg|heic)$/.test(extension) || mimeType.startsWith('image/')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.image
|
||||
}
|
||||
if (/^(xls|xlsx|csv|numbers)$/.test(extension) || mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.spreadsheet
|
||||
}
|
||||
if (/^(doc|docx|txt|md|pages)$/.test(extension) || mimeType.includes('word') || mimeType.includes('text')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.document
|
||||
}
|
||||
if (/^(zip|rar|7z|tar|gz)$/.test(extension) || mimeType.includes('zip') || mimeType.includes('compressed')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.archive
|
||||
}
|
||||
return AI_COMPOSER_FILE_TYPE_META.file
|
||||
}
|
||||
|
||||
export function buildSelectedFileCards(files = []) {
|
||||
return files.map((file) => ({
|
||||
key: buildFileIdentity(file),
|
||||
name: resolveAiComposerFileName(file),
|
||||
...resolveAiComposerFileType(file)
|
||||
}))
|
||||
}
|
||||
|
||||
export function isLikelyAiModeOcrFile(file = {}) {
|
||||
const name = String(file?.name || '').trim()
|
||||
const type = String(file?.type || '').trim()
|
||||
return /\.(pdf|jpe?g|png|webp|bmp)$/i.test(name) || /^(image\/|application\/pdf)/i.test(type)
|
||||
}
|
||||
|
||||
export function isLikelyReceiptAssociationFile(file = {}) {
|
||||
return isLikelyAiModeOcrFile(file)
|
||||
}
|
||||
|
||||
export function shouldKeepAiAttachmentInAssistantReply(prompt = '') {
|
||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||
return /(OCR|ocr|识别|票面|票据内容|发票内容|文字|读一下|看一下)/.test(compact)
|
||||
}
|
||||
|
||||
export function shouldRunAiAttachmentAutoAssociation(entry = {}, files = [], prompt = '') {
|
||||
return Boolean(
|
||||
Array.isArray(files) &&
|
||||
files.length &&
|
||||
files.every((file) => isLikelyReceiptAssociationFile(file)) &&
|
||||
!shouldKeepAiAttachmentInAssistantReply(prompt) &&
|
||||
String(entry?.sessionType || '').trim() === 'steward'
|
||||
)
|
||||
}
|
||||
195
web/src/composables/workbenchAiMode/workbenchAiMessageModel.js
Normal file
195
web/src/composables/workbenchAiMode/workbenchAiMessageModel.js
Normal file
@@ -0,0 +1,195 @@
|
||||
export const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'
|
||||
export const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'
|
||||
|
||||
function normalizeParagraphs(content) {
|
||||
return String(content || '')
|
||||
.split(/\n{2,}|\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function stripInlineAssociationMarkdown(value = '') {
|
||||
return String(value || '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/`/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function resolveLegacyAiAttachmentAssociationPayload(content = '') {
|
||||
const text = String(content || '')
|
||||
if (!/我已先识别票据,并(?:匹配到最可能的报销单|找到一张可能关联的报销单)/.test(text)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const claimNo = stripInlineAssociationMarkdown(
|
||||
text.match(/推荐关联[::]\s*([^\n]+)/u)?.[1] || ''
|
||||
)
|
||||
if (!claimNo) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
claim_no: claimNo,
|
||||
document_type: 'expense'
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateInlineAttachmentAssociationSuggestedActions(actions = [], content = '') {
|
||||
const safeActions = Array.isArray(actions) ? actions : []
|
||||
const hasConfirmAction = safeActions.some(
|
||||
(action) => String(action?.action_type || '').trim() === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION
|
||||
)
|
||||
if (hasConfirmAction) {
|
||||
return safeActions
|
||||
}
|
||||
|
||||
const payload = resolveLegacyAiAttachmentAssociationPayload(content)
|
||||
if (!payload) {
|
||||
return safeActions
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '确认自动关联',
|
||||
description: '将本次票据自动归集到推荐单据。',
|
||||
icon: 'mdi mdi-link-variant',
|
||||
action_type: AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
|
||||
payload
|
||||
},
|
||||
...safeActions
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeInlineAttachmentOcrField(field = {}) {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null
|
||||
}
|
||||
const value = String(field.value ?? field.text ?? '').trim()
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label: String(field.label || field.key || field.name || '识别字段').trim() || '识别字段',
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInlineAttachmentOcrDocument(document = {}, index = 0) {
|
||||
const fields = (Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || [])
|
||||
.map((field) => normalizeInlineAttachmentOcrField(field))
|
||||
.filter(Boolean)
|
||||
.slice(0, 12)
|
||||
const summary = String(document?.summary || document?.text || '').replace(/\s+/g, ' ').trim()
|
||||
const filename = String(document?.filename || document?.name || '').trim()
|
||||
if (!filename && !summary && !fields.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
filename: filename || `附件 ${index + 1}`,
|
||||
summary,
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeInlineAttachmentOcrDetails(details = null) {
|
||||
if (!details || typeof details !== 'object') {
|
||||
return null
|
||||
}
|
||||
const documents = (Array.isArray(details.documents) ? details.documents : details.ocrDocuments || [])
|
||||
.map((document, index) => normalizeInlineAttachmentOcrDocument(document, index))
|
||||
.filter(Boolean)
|
||||
const fileNames = (Array.isArray(details.fileNames) ? details.fileNames : [])
|
||||
.map((name) => String(name || '').trim())
|
||||
.filter(Boolean)
|
||||
if (!documents.length && !fileNames.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
fileNames,
|
||||
documents
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInlineAttachmentOcrDetails(collected = {}, files = []) {
|
||||
return normalizeInlineAttachmentOcrDetails({
|
||||
fileNames: files.map((file) => file?.name || '').filter(Boolean),
|
||||
documents: collected?.ocrDocuments || []
|
||||
})
|
||||
}
|
||||
|
||||
export function formatMessageTime(timestamp) {
|
||||
if (!timestamp) return ''
|
||||
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function createWorkbenchAiMessageRuntime() {
|
||||
let messageSeq = 0
|
||||
|
||||
function nextMessageId() {
|
||||
messageSeq += 1
|
||||
return `${Date.now()}-${messageSeq}`
|
||||
}
|
||||
|
||||
function createAiAttachmentAssociationId() {
|
||||
messageSeq += 1
|
||||
return `ai-attachment-${Date.now()}-${messageSeq}`
|
||||
}
|
||||
|
||||
function createInlineMessage(role, content, options = {}) {
|
||||
const normalizedContent = String(content || '').trim()
|
||||
const suggestedActions = Array.isArray(options.suggestedActions) ? options.suggestedActions : []
|
||||
return {
|
||||
id: options.id || nextMessageId(),
|
||||
role,
|
||||
content: normalizedContent,
|
||||
paragraphs: normalizeParagraphs(normalizedContent),
|
||||
pending: Boolean(options.pending),
|
||||
feedback: String(options.feedback || ''),
|
||||
stewardPlan: options.stewardPlan || null,
|
||||
suggestedActions: role === 'assistant'
|
||||
? hydrateInlineAttachmentAssociationSuggestedActions(suggestedActions, normalizedContent)
|
||||
: suggestedActions,
|
||||
applicationPreview: options.applicationPreview || null,
|
||||
draftPayload: options.draftPayload || null,
|
||||
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
|
||||
text: options.text || normalizedContent,
|
||||
createdAt: options.createdAt || Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeMessage(message = {}) {
|
||||
return createInlineMessage(message.role || 'assistant', message.content || '', {
|
||||
id: message.id,
|
||||
pending: false,
|
||||
feedback: message.feedback || '',
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null,
|
||||
text: message.text || message.content || ''
|
||||
})
|
||||
}
|
||||
|
||||
function serializeRuntimeMessage(message = {}) {
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
text: message.text || message.content || '',
|
||||
feedback: message.feedback || '',
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
normalizeRuntimeMessage,
|
||||
serializeRuntimeMessage
|
||||
}
|
||||
}
|
||||
521
web/src/utils/aiAttachmentAssociationModel.js
Normal file
521
web/src/utils/aiAttachmentAssociationModel.js
Normal file
@@ -0,0 +1,521 @@
|
||||
import { buildDraftAssociationQueryPayload } from '../views/scripts/travelReimbursementExpenseQueryModel.js'
|
||||
|
||||
const CITY_NAMES = [
|
||||
'北京',
|
||||
'上海',
|
||||
'广州',
|
||||
'深圳',
|
||||
'武汉',
|
||||
'南京',
|
||||
'杭州',
|
||||
'成都',
|
||||
'重庆',
|
||||
'西安',
|
||||
'天津',
|
||||
'苏州',
|
||||
'长沙',
|
||||
'郑州',
|
||||
'青岛',
|
||||
'厦门',
|
||||
'宁波',
|
||||
'无锡',
|
||||
'合肥',
|
||||
'福州',
|
||||
'昆明',
|
||||
'大连',
|
||||
'沈阳',
|
||||
'济南',
|
||||
'哈尔滨',
|
||||
'长春',
|
||||
'南昌',
|
||||
'太原',
|
||||
'贵阳',
|
||||
'南宁',
|
||||
'石家庄',
|
||||
'兰州',
|
||||
'银川',
|
||||
'西宁',
|
||||
'海口',
|
||||
'拉萨'
|
||||
]
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function escapeHtml(value = '') {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function unique(values = []) {
|
||||
return Array.from(new Set(values.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function collectOcrText(ocrDocuments = []) {
|
||||
return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
|
||||
.flatMap((document) => {
|
||||
const fields = Array.isArray(document?.document_fields)
|
||||
? document.document_fields.flatMap((field) => [field?.label, field?.value])
|
||||
: []
|
||||
return [document?.filename, document?.summary, document?.text, ...fields]
|
||||
})
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function normalizeDateToken(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const fullDateMatch = text.match(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
|
||||
if (fullDateMatch) {
|
||||
const [, year, month, day] = fullDateMatch
|
||||
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const shortDateMatch = text.match(/(\d{1,2})月(\d{1,2})/)
|
||||
if (shortDateMatch) {
|
||||
const [, month, day] = shortDateMatch
|
||||
return `${month.padStart(2, '0')}-${day.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractDateTokens(text) {
|
||||
const source = String(text || '')
|
||||
const matches = [
|
||||
...source.matchAll(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}/g),
|
||||
...source.matchAll(/\d{1,2}月\d{1,2}/g)
|
||||
]
|
||||
return unique(matches.map((match) => normalizeDateToken(match[0])))
|
||||
}
|
||||
|
||||
function extractCityTokens(text) {
|
||||
const compact = normalizeText(text)
|
||||
if (!compact) {
|
||||
return []
|
||||
}
|
||||
return CITY_NAMES.filter((city) => compact.includes(city))
|
||||
}
|
||||
|
||||
function collectFieldSignals(ocrDocuments = []) {
|
||||
return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
|
||||
.flatMap((document) => Array.isArray(document?.document_fields) ? document.document_fields : [])
|
||||
.filter((field) => {
|
||||
const label = normalizeText(field?.label)
|
||||
return /(日期|时间|发生|开票|出发|到达|起点|终点|地点|城市|路线|行程)/.test(label)
|
||||
})
|
||||
.map((field) => `${field?.label || ''} ${field?.value || ''}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function collectAiAttachmentAssociationSignals(ocrDocuments = []) {
|
||||
const documentText = collectOcrText(ocrDocuments)
|
||||
const fieldText = collectFieldSignals(ocrDocuments)
|
||||
const combinedText = `${documentText} ${fieldText}`
|
||||
|
||||
return {
|
||||
text: combinedText,
|
||||
compactText: normalizeText(combinedText),
|
||||
dates: extractDateTokens(combinedText),
|
||||
cities: unique(extractCityTokens(combinedText))
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecordText(record = {}) {
|
||||
return [
|
||||
record.claimNo,
|
||||
record.expenseTypeLabel,
|
||||
record.statusLabel,
|
||||
record.reason,
|
||||
record.location,
|
||||
record.occurredAt,
|
||||
record.documentDate,
|
||||
record.summary
|
||||
].map((item) => String(item || '').trim()).filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function scoreRecord(record = {}, signals = {}) {
|
||||
const recordText = buildRecordText(record)
|
||||
const compactRecordText = normalizeText(recordText)
|
||||
const recordDates = extractDateTokens(recordText)
|
||||
const recordCities = unique([...extractCityTokens(recordText), ...extractCityTokens(record.location)])
|
||||
const reasons = []
|
||||
let score = 0
|
||||
|
||||
const dateMatched = (signals.dates || []).some((date) => {
|
||||
if (!date) return false
|
||||
return recordDates.some((recordDate) => recordDate === date || recordDate.endsWith(date) || date.endsWith(recordDate))
|
||||
})
|
||||
if (dateMatched) {
|
||||
score += 4
|
||||
reasons.push('票据日期与报销单日期一致')
|
||||
}
|
||||
|
||||
const matchedCities = (signals.cities || []).filter((city) => compactRecordText.includes(city))
|
||||
if (matchedCities.length) {
|
||||
const cityScore = Math.min(4, matchedCities.length * 2)
|
||||
score += cityScore
|
||||
reasons.push(`地点或行程包含 ${matchedCities.join('、')}`)
|
||||
}
|
||||
|
||||
if (recordCities.length >= 2 && matchedCities.length >= 2) {
|
||||
score += 2
|
||||
reasons.push('票据往返城市与报销事由吻合')
|
||||
}
|
||||
|
||||
if (String(record.status || '').trim() === 'draft') {
|
||||
score += 1
|
||||
reasons.push('当前单据仍是可归集草稿')
|
||||
}
|
||||
|
||||
return {
|
||||
record,
|
||||
score,
|
||||
reasons
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiAttachmentAssociationMatch(claims = [], ocrDocuments = []) {
|
||||
const queryPayload = buildDraftAssociationQueryPayload(claims)
|
||||
const records = Array.isArray(queryPayload?.records) ? queryPayload.records : []
|
||||
const signals = collectAiAttachmentAssociationSignals(ocrDocuments)
|
||||
const rankedRecords = records
|
||||
.map((record) => scoreRecord(record, signals))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
|
||||
const recommended = rankedRecords[0] || null
|
||||
const runnerUp = rankedRecords[1] || null
|
||||
const highConfidence = Boolean(
|
||||
recommended &&
|
||||
recommended.score >= 5 &&
|
||||
(!runnerUp || recommended.score - runnerUp.score >= 2)
|
||||
)
|
||||
|
||||
return {
|
||||
queryPayload,
|
||||
signals,
|
||||
rankedRecords,
|
||||
recommended,
|
||||
best: highConfidence ? recommended : null,
|
||||
highConfidence
|
||||
}
|
||||
}
|
||||
|
||||
function formatCandidateLine(candidate, index) {
|
||||
const record = candidate?.record || {}
|
||||
const claimNo = String(record.claimNo || '未编号').trim()
|
||||
const date = String(record.occurredAt || record.documentDate || '日期待补充').trim()
|
||||
const location = String(record.location || '地点待补充').trim()
|
||||
const reason = resolveRecordBusinessDescription(record) || '报销事项'
|
||||
return `${index + 1}. ${claimNo},${date},${location},${reason}`
|
||||
}
|
||||
|
||||
function wrapTrustedHtml(html = '') {
|
||||
return [
|
||||
'<!-- ai-trusted-html:start -->',
|
||||
html,
|
||||
'<!-- ai-trusted-html:end -->'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderAssociationField(label = '', value = '', options = {}) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const fieldClass = options.wide ? ' ai-document-card__field--wide' : ''
|
||||
const valueClass = options.muted ? ' ai-attachment-association__muted' : ''
|
||||
return [
|
||||
`<div class="ai-document-card__field${fieldClass}">`,
|
||||
`<span class="ai-document-card__label">${escapeHtml(label)}</span>`,
|
||||
`<strong class="ai-document-card__value${valueClass}">${escapeHtml(text)}</strong>`,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function formatAttachmentNames(fileNames = []) {
|
||||
const names = unique(fileNames)
|
||||
if (!names.length) {
|
||||
return '已接收票据附件'
|
||||
}
|
||||
return `${names.length} 份:${names.slice(0, 2).join('、')}${names.length > 2 ? ' 等' : ''}`
|
||||
}
|
||||
|
||||
function formatSignalSummary(match = null) {
|
||||
const dates = Array.isArray(match?.signals?.dates) ? match.signals.dates : []
|
||||
const cities = Array.isArray(match?.signals?.cities) ? match.signals.cities : []
|
||||
return [
|
||||
dates.length ? `日期 ${dates.slice(0, 2).join('、')}` : '',
|
||||
cities.length ? `城市 ${cities.slice(0, 4).join('、')}` : ''
|
||||
].filter(Boolean).join(';') || '已识别票据关键信息'
|
||||
}
|
||||
|
||||
function isNoisyAssociationText(value = '') {
|
||||
const text = String(value || '').replace(/\s+/g, '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
if (!/[\u4e00-\u9fa5A-Za-z]/.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (/^[::;;,,.\-\d]+$/.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (/^[::;;]/.test(text) && /\d{6,}/.test(text)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeBusinessDescription(value = '') {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||||
return isNoisyAssociationText(text) ? '' : text
|
||||
}
|
||||
|
||||
function resolveRecordBusinessDescription(record = {}) {
|
||||
return (
|
||||
normalizeBusinessDescription(record.reason) ||
|
||||
normalizeBusinessDescription(record.summary)
|
||||
)
|
||||
}
|
||||
|
||||
function truncateOcrDetail(value = '', maxLength = 180) {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text
|
||||
}
|
||||
return `${text.slice(0, maxLength - 1)}…`
|
||||
}
|
||||
|
||||
function formatOcrDocumentDetail(document = {}) {
|
||||
const filename = String(document?.filename || '').trim()
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
const fieldText = fields
|
||||
.map((field) => {
|
||||
const label = String(field?.label || '').trim()
|
||||
const value = String(field?.value || '').trim()
|
||||
return label && value ? `${label}:${value}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
.join(',')
|
||||
const fallbackText = String(document?.summary || document?.text || '').trim()
|
||||
const detailText = truncateOcrDetail(fieldText || fallbackText)
|
||||
return [filename, detailText].filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
function formatOcrDocumentDetails(ocrDocuments = []) {
|
||||
return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
|
||||
.map((document) => formatOcrDocumentDetail(document))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.join(';')
|
||||
}
|
||||
|
||||
function renderAssociationCard({
|
||||
title = '',
|
||||
status = '',
|
||||
tone = 'is-warning',
|
||||
className = '',
|
||||
ariaLabel = '票据关联确认',
|
||||
fields = [],
|
||||
note = ''
|
||||
} = {}) {
|
||||
const normalizedClassName = String(className || '').trim()
|
||||
return wrapTrustedHtml([
|
||||
`<section class="ai-document-card-list" aria-label="${escapeHtml(ariaLabel)}">`,
|
||||
`<article class="ai-document-card ai-attachment-association-card${normalizedClassName ? ` ${escapeHtml(normalizedClassName)}` : ''} ${tone}">`,
|
||||
'<header class="ai-document-card__head">',
|
||||
`<strong class="ai-document-card__reason">${escapeHtml(title)}</strong>`,
|
||||
status ? `<span class="ai-document-card__status">${escapeHtml(status)}</span>` : '',
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
'<div class="ai-document-card__details ai-attachment-association__details">',
|
||||
fields.join(''),
|
||||
'</div>',
|
||||
note ? `<div class="ai-attachment-association__note">${escapeHtml(note)}</div>` : '',
|
||||
'</div>',
|
||||
'</article>',
|
||||
'</section>'
|
||||
].join(''))
|
||||
}
|
||||
|
||||
function renderOcrRecognitionCard({ attachmentLabel = '', signalSummary = '', ocrDetailSummary = '' } = {}) {
|
||||
return renderAssociationCard({
|
||||
title: '票据识别结果',
|
||||
status: '已识别',
|
||||
tone: 'is-pending',
|
||||
className: 'ai-ocr-recognition-card',
|
||||
ariaLabel: '票据 OCR 识别结果',
|
||||
fields: [
|
||||
renderAssociationField('本次附件', attachmentLabel),
|
||||
renderAssociationField('识别线索', signalSummary),
|
||||
ocrDetailSummary
|
||||
? renderAssociationField('票面识别', ocrDetailSummary, { wide: true, muted: true })
|
||||
: ''
|
||||
].filter(Boolean),
|
||||
note: '我会基于这些票面信息继续查询可关联单据。'
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAiAttachmentAssociationMessage({
|
||||
match = null,
|
||||
fileNames = [],
|
||||
ocrDocuments = []
|
||||
} = {}) {
|
||||
const attachmentLabel = formatAttachmentNames(fileNames)
|
||||
const signalSummary = formatSignalSummary(match)
|
||||
const ocrDetailSummary = formatOcrDocumentDetails(ocrDocuments)
|
||||
const recognitionCard = renderOcrRecognitionCard({
|
||||
attachmentLabel,
|
||||
signalSummary,
|
||||
ocrDetailSummary
|
||||
})
|
||||
|
||||
if (!match?.rankedRecords?.length) {
|
||||
return [
|
||||
'我已先完成票据识别,识别结果如下。',
|
||||
recognitionCard,
|
||||
'我又查询了可关联单据,但当前没有查到可关联的报销草稿或待补充单据。',
|
||||
renderAssociationCard({
|
||||
title: '未找到可关联单据',
|
||||
status: '未归集',
|
||||
tone: 'is-warning',
|
||||
fields: [
|
||||
renderAssociationField('查询范围', '可归集草稿、待补充和退回单据', { wide: true }),
|
||||
renderAssociationField('处理建议', '暂不归集,避免把票据放错位置', { wide: true, muted: true })
|
||||
],
|
||||
note: '我先不做归集,避免把票据放错位置。'
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
if (match.highConfidence && match.best?.record) {
|
||||
const record = match.best.record
|
||||
const recordDescription = resolveRecordBusinessDescription(record)
|
||||
const reasons = match.best.reasons.length
|
||||
? match.best.reasons.join(';')
|
||||
: '票据信息与单据基础信息吻合'
|
||||
return [
|
||||
'我已先完成票据识别,识别结果如下。',
|
||||
recognitionCard,
|
||||
'我根据上述票面信息找到一张最可能关联的报销单。请确认是否自动归集:',
|
||||
renderAssociationCard({
|
||||
title: '可能关联单据',
|
||||
status: '待确认',
|
||||
tone: 'is-warning',
|
||||
fields: [
|
||||
renderAssociationField('推荐单据', record.claimNo),
|
||||
recordDescription
|
||||
? renderAssociationField('关联事项', recordDescription, { wide: true })
|
||||
: '',
|
||||
renderAssociationField('匹配依据', reasons, { wide: true, muted: true })
|
||||
].filter(Boolean),
|
||||
note: '确认后,我会把这些附件自动归集到该单据,并反馈处理结果。'
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
const candidates = match.rankedRecords.slice(0, 3).map(formatCandidateLine)
|
||||
return [
|
||||
'我已先完成票据识别,识别结果如下。',
|
||||
recognitionCard,
|
||||
'我根据上述票面信息查询到候选单据,但还不能放心自动锁定。',
|
||||
renderAssociationCard({
|
||||
title: '候选单据待核对',
|
||||
status: '需确认',
|
||||
tone: 'is-warning',
|
||||
fields: [
|
||||
renderAssociationField('候选单据', candidates.join(';'), { wide: true, muted: true })
|
||||
],
|
||||
note: '如果这就是要归集的单据,可直接点下方“确认自动关联”;不确定时也可以先查看单据。'
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export function buildAiAttachmentAssociationResultMessage({
|
||||
claimNo = '',
|
||||
uploadedCount = 0,
|
||||
skippedCount = 0,
|
||||
fileNames = []
|
||||
} = {}) {
|
||||
const normalizedUploadedCount = Math.max(0, Number(uploadedCount || 0))
|
||||
const normalizedSkippedCount = Math.max(0, Number(skippedCount || 0))
|
||||
const done = normalizedUploadedCount > 0 && normalizedSkippedCount === 0
|
||||
return [
|
||||
done ? '已完成自动归集。' : '自动归集已处理完成,请留意未归集附件。',
|
||||
renderAssociationCard({
|
||||
title: done ? '票据已归集' : '票据归集结果',
|
||||
status: done ? '已完成' : '部分完成',
|
||||
tone: done ? 'is-success' : 'is-warning',
|
||||
fields: [
|
||||
renderAssociationField('关联单据', claimNo || '当前匹配单据'),
|
||||
renderAssociationField('归集结果', `${normalizedUploadedCount} 份成功${normalizedSkippedCount ? `,${normalizedSkippedCount} 份未归集` : ''}`),
|
||||
renderAssociationField('附件', formatAttachmentNames(fileNames), { wide: true })
|
||||
],
|
||||
note: done
|
||||
? '附件已经写入该报销单,可进入详情页继续核对。'
|
||||
: '部分附件没有找到可用明细项,请进入详情页手动核对。'
|
||||
})
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
export function buildAiAttachmentAssociationActions(match = null, associationId = '', options = {}) {
|
||||
const record = match?.best?.record || match?.recommended?.record
|
||||
const actions = []
|
||||
if (options.includeOcrDetails) {
|
||||
actions.push({
|
||||
label: '查看附件信息',
|
||||
description: '展开本次上传附件的 OCR 识别明细。',
|
||||
icon: 'mdi mdi-file-search-outline',
|
||||
action_type: 'show_ai_attachment_ocr_details',
|
||||
payload: {}
|
||||
})
|
||||
}
|
||||
|
||||
if (!record?.claimNo && !record?.claimId) {
|
||||
return actions
|
||||
}
|
||||
|
||||
const payload = {
|
||||
claim_id: String(record.claimId || '').trim(),
|
||||
claim_no: String(record.claimNo || '').trim(),
|
||||
document_type: 'expense'
|
||||
}
|
||||
const normalizedAssociationId = String(associationId || '').trim()
|
||||
|
||||
if (payload.claim_id && normalizedAssociationId) {
|
||||
actions.push({
|
||||
label: '确认自动关联',
|
||||
description: '把本次票据自动归集到匹配单据。',
|
||||
icon: 'mdi mdi-link-variant',
|
||||
action_type: 'confirm_ai_attachment_association',
|
||||
payload: {
|
||||
...payload,
|
||||
association_id: normalizedAssociationId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: '查看单据',
|
||||
description: '先打开匹配单据核对详情。',
|
||||
icon: 'mdi mdi-open-in-new',
|
||||
action_type: 'open_application_detail',
|
||||
payload
|
||||
})
|
||||
|
||||
return actions
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { renderLegacyAttachmentAssociationHtml } from './aiConversationLegacyAttachmentRenderer.js'
|
||||
import { parseTableRow, renderTable } from './aiConversationTableRenderer.js'
|
||||
|
||||
const ALLOWED_COLON_HEADING_TITLES = new Set([
|
||||
'基础信息识别结果',
|
||||
'报销测算参考',
|
||||
@@ -25,18 +28,6 @@ const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
||||
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
||||
const DOCUMENT_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
submitted: '审批中',
|
||||
pending: '待处理',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
returned: '已退回',
|
||||
rejected: '已驳回',
|
||||
pending_payment: '待付款',
|
||||
paid: '已付款'
|
||||
}
|
||||
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
|
||||
'section',
|
||||
'article',
|
||||
@@ -349,18 +340,6 @@ function parseImageLine(line = '') {
|
||||
}
|
||||
}
|
||||
|
||||
function parseTableRow(line = '') {
|
||||
const trimmed = String(line || '').trim()
|
||||
if (!trimmed.startsWith('|')) {
|
||||
return []
|
||||
}
|
||||
return trimmed
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
function splitLabelAndBody(rawText = '') {
|
||||
const text = String(rawText || '').trim()
|
||||
const strongMatch = text.match(/^\*\*([^*]+)\*\*[::]\s*(.*)$/u)
|
||||
@@ -510,174 +489,17 @@ function renderOrderedList(items = []) {
|
||||
].join('')
|
||||
}
|
||||
|
||||
function normalizeTableHeaderCell(value = '') {
|
||||
return String(value || '').replace(/\s+/g, '').trim()
|
||||
}
|
||||
|
||||
function findTableColumnIndex(normalizedHeader = [], labels = []) {
|
||||
return labels
|
||||
.map((label) => normalizedHeader.indexOf(label))
|
||||
.find((index) => index >= 0) ?? -1
|
||||
}
|
||||
|
||||
function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
|
||||
const columnIndex = findTableColumnIndex(normalizedHeader, labels)
|
||||
return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
|
||||
}
|
||||
|
||||
function hasMeaningfulTableValue(value = '') {
|
||||
const text = String(value || '').trim()
|
||||
return Boolean(text && text !== '-')
|
||||
}
|
||||
|
||||
function normalizeDocumentStatusLabel(status = '') {
|
||||
const text = String(status || '').trim()
|
||||
if (!text || text === '-') {
|
||||
return ''
|
||||
}
|
||||
return DOCUMENT_STATUS_LABELS[text.toLowerCase()] || text
|
||||
}
|
||||
|
||||
function resolveDocumentRecordTone(status = '', stage = '') {
|
||||
const normalizedStatus = normalizeDocumentStatusLabel(status)
|
||||
const text = `${normalizedStatus || String(status || '')} ${String(stage || '')}`.trim()
|
||||
if (/已删除|已驳回|驳回|拒绝|失败/.test(text)) {
|
||||
return 'is-danger'
|
||||
}
|
||||
if (/已审批|审批通过|已完成|已归档|已付款|已支付|可通过/.test(text)) {
|
||||
return 'is-success'
|
||||
}
|
||||
if (/草稿|待提交|待补充|已退回|退回/.test(text)) {
|
||||
return 'is-warning'
|
||||
}
|
||||
return 'is-pending'
|
||||
}
|
||||
|
||||
function isDocumentRecordTable(normalizedHeader = []) {
|
||||
return (
|
||||
normalizedHeader.includes('单据编号') &&
|
||||
normalizedHeader.includes('操作') &&
|
||||
normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
function renderDocumentCardField(label = '', value = '', options = {}) {
|
||||
if (!hasMeaningfulTableValue(value)) {
|
||||
return ''
|
||||
}
|
||||
const valueClass = options.valueClass ? ` ${options.valueClass}` : ''
|
||||
return [
|
||||
`<div class="ai-document-card__field${options.fieldClass ? ` ${options.fieldClass}` : ''}">`,
|
||||
`<span class="ai-document-card__label">${escapeHtml(label)}</span>`,
|
||||
`<strong class="ai-document-card__value${valueClass}">${renderInlineHtml(value)}</strong>`,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderDocumentCardAction(action = '') {
|
||||
if (!hasMeaningfulTableValue(action)) {
|
||||
return ''
|
||||
}
|
||||
const actionHtml = renderInlineHtml(action).replace(
|
||||
/class="ai-html-action-link\s+/g,
|
||||
'class="ai-html-action-link ai-document-card__action '
|
||||
)
|
||||
return [
|
||||
'<div class="ai-document-card__field ai-document-card__field--action">',
|
||||
'<span class="ai-document-card__label">操作</span>',
|
||||
actionHtml,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderDocumentRecordList(header = [], bodyRows = []) {
|
||||
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||
const items = bodyRows.map((row) => {
|
||||
const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
|
||||
const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
|
||||
const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间', '日期', '时间'])
|
||||
const location = resolveTableCell(row, normalizedHeader, ['地点', '目的地'])
|
||||
const amount = resolveTableCell(row, normalizedHeader, ['金额', '预计金额', '报销金额'])
|
||||
const status = normalizeDocumentStatusLabel(resolveTableCell(row, normalizedHeader, ['单据状态', '状态']))
|
||||
const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
|
||||
const reason = resolveTableCell(row, normalizedHeader, ['事由'])
|
||||
const action = resolveTableCell(row, normalizedHeader, ['操作'])
|
||||
const tone = resolveDocumentRecordTone(status, stage)
|
||||
const title = documentType || reason || documentNo || '单据详情'
|
||||
const summarySecondField = amount
|
||||
? renderDocumentCardField('金额', amount, { valueClass: 'ai-document-card__amount' })
|
||||
: renderDocumentCardField('当前节点', stage || status || '待确认')
|
||||
const summaryHtml = [
|
||||
renderDocumentCardField('日期', applyTime || '待补充'),
|
||||
summarySecondField
|
||||
].join('')
|
||||
const detailsHtml = [
|
||||
renderDocumentCardField('地点', location || '待补充'),
|
||||
renderDocumentCardField('单据编号', documentNo, { valueClass: 'ai-document-card__number' }),
|
||||
renderDocumentCardField('事由', reason || '待补充'),
|
||||
amount ? renderDocumentCardField('当前节点', stage || status || '待确认') : '',
|
||||
renderDocumentCardAction(action),
|
||||
renderDocumentCardField('单据类型', documentType)
|
||||
].join('')
|
||||
return [
|
||||
`<article class="ai-document-card ${tone}" role="listitem" aria-label="单据详情">`,
|
||||
'<header class="ai-document-card__head">',
|
||||
`<strong class="ai-document-card__reason">${renderInlineHtml(title)}</strong>`,
|
||||
hasMeaningfulTableValue(status) ? `<span class="ai-document-card__status">${renderInlineHtml(status)}</span>` : '',
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
summaryHtml ? `<div class="ai-document-card__summary">${summaryHtml}</div>` : '',
|
||||
'<div class="ai-document-card__details">',
|
||||
detailsHtml,
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</article>'
|
||||
].join('')
|
||||
}).filter(Boolean)
|
||||
|
||||
return [
|
||||
'<section class="ai-document-card-list" role="list" aria-label="单据结果">',
|
||||
...items,
|
||||
'</section>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderTable(lines = []) {
|
||||
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
|
||||
if (rows.length < 2) {
|
||||
return ''
|
||||
}
|
||||
const header = rows[0]
|
||||
const bodyRows = rows.slice(2)
|
||||
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||
if (isDocumentRecordTable(normalizedHeader)) {
|
||||
return renderDocumentRecordList(header, bodyRows)
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="ai-html-table-wrap">',
|
||||
'<table>',
|
||||
'<thead><tr>',
|
||||
...header.map((cell) => `<th>${renderInlineHtml(cell)}</th>`),
|
||||
'</tr></thead>',
|
||||
'<tbody>',
|
||||
...bodyRows.map((row) => [
|
||||
'<tr>',
|
||||
...header.map((_cell, index) => `<td>${renderInlineHtml(row[index] || '')}</td>`),
|
||||
'</tr>'
|
||||
].join('')),
|
||||
'</tbody>',
|
||||
'</table>',
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderCodeBlock(lines = []) {
|
||||
const code = lines.join('\n').replace(/\n$/, '')
|
||||
return `<pre class="ai-html-code"><code>${escapeHtml(code)}</code></pre>`
|
||||
}
|
||||
|
||||
export function renderAiConversationHtml(content = '') {
|
||||
const legacyAttachmentAssociationHtml = renderLegacyAttachmentAssociationHtml(content, { escapeHtml })
|
||||
if (legacyAttachmentAssociationHtml) {
|
||||
return legacyAttachmentAssociationHtml
|
||||
}
|
||||
|
||||
const extracted = extractTrustedHtmlBlocks(content)
|
||||
const normalized = normalizeConversationText(extracted.content)
|
||||
if (!normalized) {
|
||||
@@ -723,7 +545,7 @@ export function renderAiConversationHtml(content = '') {
|
||||
tableLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
blocks.push(renderTable(tableLines))
|
||||
blocks.push(renderTable(tableLines, { escapeHtml, renderInlineHtml }))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
87
web/src/utils/aiConversationLegacyAttachmentRenderer.js
Normal file
87
web/src/utils/aiConversationLegacyAttachmentRenderer.js
Normal file
@@ -0,0 +1,87 @@
|
||||
function stripInlineMarkdownMarkers(value = '') {
|
||||
return String(value || '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/`/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function resolveLegacyAttachmentAssociationField(text = '', label = '') {
|
||||
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = String(text || '').match(new RegExp(`${escapedLabel}[::]\\s*([^\\n]+)`, 'u'))
|
||||
return stripInlineMarkdownMarkers(match?.[1] || '')
|
||||
}
|
||||
|
||||
function renderLegacyAttachmentAssociationField(label = '', value = '', options = {}, context = {}) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
`<div class="ai-document-card__field${options.wide ? ' ai-document-card__field--wide' : ''}">`,
|
||||
`<span class="ai-document-card__label">${context.escapeHtml(label)}</span>`,
|
||||
`<strong class="ai-document-card__value${options.muted ? ' ai-attachment-association__muted' : ''}">${context.escapeHtml(text)}</strong>`,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function isNoisyLegacyAssociationText(value = '') {
|
||||
const text = String(value || '').replace(/\s+/g, '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
if (!/[\u4e00-\u9fa5A-Za-z]/.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (/^[::;;,,.\-\d]+$/.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (/^[::;;]/.test(text) && /\d{6,}/.test(text)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeLegacyAssociationDescription(value = '') {
|
||||
const text = stripInlineMarkdownMarkers(value).replace(/\s+/g, ' ').trim()
|
||||
return isNoisyLegacyAssociationText(text) ? '' : text
|
||||
}
|
||||
|
||||
export function renderLegacyAttachmentAssociationHtml(content = '', options = {}) {
|
||||
const context = {
|
||||
escapeHtml: options.escapeHtml || ((item) => String(item || ''))
|
||||
}
|
||||
const text = String(content || '')
|
||||
if (!/我已先识别票据,并匹配到最可能的报销单/.test(text)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const attachment = resolveLegacyAttachmentAssociationField(text, '本次附件')
|
||||
const summary = resolveLegacyAttachmentAssociationField(text, '识别摘要')
|
||||
const claimNo = resolveLegacyAttachmentAssociationField(text, '推荐关联')
|
||||
const reason = normalizeLegacyAssociationDescription(resolveLegacyAttachmentAssociationField(text, '单据事项'))
|
||||
const basis = resolveLegacyAttachmentAssociationField(text, '匹配依据')
|
||||
|
||||
return [
|
||||
'<div class="ai-html-flow">',
|
||||
'<p class="ai-html-paragraph">我已先识别票据,并找到一张可能关联的报销单。请确认是否自动归集:</p>',
|
||||
'<section class="ai-document-card-list" aria-label="票据关联确认">',
|
||||
'<article class="ai-document-card ai-attachment-association-card is-warning">',
|
||||
'<header class="ai-document-card__head">',
|
||||
'<strong class="ai-document-card__reason">可能关联单据</strong>',
|
||||
'<span class="ai-document-card__status">待确认</span>',
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
'<div class="ai-document-card__details ai-attachment-association__details">',
|
||||
renderLegacyAttachmentAssociationField('推荐单据', claimNo, {}, context),
|
||||
renderLegacyAttachmentAssociationField('本次附件', attachment, {}, context),
|
||||
renderLegacyAttachmentAssociationField('识别摘要', summary, { wide: true, muted: true }, context),
|
||||
renderLegacyAttachmentAssociationField('关联事项', reason, { wide: true }, context),
|
||||
renderLegacyAttachmentAssociationField('匹配依据', basis, { wide: true, muted: true }, context),
|
||||
'</div>',
|
||||
'<div class="ai-attachment-association__note">如果这就是要归集的单据,可直接使用下方快捷按钮;不确定时也可以先查看单据。</div>',
|
||||
'</div>',
|
||||
'</article>',
|
||||
'</section>',
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
192
web/src/utils/aiConversationTableRenderer.js
Normal file
192
web/src/utils/aiConversationTableRenderer.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const DOCUMENT_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
submitted: '审批中',
|
||||
pending: '待处理',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
returned: '已退回',
|
||||
rejected: '已驳回',
|
||||
pending_payment: '待付款',
|
||||
paid: '已付款'
|
||||
}
|
||||
|
||||
function normalizeTableHeaderCell(value = '') {
|
||||
return String(value || '').replace(/\s+/g, '').trim()
|
||||
}
|
||||
|
||||
function findTableColumnIndex(normalizedHeader = [], labels = []) {
|
||||
return labels
|
||||
.map((label) => normalizedHeader.indexOf(label))
|
||||
.find((index) => index >= 0) ?? -1
|
||||
}
|
||||
|
||||
function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
|
||||
const columnIndex = findTableColumnIndex(normalizedHeader, labels)
|
||||
return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
|
||||
}
|
||||
|
||||
function hasMeaningfulTableValue(value = '') {
|
||||
const text = String(value || '').trim()
|
||||
return Boolean(text && text !== '-')
|
||||
}
|
||||
|
||||
function normalizeDocumentStatusLabel(status = '') {
|
||||
const text = String(status || '').trim()
|
||||
if (!text || text === '-') {
|
||||
return ''
|
||||
}
|
||||
return DOCUMENT_STATUS_LABELS[text.toLowerCase()] || text
|
||||
}
|
||||
|
||||
function resolveDocumentRecordTone(status = '', stage = '') {
|
||||
const normalizedStatus = normalizeDocumentStatusLabel(status)
|
||||
const text = `${normalizedStatus || String(status || '')} ${String(stage || '')}`.trim()
|
||||
if (/已删除|已驳回|驳回|拒绝|失败/.test(text)) {
|
||||
return 'is-danger'
|
||||
}
|
||||
if (/已审批|审批通过|已完成|已归档|已付款|已支付|可通过/.test(text)) {
|
||||
return 'is-success'
|
||||
}
|
||||
if (/草稿|待提交|待补充|已退回|退回/.test(text)) {
|
||||
return 'is-warning'
|
||||
}
|
||||
return 'is-pending'
|
||||
}
|
||||
|
||||
function isDocumentRecordTable(normalizedHeader = []) {
|
||||
return (
|
||||
normalizedHeader.includes('单据编号') &&
|
||||
normalizedHeader.includes('操作') &&
|
||||
normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
function renderDocumentCardField(label = '', value = '', options = {}, context = {}) {
|
||||
if (!hasMeaningfulTableValue(value)) {
|
||||
return ''
|
||||
}
|
||||
const renderInlineHtml = context.renderInlineHtml || ((item) => String(item || ''))
|
||||
const valueClass = options.valueClass ? ` ${options.valueClass}` : ''
|
||||
return [
|
||||
`<div class="ai-document-card__field${options.fieldClass ? ` ${options.fieldClass}` : ''}">`,
|
||||
`<span class="ai-document-card__label">${context.escapeHtml(label)}</span>`,
|
||||
`<strong class="ai-document-card__value${valueClass}">${renderInlineHtml(value)}</strong>`,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderDocumentCardAction(action = '', context = {}) {
|
||||
if (!hasMeaningfulTableValue(action)) {
|
||||
return ''
|
||||
}
|
||||
const renderInlineHtml = context.renderInlineHtml || ((item) => String(item || ''))
|
||||
const actionHtml = renderInlineHtml(action).replace(
|
||||
/class="ai-html-action-link\s+/g,
|
||||
'class="ai-html-action-link ai-document-card__action '
|
||||
)
|
||||
return [
|
||||
'<div class="ai-document-card__field ai-document-card__field--action">',
|
||||
'<span class="ai-document-card__label">操作</span>',
|
||||
actionHtml,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderDocumentRecordList(header = [], bodyRows = [], context = {}) {
|
||||
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||
const items = bodyRows.map((row) => {
|
||||
const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
|
||||
const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
|
||||
const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间', '日期', '时间'])
|
||||
const location = resolveTableCell(row, normalizedHeader, ['地点', '目的地'])
|
||||
const amount = resolveTableCell(row, normalizedHeader, ['金额', '预计金额', '报销金额'])
|
||||
const status = normalizeDocumentStatusLabel(resolveTableCell(row, normalizedHeader, ['单据状态', '状态']))
|
||||
const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
|
||||
const reason = resolveTableCell(row, normalizedHeader, ['事由'])
|
||||
const action = resolveTableCell(row, normalizedHeader, ['操作'])
|
||||
const tone = resolveDocumentRecordTone(status, stage)
|
||||
const title = documentType || reason || documentNo || '单据详情'
|
||||
const summarySecondField = amount
|
||||
? renderDocumentCardField('金额', amount, { valueClass: 'ai-document-card__amount' }, context)
|
||||
: renderDocumentCardField('当前节点', stage || status || '待确认', {}, context)
|
||||
const summaryHtml = [
|
||||
renderDocumentCardField('日期', applyTime || '待补充', {}, context),
|
||||
summarySecondField
|
||||
].join('')
|
||||
const detailsHtml = [
|
||||
renderDocumentCardField('地点', location || '待补充', {}, context),
|
||||
renderDocumentCardField('单据编号', documentNo, { valueClass: 'ai-document-card__number' }, context),
|
||||
renderDocumentCardField('事由', reason || '待补充', {}, context),
|
||||
amount ? renderDocumentCardField('当前节点', stage || status || '待确认', {}, context) : '',
|
||||
renderDocumentCardAction(action, context),
|
||||
renderDocumentCardField('单据类型', documentType, {}, context)
|
||||
].join('')
|
||||
return [
|
||||
`<article class="ai-document-card ${tone}" role="listitem" aria-label="单据详情">`,
|
||||
'<header class="ai-document-card__head">',
|
||||
`<strong class="ai-document-card__reason">${context.renderInlineHtml(title)}</strong>`,
|
||||
hasMeaningfulTableValue(status) ? `<span class="ai-document-card__status">${context.renderInlineHtml(status)}</span>` : '',
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
summaryHtml ? `<div class="ai-document-card__summary">${summaryHtml}</div>` : '',
|
||||
'<div class="ai-document-card__details">',
|
||||
detailsHtml,
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</article>'
|
||||
].join('')
|
||||
}).filter(Boolean)
|
||||
|
||||
return [
|
||||
'<section class="ai-document-card-list" role="list" aria-label="单据结果">',
|
||||
...items,
|
||||
'</section>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
export function parseTableRow(line = '') {
|
||||
const trimmed = String(line || '').trim()
|
||||
if (!trimmed.startsWith('|')) {
|
||||
return []
|
||||
}
|
||||
return trimmed
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
export function renderTable(lines = [], options = {}) {
|
||||
const context = {
|
||||
escapeHtml: options.escapeHtml || ((item) => String(item || '')),
|
||||
renderInlineHtml: options.renderInlineHtml || ((item) => String(item || ''))
|
||||
}
|
||||
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
|
||||
if (rows.length < 2) {
|
||||
return ''
|
||||
}
|
||||
const header = rows[0]
|
||||
const bodyRows = rows.slice(2)
|
||||
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||
if (isDocumentRecordTable(normalizedHeader)) {
|
||||
return renderDocumentRecordList(header, bodyRows, context)
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="ai-html-table-wrap">',
|
||||
'<table>',
|
||||
'<thead><tr>',
|
||||
...header.map((cell) => `<th>${context.renderInlineHtml(cell)}</th>`),
|
||||
'</tr></thead>',
|
||||
'<tbody>',
|
||||
...bodyRows.map((row) => [
|
||||
'<tr>',
|
||||
...header.map((_cell, index) => `<td>${context.renderInlineHtml(row[index] || '')}</td>`),
|
||||
'</tr>'
|
||||
].join('')),
|
||||
'</tbody>',
|
||||
'</table>',
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export function buildAiDocumentDetailRequest(detailReference = {}) {
|
||||
documentType: isApplication ? 'application' : 'reimbursement',
|
||||
documentTypeCode: isApplication ? 'application' : 'reimbursement',
|
||||
detailLookupOnly: true,
|
||||
source: 'workbench',
|
||||
returnTo: 'workbench'
|
||||
source: 'ai-conversation',
|
||||
returnTo: 'conversation'
|
||||
}
|
||||
}
|
||||
|
||||
240
web/src/utils/aiDocumentQueryIntent.js
Normal file
240
web/src/utils/aiDocumentQueryIntent.js
Normal file
@@ -0,0 +1,240 @@
|
||||
import { compactText, formatDate, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ },
|
||||
{ label: '审批中', keys: ['submitted', 'pending'], pattern: /审批中|审核中|待审批|待审核|待审|已提交/ },
|
||||
{ label: '已审批', keys: ['approved'], pattern: /已审批|审批通过|已通过|已批准/ },
|
||||
{ label: '已完成', keys: ['completed'], pattern: /已完成|完成/ },
|
||||
{ label: '已归档', keys: ['archived'], pattern: /已归档|归档/ },
|
||||
{ label: '已退回', keys: ['returned'], pattern: /已退回|退回|待补充/ },
|
||||
{ label: '已驳回', keys: ['rejected'], pattern: /已驳回|驳回|拒绝/ },
|
||||
{ label: '待付款', keys: ['pending_payment'], pattern: /待付款|待支付/ },
|
||||
{ label: '已付款', keys: ['paid'], pattern: /已付款|已支付/ }
|
||||
]
|
||||
|
||||
const EXPENSE_TYPE_FILTERS = [
|
||||
{ label: '差旅费', codes: ['travel', 'travel_application'], pattern: /差旅|出差|差旅费|差旅费用/ },
|
||||
{ label: '交通费', codes: ['transport'], pattern: /交通|火车|机票|打车|出租|网约车/ },
|
||||
{ label: '住宿费', codes: ['hotel'], pattern: /住宿|酒店|宾馆/ },
|
||||
{ label: '业务招待费', codes: ['meal', 'entertainment'], pattern: /招待|餐饮|宴请|客户餐/ },
|
||||
{ label: '办公用品费', codes: ['office'], pattern: /办公|办公用品|采购/ },
|
||||
{ label: '会务费', codes: ['meeting'], pattern: /会务|会议/ },
|
||||
{ label: '培训费', codes: ['training'], pattern: /培训/ },
|
||||
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
|
||||
]
|
||||
|
||||
function resolveToday(options = {}) {
|
||||
return parseDate(options.today) || new Date()
|
||||
}
|
||||
|
||||
function lastDayOfMonth(year, month) {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
function buildMonthRange(year, month) {
|
||||
const normalizedMonth = String(month).padStart(2, '0')
|
||||
return {
|
||||
start: `${year}-${normalizedMonth}-01`,
|
||||
end: `${year}-${normalizedMonth}-${String(lastDayOfMonth(year, month)).padStart(2, '0')}`,
|
||||
label: `${year}年${month}月`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTimeRange(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
const today = resolveToday(options)
|
||||
const todayText = formatDate(today)
|
||||
|
||||
const explicitMonth = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?!\d{1,2})/)
|
||||
if (explicitMonth?.groups) {
|
||||
const year = Number(explicitMonth.groups.year || today.getUTCFullYear())
|
||||
const month = Number(explicitMonth.groups.month)
|
||||
if (month >= 1 && month <= 12) {
|
||||
return buildMonthRange(year, month)
|
||||
}
|
||||
}
|
||||
|
||||
const explicitRange = text.match(/(?:(?<year>20\d{2})年?)?(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?(?:至|到|~|-|—|–)(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日?/)
|
||||
if (explicitRange?.groups) {
|
||||
const year = Number(explicitRange.groups.year || today.getUTCFullYear())
|
||||
const startMonth = Number(explicitRange.groups.startMonth)
|
||||
const endMonth = Number(explicitRange.groups.endMonth || startMonth)
|
||||
const start = `${year}-${String(startMonth).padStart(2, '0')}-${String(explicitRange.groups.startDay).padStart(2, '0')}`
|
||||
const end = `${year}-${String(endMonth).padStart(2, '0')}-${String(explicitRange.groups.endDay).padStart(2, '0')}`
|
||||
return { start, end, label: `${start} 至 ${end}` }
|
||||
}
|
||||
|
||||
const explicitDay = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?<day>\d{1,2})日?/)
|
||||
if (explicitDay?.groups) {
|
||||
const year = Number(explicitDay.groups.year || today.getUTCFullYear())
|
||||
const value = `${year}-${String(explicitDay.groups.month).padStart(2, '0')}-${String(explicitDay.groups.day).padStart(2, '0')}`
|
||||
return { start: value, end: value, label: value }
|
||||
}
|
||||
|
||||
if (/今天|今日/.test(text)) {
|
||||
return { start: todayText, end: todayText, label: '今天' }
|
||||
}
|
||||
|
||||
if (/昨天/.test(text)) {
|
||||
const date = new Date(today.getTime())
|
||||
date.setUTCDate(date.getUTCDate() - 1)
|
||||
const value = formatDate(date)
|
||||
return { start: value, end: value, label: '昨天' }
|
||||
}
|
||||
|
||||
if (/本月|这个月|当月/.test(text)) {
|
||||
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (/上月|上个月/.test(text)) {
|
||||
const date = new Date(today.getTime())
|
||||
date.setUTCMonth(date.getUTCMonth() - 1)
|
||||
return buildMonthRange(date.getUTCFullYear(), date.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (/今年|本年/.test(text)) {
|
||||
const year = today.getUTCFullYear()
|
||||
return { start: `${year}-01-01`, end: `${year}-12-31`, label: `${year}年` }
|
||||
}
|
||||
|
||||
const recent = text.match(/近(?<days>\d{1,3})天/)
|
||||
if (recent?.groups?.days) {
|
||||
const days = Math.max(1, Number(recent.groups.days))
|
||||
const start = new Date(today.getTime())
|
||||
start.setUTCDate(start.getUTCDate() - days + 1)
|
||||
return { start: formatDate(start), end: todayText, label: `近${days}天` }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDocumentType(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/申请单|申请类单据|申请类/.test(text)) {
|
||||
return 'application'
|
||||
}
|
||||
if (/报销单|报销类单据|报销类/.test(text)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function resolveStatusFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return STATUS_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function resolveExpenseTypeFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function normalizeAmountText(value = '') {
|
||||
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
||||
if (!matched) {
|
||||
return null
|
||||
}
|
||||
const amount = Number(matched[0])
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
function resolveAmountFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
const range = text.match(/金额(?:在|为)?(?<min>\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|–)(?<max>\d+(?:\.\d+)?)(?:元)?/)
|
||||
if (range?.groups) {
|
||||
const min = normalizeAmountText(range.groups.min)
|
||||
const max = normalizeAmountText(range.groups.max)
|
||||
if (min !== null && max !== null) {
|
||||
return {
|
||||
min: Math.min(min, max),
|
||||
max: Math.max(min, max),
|
||||
label: `${Math.min(min, max)}-${Math.max(min, max)}元`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minMatch = text.match(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|
||||
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以上/)
|
||||
if (minMatch?.groups?.amount) {
|
||||
const min = normalizeAmountText(minMatch.groups.amount)
|
||||
return min === null ? null : { min, max: null, label: `不少于${min}元` }
|
||||
}
|
||||
|
||||
const maxMatch = text.match(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|
||||
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以下/)
|
||||
if (maxMatch?.groups?.amount) {
|
||||
const max = normalizeAmountText(maxMatch.groups.amount)
|
||||
return max === null ? null : { min: null, max, label: `不超过${max}元` }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeKeywordCandidate(value = '') {
|
||||
return normalizeText(value)
|
||||
.replace(/^(的|是|为|包含|含有)+/u, '')
|
||||
.replace(/(相关的?|有关的?|单据|单子|申请单|报销单|审核单|审批单|有哪些|有吗|查询|查看)$/u, '')
|
||||
.replace(/的$/u, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function resolveKeywordFilter(prompt) {
|
||||
const text = normalizeText(prompt)
|
||||
const compact = compactText(prompt)
|
||||
const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[::\s]*(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
|
||||
const relatedMatch = compact.match(/(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})相关(?:的)?(?:单据|单子|申请单|报销单|审核单|审批单)/u)
|
||||
const keyword = normalizeKeywordCandidate(explicitMatch?.groups?.keyword || relatedMatch?.groups?.keyword || '')
|
||||
if (!keyword || /^(现在|当前|哪些|审核|审批|申请|报销|单据|单子)$/u.test(keyword)) {
|
||||
return null
|
||||
}
|
||||
return { keyword, label: keyword }
|
||||
}
|
||||
|
||||
function resolveSource(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
|
||||
return {
|
||||
source: 'approval',
|
||||
sourceLabel: '待我审核的单据'
|
||||
}
|
||||
}
|
||||
if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
|
||||
return {
|
||||
source: 'mine',
|
||||
sourceLabel: '我的单据'
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: 'accessible',
|
||||
sourceLabel: '我可见的单据'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
|
||||
return null
|
||||
}
|
||||
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
||||
return null
|
||||
}
|
||||
const source = resolveSource(text)
|
||||
const documentType = resolveDocumentType(text)
|
||||
const statusFilter = resolveStatusFilter(text)
|
||||
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
||||
const keywordFilter = resolveKeywordFilter(prompt)
|
||||
const amountFilter = resolveAmountFilter(text)
|
||||
return {
|
||||
...source,
|
||||
documentType,
|
||||
documentTypeLabel: documentType === 'application'
|
||||
? '申请单'
|
||||
: documentType === 'reimbursement'
|
||||
? '报销单'
|
||||
: '全部单据',
|
||||
timeRange: resolveTimeRange(text, options),
|
||||
statusFilter,
|
||||
expenseTypeFilter,
|
||||
keywordFilter,
|
||||
amountFilter
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { extractExpenseClaimItems } from '../services/reimbursements.js'
|
||||
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
|
||||
import { isApplicationDocumentNo } from './documentClassification.js'
|
||||
import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
||||
|
||||
export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
|
||||
|
||||
const DOCUMENT_QUERY_LIMIT = 8
|
||||
|
||||
@@ -33,29 +36,6 @@ const TYPE_LABELS = {
|
||||
other: '其他费用'
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ },
|
||||
{ label: '审批中', keys: ['submitted', 'pending'], pattern: /审批中|审核中|待审批|待审核|待审|已提交/ },
|
||||
{ label: '已审批', keys: ['approved'], pattern: /已审批|审批通过|已通过|已批准/ },
|
||||
{ label: '已完成', keys: ['completed'], pattern: /已完成|完成/ },
|
||||
{ label: '已归档', keys: ['archived'], pattern: /已归档|归档/ },
|
||||
{ label: '已退回', keys: ['returned'], pattern: /已退回|退回|待补充/ },
|
||||
{ label: '已驳回', keys: ['rejected'], pattern: /已驳回|驳回|拒绝/ },
|
||||
{ label: '待付款', keys: ['pending_payment'], pattern: /待付款|待支付/ },
|
||||
{ label: '已付款', keys: ['paid'], pattern: /已付款|已支付/ }
|
||||
]
|
||||
|
||||
const EXPENSE_TYPE_FILTERS = [
|
||||
{ label: '差旅费', codes: ['travel', 'travel_application'], pattern: /差旅|出差|差旅费|差旅费用/ },
|
||||
{ label: '交通费', codes: ['transport'], pattern: /交通|火车|机票|打车|出租|网约车/ },
|
||||
{ label: '住宿费', codes: ['hotel'], pattern: /住宿|酒店|宾馆/ },
|
||||
{ label: '业务招待费', codes: ['meal', 'entertainment'], pattern: /招待|餐饮|宴请|客户餐/ },
|
||||
{ label: '办公用品费', codes: ['office'], pattern: /办公|办公用品|采购/ },
|
||||
{ label: '会务费', codes: ['meeting'], pattern: /会务|会议/ },
|
||||
{ label: '培训费', codes: ['training'], pattern: /培训/ },
|
||||
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
|
||||
]
|
||||
|
||||
const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
@@ -63,10 +43,6 @@ const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function resolveStatusDisplayLabel(value = '') {
|
||||
const text = normalizeText(value)
|
||||
if (!text) {
|
||||
@@ -84,252 +60,6 @@ function escapeHtml(value = '') {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return normalizeText(value).replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function normalizeDateText(value) {
|
||||
const text = normalizeText(value)
|
||||
const matched = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
|
||||
if (!matched) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
matched[1],
|
||||
String(matched[2]).padStart(2, '0'),
|
||||
String(matched[3]).padStart(2, '0')
|
||||
].join('-')
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
const text = normalizeDateText(value)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(`${text}T00:00:00Z`)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function resolveToday(options = {}) {
|
||||
return parseDate(options.today) || new Date()
|
||||
}
|
||||
|
||||
function lastDayOfMonth(year, month) {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
function buildMonthRange(year, month) {
|
||||
const normalizedMonth = String(month).padStart(2, '0')
|
||||
return {
|
||||
start: `${year}-${normalizedMonth}-01`,
|
||||
end: `${year}-${normalizedMonth}-${String(lastDayOfMonth(year, month)).padStart(2, '0')}`,
|
||||
label: `${year}年${month}月`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTimeRange(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
const today = resolveToday(options)
|
||||
const todayText = formatDate(today)
|
||||
|
||||
const explicitMonth = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?!\d{1,2})/)
|
||||
if (explicitMonth?.groups) {
|
||||
const year = Number(explicitMonth.groups.year || today.getUTCFullYear())
|
||||
const month = Number(explicitMonth.groups.month)
|
||||
if (month >= 1 && month <= 12) {
|
||||
return buildMonthRange(year, month)
|
||||
}
|
||||
}
|
||||
|
||||
const explicitRange = text.match(/(?:(?<year>20\d{2})年?)?(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?(?:至|到|~|-|—|–)(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日?/)
|
||||
if (explicitRange?.groups) {
|
||||
const year = Number(explicitRange.groups.year || today.getUTCFullYear())
|
||||
const startMonth = Number(explicitRange.groups.startMonth)
|
||||
const endMonth = Number(explicitRange.groups.endMonth || startMonth)
|
||||
const start = `${year}-${String(startMonth).padStart(2, '0')}-${String(explicitRange.groups.startDay).padStart(2, '0')}`
|
||||
const end = `${year}-${String(endMonth).padStart(2, '0')}-${String(explicitRange.groups.endDay).padStart(2, '0')}`
|
||||
return { start, end, label: `${start} 至 ${end}` }
|
||||
}
|
||||
|
||||
const explicitDay = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?<day>\d{1,2})日?/)
|
||||
if (explicitDay?.groups) {
|
||||
const year = Number(explicitDay.groups.year || today.getUTCFullYear())
|
||||
const value = `${year}-${String(explicitDay.groups.month).padStart(2, '0')}-${String(explicitDay.groups.day).padStart(2, '0')}`
|
||||
return { start: value, end: value, label: value }
|
||||
}
|
||||
|
||||
if (/今天|今日/.test(text)) {
|
||||
return { start: todayText, end: todayText, label: '今天' }
|
||||
}
|
||||
|
||||
if (/昨天/.test(text)) {
|
||||
const date = new Date(today.getTime())
|
||||
date.setUTCDate(date.getUTCDate() - 1)
|
||||
const value = formatDate(date)
|
||||
return { start: value, end: value, label: '昨天' }
|
||||
}
|
||||
|
||||
if (/本月|这个月|当月/.test(text)) {
|
||||
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (/上月|上个月/.test(text)) {
|
||||
const date = new Date(today.getTime())
|
||||
date.setUTCMonth(date.getUTCMonth() - 1)
|
||||
return buildMonthRange(date.getUTCFullYear(), date.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (/今年|本年/.test(text)) {
|
||||
const year = today.getUTCFullYear()
|
||||
return { start: `${year}-01-01`, end: `${year}-12-31`, label: `${year}年` }
|
||||
}
|
||||
|
||||
const recent = text.match(/近(?<days>\d{1,3})天/)
|
||||
if (recent?.groups?.days) {
|
||||
const days = Math.max(1, Number(recent.groups.days))
|
||||
const start = new Date(today.getTime())
|
||||
start.setUTCDate(start.getUTCDate() - days + 1)
|
||||
return { start: formatDate(start), end: todayText, label: `近${days}天` }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDocumentType(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/申请单|申请类单据|申请类/.test(text)) {
|
||||
return 'application'
|
||||
}
|
||||
if (/报销单|报销类单据|报销类/.test(text)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function resolveStatusFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return STATUS_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function resolveExpenseTypeFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function normalizeAmountText(value = '') {
|
||||
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
||||
if (!matched) {
|
||||
return null
|
||||
}
|
||||
const amount = Number(matched[0])
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
function resolveAmountFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
const range = text.match(/金额(?:在|为)?(?<min>\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|–)(?<max>\d+(?:\.\d+)?)(?:元)?/)
|
||||
if (range?.groups) {
|
||||
const min = normalizeAmountText(range.groups.min)
|
||||
const max = normalizeAmountText(range.groups.max)
|
||||
if (min !== null && max !== null) {
|
||||
return {
|
||||
min: Math.min(min, max),
|
||||
max: Math.max(min, max),
|
||||
label: `${Math.min(min, max)}-${Math.max(min, max)}元`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minMatch = text.match(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|
||||
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以上/)
|
||||
if (minMatch?.groups?.amount) {
|
||||
const min = normalizeAmountText(minMatch.groups.amount)
|
||||
return min === null ? null : { min, max: null, label: `不少于${min}元` }
|
||||
}
|
||||
|
||||
const maxMatch = text.match(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|
||||
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以下/)
|
||||
if (maxMatch?.groups?.amount) {
|
||||
const max = normalizeAmountText(maxMatch.groups.amount)
|
||||
return max === null ? null : { min: null, max, label: `不超过${max}元` }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeKeywordCandidate(value = '') {
|
||||
return normalizeText(value)
|
||||
.replace(/^(的|是|为|包含|含有)+/u, '')
|
||||
.replace(/(相关的?|有关的?|单据|单子|申请单|报销单|审核单|审批单|有哪些|有吗|查询|查看)$/u, '')
|
||||
.replace(/的$/u, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function resolveKeywordFilter(prompt) {
|
||||
const text = normalizeText(prompt)
|
||||
const compact = compactText(prompt)
|
||||
const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[::\s]*(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
|
||||
const relatedMatch = compact.match(/(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})相关(?:的)?(?:单据|单子|申请单|报销单|审核单|审批单)/u)
|
||||
const keyword = normalizeKeywordCandidate(explicitMatch?.groups?.keyword || relatedMatch?.groups?.keyword || '')
|
||||
if (!keyword || /^(现在|当前|哪些|审核|审批|申请|报销|单据|单子)$/u.test(keyword)) {
|
||||
return null
|
||||
}
|
||||
return { keyword, label: keyword }
|
||||
}
|
||||
|
||||
function resolveSource(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
|
||||
return {
|
||||
source: 'approval',
|
||||
sourceLabel: '待我审核的单据'
|
||||
}
|
||||
}
|
||||
if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
|
||||
return {
|
||||
source: 'mine',
|
||||
sourceLabel: '我的单据'
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: 'accessible',
|
||||
sourceLabel: '我可见的单据'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
|
||||
return null
|
||||
}
|
||||
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
||||
return null
|
||||
}
|
||||
const source = resolveSource(text)
|
||||
const documentType = resolveDocumentType(text)
|
||||
const statusFilter = resolveStatusFilter(text)
|
||||
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
||||
const keywordFilter = resolveKeywordFilter(prompt)
|
||||
const amountFilter = resolveAmountFilter(text)
|
||||
return {
|
||||
...source,
|
||||
documentType,
|
||||
documentTypeLabel: documentType === 'application'
|
||||
? '申请单'
|
||||
: documentType === 'reimbursement'
|
||||
? '报销单'
|
||||
: '全部单据',
|
||||
timeRange: resolveTimeRange(text, options),
|
||||
statusFilter,
|
||||
expenseTypeFilter,
|
||||
keywordFilter,
|
||||
amountFilter
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDocumentNo(claim = {}) {
|
||||
return normalizeText(claim.claim_no || claim.claimNo || claim.documentNo || claim.id || claim.claim_id)
|
||||
}
|
||||
|
||||
33
web/src/utils/aiDocumentQueryText.js
Normal file
33
web/src/utils/aiDocumentQueryText.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export function normalizeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
export function compactText(value) {
|
||||
return normalizeText(value).replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
export function normalizeDateText(value) {
|
||||
const text = normalizeText(value)
|
||||
const matched = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
|
||||
if (!matched) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
matched[1],
|
||||
String(matched[2]).padStart(2, '0'),
|
||||
String(matched[3]).padStart(2, '0')
|
||||
].join('-')
|
||||
}
|
||||
|
||||
export function parseDate(value) {
|
||||
const text = normalizeDateText(value)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(`${text}T00:00:00Z`)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
361
web/src/utils/documentCenterViewModel.js
Normal file
361
web/src/utils/documentCenterViewModel.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import { countClaimRisks, resolveArchiveRiskTone } from './archiveCenterListFilters.js'
|
||||
import { isNewDocument } from './documentCenterNewState.js'
|
||||
import { isArchivedDocumentRow } from './documentCenterRows.js'
|
||||
import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
|
||||
import {
|
||||
extractDateText,
|
||||
formatDocumentListTime,
|
||||
resolveDocumentSortTime,
|
||||
resolveDocumentStayTimeDisplay
|
||||
} from './documentCenterTime.js'
|
||||
import { normalizeRequestForUi } from './requestViewModel.js'
|
||||
|
||||
export const DOCUMENT_TYPE_ALL = 'all'
|
||||
export const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
export const SCENE_ALL = 'all'
|
||||
export const DOCUMENT_SCOPE_ALL = '全部'
|
||||
export const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
||||
export const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
||||
export const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||
export const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||
export const scopeTabs = [
|
||||
DOCUMENT_SCOPE_ALL,
|
||||
DOCUMENT_SCOPE_APPLICATION,
|
||||
DOCUMENT_SCOPE_REIMBURSEMENT,
|
||||
DOCUMENT_SCOPE_REVIEW,
|
||||
DOCUMENT_SCOPE_ARCHIVE
|
||||
]
|
||||
export const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
|
||||
export const DOCUMENT_CENTER_QUERY_KEYS = new Set([
|
||||
'dc_page',
|
||||
'dc_page_size',
|
||||
'dc_scope',
|
||||
'dc_status',
|
||||
'dc_doc_type',
|
||||
'dc_scene',
|
||||
'dc_q',
|
||||
'dc_start',
|
||||
'dc_end'
|
||||
])
|
||||
export const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
|
||||
export const RISK_TONE_META = {
|
||||
high: { label: '高风险', tone: 'high' },
|
||||
medium: { label: '中风险', tone: 'medium' },
|
||||
low: { label: '低风险', tone: 'low' },
|
||||
none: { label: '无风险', tone: 'none' }
|
||||
}
|
||||
export const FILTER_CONFIG_BY_SCOPE = {
|
||||
[DOCUMENT_SCOPE_ALL]: {
|
||||
searchPlaceholder: '搜索单号、事项、费用场景...',
|
||||
sceneFallbackLabel: '单据场景',
|
||||
dateLabel: '单据时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: true
|
||||
},
|
||||
[DOCUMENT_SCOPE_APPLICATION]: {
|
||||
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
|
||||
sceneFallbackLabel: '申请场景',
|
||||
dateLabel: '申请时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_REIMBURSEMENT]: {
|
||||
searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
|
||||
sceneFallbackLabel: '费用场景',
|
||||
dateLabel: '报销时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_REVIEW]: {
|
||||
searchPlaceholder: '搜索审核单号、事项、当前环节...',
|
||||
sceneFallbackLabel: '审核场景',
|
||||
dateLabel: '审核时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_ARCHIVE]: {
|
||||
searchPlaceholder: '搜索归档单号、事项、费用场景...',
|
||||
sceneFallbackLabel: '归档场景',
|
||||
dateLabel: '归档时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
}
|
||||
}
|
||||
export const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
export const pageSizeValues = pageSizeOptions.map((item) => item.value)
|
||||
export const documentTypeOptions = [
|
||||
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
|
||||
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
|
||||
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
|
||||
]
|
||||
|
||||
export function routeQueryEquals(left, right) {
|
||||
const leftEntries = Object.entries(left || {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.join(',') : String(value ?? '')
|
||||
])
|
||||
const rightEntries = Object.entries(right || {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.join(',') : String(value ?? '')
|
||||
])
|
||||
if (leftEntries.length !== rightEntries.length) return false
|
||||
const rightMap = new Map(rightEntries)
|
||||
return leftEntries.every(([key, value]) => rightMap.get(key) === value)
|
||||
}
|
||||
|
||||
export function buildDocumentRow(request, options = {}) {
|
||||
const normalized = normalizeRequestForUi(request)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
const archived = Boolean(options.archived)
|
||||
const source = options.source || 'owned'
|
||||
const statusGroup = resolveStatusGroup(normalized, archived)
|
||||
const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
|
||||
const riskMeta = buildDocumentRiskMeta(normalized, options.currentUser)
|
||||
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
|
||||
const claimId = normalized.claimId || normalized.id || documentNo
|
||||
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
|
||||
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
|
||||
const createdSortTime = resolveDocumentSortTime(createdAtSource)
|
||||
const updatedSortTime = resolveDocumentSortTime(updatedAtSource)
|
||||
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
||||
const documentTypeLabel =
|
||||
normalized.documentTypeLabel
|
||||
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
||||
const initiatorName = String(
|
||||
normalized.person
|
||||
|| normalized.employeeName
|
||||
|| normalized.profileName
|
||||
|| normalized.applicant
|
||||
|| request?.employee_name
|
||||
|| request?.employeeName
|
||||
|| request?.person
|
||||
|| ''
|
||||
).trim() || '待补充'
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
rawRequest: request,
|
||||
documentKey: `${source}:${claimId || documentNo}`,
|
||||
documentTypeCode,
|
||||
documentTypeLabel,
|
||||
claimId,
|
||||
documentNo,
|
||||
initiatorName,
|
||||
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
|
||||
statusGroup,
|
||||
statusLabel,
|
||||
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
|
||||
riskTone: riskMeta.tone,
|
||||
riskLabel: riskMeta.label,
|
||||
riskCount: riskMeta.count,
|
||||
riskTags: riskMeta.tags,
|
||||
source,
|
||||
archived,
|
||||
createdAtDisplay: formatDocumentListTime(createdAtSource),
|
||||
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
|
||||
isNewDocument: archived
|
||||
? false
|
||||
: isNewDocument({ ...normalized, source, claimId, documentNo }, options.viewedDocumentKeys || []),
|
||||
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
|
||||
createdSortTime,
|
||||
updatedSortTime,
|
||||
sortTime: Math.max(createdSortTime, updatedSortTime)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDocumentRiskMeta(row, currentUser = null) {
|
||||
const riskFlags = resolveDocumentRiskFlags(row)
|
||||
const riskSummary = row?.riskSummary || row?.risk
|
||||
// 列表风险标签按当前查看者可见性过滤,与详情页口径一致。
|
||||
const viewerOptions = currentUser ? { request: row || {}, currentUser } : null
|
||||
const count = countClaimRisks(riskFlags, riskSummary, viewerOptions)
|
||||
if (!count) {
|
||||
const meta = RISK_TONE_META.none
|
||||
return {
|
||||
...meta,
|
||||
count: 0,
|
||||
tags: [{ ...meta }]
|
||||
}
|
||||
}
|
||||
|
||||
const tone = resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions)
|
||||
const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium
|
||||
return {
|
||||
...meta,
|
||||
count,
|
||||
tags: [{ tone: meta.tone, label: `${meta.label} ${count}项` }]
|
||||
}
|
||||
}
|
||||
|
||||
export function filterDocumentRows(rows, filters = {}) {
|
||||
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||
return sortDocumentRowsByLatestTime((rows || []).filter((row) => {
|
||||
const matchesKeyword = !keyword || [
|
||||
row.documentNo,
|
||||
row.documentTypeLabel,
|
||||
row.typeLabel,
|
||||
row.initiatorName,
|
||||
row.reason,
|
||||
row.node,
|
||||
row.statusLabel,
|
||||
row.riskLabel
|
||||
].filter(Boolean).join('').toLowerCase().includes(keyword)
|
||||
|
||||
const matchesDocumentType =
|
||||
!filters.showDocumentTypeFilter
|
||||
|| filters.activeDocumentType === DOCUMENT_TYPE_ALL
|
||||
|| row.documentTypeCode === filters.activeDocumentType
|
||||
|
||||
const matchesScene = filters.activeScene === SCENE_ALL || row.typeCode === filters.activeScene
|
||||
const matchesRiskLevel = matchesRiskLevelTab(row, filters.activeStatusTab, filters.activeScopeTab)
|
||||
const matchesDateRange = matchesAppliedDateRange(row, filters.appliedStart, filters.appliedEnd)
|
||||
|
||||
return matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange
|
||||
}))
|
||||
}
|
||||
|
||||
export function matchesRiskLevelTab(row, tab, activeScopeTab = DOCUMENT_SCOPE_ALL) {
|
||||
if (activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (tab === '全部') return true
|
||||
if (tab === '高风险') return row.riskTone === 'high'
|
||||
if (tab === '中风险') return row.riskTone === 'medium'
|
||||
if (tab === '低风险') return row.riskTone === 'low'
|
||||
if (tab === '无风险') return row.riskTone === 'none'
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchesAppliedDateRange(row, start, end) {
|
||||
if (!start || !end) {
|
||||
return true
|
||||
}
|
||||
|
||||
const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
|
||||
return Boolean(date) && date >= start && date <= end
|
||||
}
|
||||
|
||||
export function mergeDocumentRows(rows) {
|
||||
const rowMap = new Map()
|
||||
|
||||
rows.filter(Boolean).forEach((row) => {
|
||||
const key = row.claimId || row.documentNo || row.documentKey
|
||||
const current = rowMap.get(key)
|
||||
if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
|
||||
rowMap.set(key, row)
|
||||
}
|
||||
})
|
||||
|
||||
return sortDocumentRowsByLatestTime(Array.from(rowMap.values()))
|
||||
}
|
||||
|
||||
export function hasDocumentCenterActiveFilters(filters = {}) {
|
||||
return Boolean(
|
||||
String(filters.listKeyword || '').trim()
|
||||
|| filters.activeStatusTab !== '全部'
|
||||
|| (filters.showDocumentTypeFilter && filters.activeDocumentType !== DOCUMENT_TYPE_ALL)
|
||||
|| filters.activeScene !== SCENE_ALL
|
||||
|| filters.appliedStart
|
||||
|| filters.appliedEnd
|
||||
)
|
||||
}
|
||||
|
||||
export function buildDocumentCenterEmptyState(options = {}) {
|
||||
const filtered = Boolean(options.hasActiveFilters)
|
||||
const activeScopeTab = options.activeScopeTab || DOCUMENT_SCOPE_ALL
|
||||
if (
|
||||
activeScopeTab === DOCUMENT_SCOPE_APPLICATION
|
||||
|| options.activeDocumentType === DOCUMENT_TYPE_APPLICATION
|
||||
) {
|
||||
return {
|
||||
eyebrow: '申请单',
|
||||
title: '当前还没有申请单数据',
|
||||
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
|
||||
icon: 'mdi mdi-file-sign-outline',
|
||||
actionLabel: '',
|
||||
actionIcon: '',
|
||||
tone: 'theme',
|
||||
artLabel: 'APPLY',
|
||||
tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: filtered ? '筛选结果为空' : '单据中心',
|
||||
title: filtered ? '没有符合当前条件的单据' : `“${activeScopeTab}”里暂时没有单据`,
|
||||
desc: filtered
|
||||
? '可以清空当前分类下的筛选条件后再看看。'
|
||||
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
|
||||
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
|
||||
actionLabel: '',
|
||||
actionIcon: '',
|
||||
tone: 'theme',
|
||||
artLabel: filtered ? 'FILTER' : 'DOCS',
|
||||
tips: ['单据中心已接入当前报销单据', '归档视角会同步已归档数据']
|
||||
}
|
||||
}
|
||||
|
||||
function resolveArchivedDocumentNode(normalized, documentTypeCode) {
|
||||
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
|
||||
return '申请归档'
|
||||
}
|
||||
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款') {
|
||||
return '已付款'
|
||||
}
|
||||
return normalized.node || normalized.workflowNode || '财务归档'
|
||||
}
|
||||
|
||||
function resolveArchivedStatusLabel(normalized) {
|
||||
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款' || normalized.node === '已付款') {
|
||||
return '已付款'
|
||||
}
|
||||
return '已归档'
|
||||
}
|
||||
|
||||
function resolveStatusGroup(row, archived) {
|
||||
if (archived) return 'completed'
|
||||
if (row.approvalKey === 'draft') return 'draft'
|
||||
if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
|
||||
if (row.approvalKey === 'supplement') return 'supplement'
|
||||
if (row.approvalKey === 'pending_payment') return 'pending_payment'
|
||||
if (row.approvalKey === 'in_progress') return 'in_progress'
|
||||
if (row.approvalKey === 'completed') return 'completed'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function resolveStatusLabel(row, statusGroup) {
|
||||
if (statusGroup === 'pending_submit') return '待提交'
|
||||
if (statusGroup === 'pending_payment') return '待付款'
|
||||
return row.approval || row.approvalStatus || '处理中'
|
||||
}
|
||||
|
||||
function resolveStatusTone(row, statusGroup) {
|
||||
if (statusGroup === 'pending_submit') return 'warning'
|
||||
return row.approvalTone || 'neutral'
|
||||
}
|
||||
|
||||
function resolveDocumentRiskFlags(row) {
|
||||
if (Array.isArray(row?.riskFlags)) {
|
||||
return row.riskFlags
|
||||
}
|
||||
if (Array.isArray(row?.risk_flags_json)) {
|
||||
return row.risk_flags_json
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function resolveSourcePriority(row) {
|
||||
if (row.archived) return 3
|
||||
if (row.source === 'approval') return 2
|
||||
return 1
|
||||
}
|
||||
@@ -1,752 +1,56 @@
|
||||
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
|
||||
import { evaluateLocalApplicationIntentGate } from './expenseApplicationIntentGate.js'
|
||||
import {
|
||||
buildMockApplicationTransportEstimate,
|
||||
formatApplicationEstimateMoney,
|
||||
parseApplicationEstimateMoney,
|
||||
buildSystemApplicationEstimate
|
||||
} from './expenseApplicationEstimate.js'
|
||||
import { getTodayDateValue } from './workbenchComposerDate.js'
|
||||
|
||||
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||||
{ key: 'applicationType', label: '申请类型' },
|
||||
{ key: 'applicant', label: '姓名', editable: false, required: false },
|
||||
{ key: 'grade', label: '职级', highlight: true, editable: false, required: false },
|
||||
{ key: 'department', label: '部门', editable: false, required: false },
|
||||
{ key: 'position', label: '岗位', editable: false, required: false },
|
||||
{ key: 'managerName', label: '直属领导', editable: false, required: false },
|
||||
{ key: 'time', label: '申请时间' },
|
||||
{ key: 'location', label: '地点' },
|
||||
{ key: 'reason', label: '事由' },
|
||||
{ key: 'days', label: '天数' },
|
||||
{ key: 'transportMode', label: '出行方式' },
|
||||
{ key: 'lodgingDailyCap', label: '住宿上限/天', highlight: true, editable: false, required: false },
|
||||
{ key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
|
||||
{ key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
|
||||
{ key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
|
||||
{ key: 'amount', label: '系统预估费用', highlight: true, editable: false, required: false }
|
||||
]
|
||||
|
||||
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
||||
|
||||
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
||||
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
|
||||
|
||||
export function resolveApplicationTimeLabel(applicationType = '') {
|
||||
const label = String(applicationType || '').trim()
|
||||
if (/差旅|出差/.test(label)) return '出发时间'
|
||||
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
|
||||
return '申请时间'
|
||||
}
|
||||
|
||||
function resolveApplicationFieldLabel(item, fields = {}) {
|
||||
if (item.key === 'time') {
|
||||
return resolveApplicationTimeLabel(fields.applicationType)
|
||||
}
|
||||
return item.label
|
||||
}
|
||||
|
||||
function isTravelApplicationType(applicationType = '') {
|
||||
return /差旅|出差/.test(String(applicationType || '').trim())
|
||||
}
|
||||
|
||||
function resolveApplicationTripDateParts(fields = {}) {
|
||||
const timeText = String(fields.time || '').trim()
|
||||
const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || timeText)
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, fields.days)
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate: inferredEndDate || explicitEndDate || startDate
|
||||
}
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function looksLikeStructuredTravelApplication(text) {
|
||||
const source = String(text || '')
|
||||
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(source)
|
||||
&& /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(source)
|
||||
&& /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
|
||||
}
|
||||
|
||||
function resolveFirstMatch(text, patterns = []) {
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern)
|
||||
const value = String(match?.groups?.value || match?.[1] || '').trim()
|
||||
if (value) return value.replace(/[,。;;]$/, '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeDateText(value) {
|
||||
return String(value || '').replace(/[/.]/g, '-').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function parseIsoDate(value) {
|
||||
const match = normalizeDateText(value).match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
|
||||
if (!match) return null
|
||||
const [, year, month, day] = match
|
||||
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function buildEndDateFromDays(startText, daysText = '') {
|
||||
const days = parseApplicationDaysValue(daysText)
|
||||
const start = parseIsoDate(startText)
|
||||
if (!days || !start) return ''
|
||||
const end = new Date(start.getTime())
|
||||
end.setUTCDate(end.getUTCDate() + Math.max(days - 1, 0))
|
||||
return formatIsoDate(end)
|
||||
}
|
||||
|
||||
function buildDateFromMonthDay(year, month, day) {
|
||||
const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return parseIsoDate(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function resolveShortMonthDayRange(text, options = {}) {
|
||||
const match = String(text || '').match(
|
||||
/(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?\s*(?:至|到|~|—|–|--|-)\s*(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日/u
|
||||
)
|
||||
if (!match?.groups) return ''
|
||||
|
||||
const referenceYear = Number(resolvePreviewToday(options).slice(0, 4))
|
||||
const startMonth = Number(match.groups.startMonth)
|
||||
const startDay = Number(match.groups.startDay)
|
||||
const endMonth = Number(match.groups.endMonth || match.groups.startMonth)
|
||||
const endDay = Number(match.groups.endDay)
|
||||
const startDate = buildDateFromMonthDay(referenceYear, startMonth, startDay)
|
||||
const endYear = endMonth < startMonth ? referenceYear + 1 : referenceYear
|
||||
const endDate = buildDateFromMonthDay(endYear, endMonth, endDay)
|
||||
if (!startDate || !endDate) return ''
|
||||
return startDate === endDate ? startDate : `${startDate} 至 ${endDate}`
|
||||
}
|
||||
|
||||
function resolveDaysFromDateRange(rangeText) {
|
||||
const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
|
||||
if (!match) return ''
|
||||
const start = parseIsoDate(match[1])
|
||||
const end = parseIsoDate(match[2])
|
||||
if (!start || !end) return ''
|
||||
const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
|
||||
return diffDays >= 0 ? `${diffDays + 1}天` : ''
|
||||
}
|
||||
|
||||
export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
function resolveApplicationValidationIssues(fields = {}) {
|
||||
const issues = []
|
||||
const rangeDaysText = resolveDaysFromDateRange(fields.time)
|
||||
const rangeDays = parseApplicationDaysValue(rangeDaysText)
|
||||
const explicitDays = parseApplicationDaysValue(fields.days)
|
||||
if (rangeDays && explicitDays && rangeDays !== explicitDays) {
|
||||
issues.push({
|
||||
code: 'time_days_conflict',
|
||||
field: 'days',
|
||||
message: `申请时间 ${fields.time} 按自然日为 ${rangeDays} 天,但填写的是 ${explicitDays} 天。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function shouldTrustModelApplicationFields(preview = {}) {
|
||||
const status = String(preview?.modelReviewStatus || '').trim()
|
||||
const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
|
||||
return Boolean(preview?.modelRefined)
|
||||
|| status === 'completed'
|
||||
|| strategy === 'llm_primary'
|
||||
}
|
||||
|
||||
function resolveApplicationSourceValidationIssues(sourceText = '', fields = {}, preview = {}) {
|
||||
if (shouldTrustModelApplicationFields(preview)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const issues = []
|
||||
const locationCandidates = extractApplicationLocationCandidates(sourceText)
|
||||
if (locationCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'location_candidates_conflict',
|
||||
field: 'location',
|
||||
message: `当前输入里同时出现多个地点:${locationCandidates.join('、')}。请确认本次申请地点。`
|
||||
})
|
||||
}
|
||||
|
||||
const transportCandidates = extractApplicationTransportCandidates(sourceText)
|
||||
if (transportCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'transport_candidates_conflict',
|
||||
field: 'transportMode',
|
||||
message: `当前输入里同时出现多个出行方式:${transportCandidates.join('、')}。请确认本次申请使用哪一种。`
|
||||
})
|
||||
}
|
||||
|
||||
const amountCandidates = extractApplicationAmountCandidates(sourceText)
|
||||
if (amountCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'amount_candidates_conflict',
|
||||
field: 'amount',
|
||||
message: `当前输入里同时出现多个金额:${amountCandidates.join('、')}。请确认本次申请金额。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
export function shouldRequireApplicationModelReview(rawText = '') {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = compactText(text)
|
||||
if (!compact) return false
|
||||
|
||||
const hasDateRange = /(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(?:\d{1,2}月)?\d{1,2}日?)\s*(?:至|到|~|—|–|--|-)\s*(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|\d{1,2}月?\d{1,2}日?)/u.test(text)
|
||||
const hasTravelIntent = /差旅|出差|去|到|赴|前往/.test(compact)
|
||||
const hasTransport = /高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮/.test(compact)
|
||||
const hasBusinessPurpose = /服务|支撑|支持|辅助|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(compact)
|
||||
const hasInlinePurpose = hasBusinessPurpose && !/(?:事由|申请事由|出差事由|原因|用途)\s*[::]/.test(text)
|
||||
const hasMultipleClauses = /[,,。;;\n]/.test(text) || compact.length >= 24
|
||||
const signalCount = [hasDateRange, hasTravelIntent, hasTransport, hasBusinessPurpose].filter(Boolean).length
|
||||
|
||||
return (hasInlinePurpose && signalCount >= 2) || (hasMultipleClauses && signalCount >= 3)
|
||||
}
|
||||
|
||||
export function resolveApplicationDateRange(rangeText, daysText = '') {
|
||||
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || '')
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, daysText)
|
||||
const endDate = inferredEndDate || explicitEndDate || startDate
|
||||
const start = parseIsoDate(startDate)
|
||||
const end = parseIsoDate(endDate)
|
||||
if (!start || !end) {
|
||||
return null
|
||||
}
|
||||
const orderedStart = start.getTime() <= end.getTime() ? start : end
|
||||
const orderedEnd = start.getTime() <= end.getTime() ? end : start
|
||||
return {
|
||||
startDate: formatIsoDate(orderedStart),
|
||||
endDate: formatIsoDate(orderedEnd),
|
||||
startTime: orderedStart.getTime(),
|
||||
endTime: orderedEnd.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
export function applicationDateRangesOverlap(leftRange, rightRange) {
|
||||
if (!leftRange || !rightRange) {
|
||||
return false
|
||||
}
|
||||
return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
|
||||
}
|
||||
|
||||
function resolvePreviewToday(options = {}) {
|
||||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||||
if (options.now instanceof Date && !Number.isNaN(options.now.getTime())) {
|
||||
return getTodayDateValue(options.now)
|
||||
}
|
||||
return getTodayDateValue()
|
||||
}
|
||||
|
||||
function resolveApplicationType(text) {
|
||||
const compact = compactText(text)
|
||||
if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
|
||||
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
|
||||
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
|
||||
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
|
||||
if (/会务|会议|发布会|展会/.test(compact)) return '会务费用申请'
|
||||
if (/采购|办公用品|文具|设备|耗材/.test(compact)) return '采购费用申请'
|
||||
if (/培训|课程|学习/.test(compact)) return '培训费用申请'
|
||||
return '费用申请'
|
||||
}
|
||||
|
||||
function resolveApplicationAmount(text) {
|
||||
const compact = compactText(text)
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
|
||||
/(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
|
||||
])
|
||||
const normalized = normalizeApplicationAmountText(labeled)
|
||||
if (normalized) return normalized
|
||||
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeApplicationAmountText(value) {
|
||||
const text = String(value || '').replace(/[,,]/g, '').trim()
|
||||
const match = text.match(/(?<number>\d+(?:\.\d+)?)\s*(?<unit>万|千|k|K)?/u)
|
||||
if (!match?.groups) return ''
|
||||
let amount = Number(match.groups.number)
|
||||
if (!Number.isFinite(amount) || amount <= 0) return ''
|
||||
const unit = String(match.groups.unit || '').toLowerCase()
|
||||
if (unit === '万') amount *= 10000
|
||||
if (unit === '千' || unit === 'k') amount *= 1000
|
||||
return `${Number.isInteger(amount) ? amount : Number(amount.toFixed(2))}元`
|
||||
}
|
||||
|
||||
function extractApplicationLocationCandidates(text) {
|
||||
const candidates = []
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
if (labeled) candidates.push(normalizeLocationCandidate(labeled))
|
||||
|
||||
const compact = compactText(text)
|
||||
const patterns = [
|
||||
/(?:去|到|赴|前往)(?<value>[\u4e00-\u9fa5]{1,24})/gu,
|
||||
/(?<value>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)/gu
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
for (const match of compact.matchAll(pattern)) {
|
||||
candidates.push(normalizeLocationCandidate(match.groups?.value || ''))
|
||||
}
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
.filter((item) => !isInvalidApplicationLocationCandidate(item))
|
||||
}
|
||||
|
||||
function normalizeLocationCandidate(value) {
|
||||
let cleaned = String(value || '').replace(/\s+/g, '')
|
||||
for (const marker of ['前往', '去', '到', '赴']) {
|
||||
if (cleaned.includes(marker)) {
|
||||
cleaned = cleaned.slice(cleaned.lastIndexOf(marker) + marker.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
cleaned = cleaned
|
||||
.replace(/^(?:去|到|赴|前往)/u, '')
|
||||
.replace(/(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$/u, '')
|
||||
.replace(/[::,,。;;、\s]/g, '')
|
||||
return cleaned.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
|
||||
}
|
||||
|
||||
function isInvalidApplicationLocationCandidate(value) {
|
||||
const compact = compactText(value)
|
||||
if (!compact) return true
|
||||
if (/^(?:类型|申请类型|费用类型|报销类型)$/.test(compact)) return true
|
||||
if (/^(?:费用申请|差旅费用申请|交通费用申请|住宿费用申请|会务费用申请|采购费用申请|培训费用申请|报销申请|申请)$/.test(compact)) return true
|
||||
if (/(?:类型|申请类型|费用类型|报销类型)/.test(compact)) return true
|
||||
if (/(?:交通方式|出行方式|交通工具|预算|金额|费用|票据|附件|天数|时间|事由|原因|用途)/.test(compact)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function extractApplicationTransportCandidates(text) {
|
||||
const compact = compactText(text)
|
||||
return uniqueApplicationCandidates([
|
||||
resolveApplicationTransportMode(resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])),
|
||||
/高铁|动车|火车|铁路/.test(compact) ? '火车' : '',
|
||||
/飞机|机票|航班/.test(compact) ? '飞机' : '',
|
||||
/轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : ''
|
||||
])
|
||||
}
|
||||
|
||||
function extractApplicationAmountCandidates(text) {
|
||||
const candidates = []
|
||||
const source = String(text || '')
|
||||
const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu
|
||||
for (const match of source.matchAll(labelPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
const amountPattern = /(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/gu
|
||||
for (const match of source.matchAll(amountPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
}
|
||||
|
||||
function uniqueApplicationCandidates(values) {
|
||||
return values
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((item, index, list) => list.indexOf(item) === index)
|
||||
}
|
||||
|
||||
function resolveCurrentUserGrade(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.grade
|
||||
|| currentUser.employeeGrade
|
||||
|| currentUser.employee_grade
|
||||
|| currentUser.profileGrade
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolveCurrentUserDepartment(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.department
|
||||
|| currentUser.departmentName
|
||||
|| currentUser.department_name
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolveCurrentUserPosition(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.position
|
||||
|| currentUser.employeePosition
|
||||
|| currentUser.employee_position
|
||||
|| currentUser.jobTitle
|
||||
|| currentUser.job_title
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolveCurrentUserManagerName(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.managerName
|
||||
|| currentUser.manager_name
|
||||
|| currentUser.directManagerName
|
||||
|| currentUser.direct_manager_name
|
||||
|| currentUser.leaderName
|
||||
|| currentUser.leader_name
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function parseApplicationDaysValue(value) {
|
||||
const match = String(value || '').match(/\d+/)
|
||||
const days = match ? Number(match[0]) : parseChineseNumber(value)
|
||||
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
|
||||
}
|
||||
|
||||
function parseChineseNumber(value) {
|
||||
const digits = {
|
||||
一: 1,
|
||||
二: 2,
|
||||
两: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9
|
||||
}
|
||||
const text = String(value || '').match(/[一二两三四五六七八九十]{1,3}/)?.[0] || ''
|
||||
if (!text) return 0
|
||||
if (text === '十') return 10
|
||||
if (text.includes('十')) {
|
||||
const [left, right] = text.split('十')
|
||||
const tens = left ? digits[left] || 0 : 1
|
||||
const ones = right ? digits[right] || 0 : 0
|
||||
return tens * 10 + ones
|
||||
}
|
||||
return digits[text] || 0
|
||||
}
|
||||
|
||||
function parseMoneyNumber(value) {
|
||||
const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
|
||||
const amount = Number(normalized)
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
function formatPolicyMoney(value) {
|
||||
const amount = parseMoneyNumber(value)
|
||||
if (amount === null) return String(value || '').trim()
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function formatDailyPolicyMoney(value) {
|
||||
const display = formatPolicyMoney(value)
|
||||
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
|
||||
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
|
||||
const mode = String(transportMode || '').trim()
|
||||
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
||||
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
return estimate.basisText
|
||||
}
|
||||
|
||||
function buildTransportEstimateFromPolicyResult(result = {}, fields = {}) {
|
||||
const amount = parseMoneyNumber(result?.transport_estimated_amount)
|
||||
if (!amount || amount <= 0) return null
|
||||
const amountDisplay = formatPolicyMoney(amount)
|
||||
const mode = String(result?.transport_mode || fields.transportMode || '').trim()
|
||||
const origin = String(result?.transport_origin || '').trim()
|
||||
const destination = String(result?.transport_destination || result?.matched_city || fields.location || '').trim()
|
||||
const basis = String(result?.transport_estimate_basis || '').trim()
|
||||
const ruleName = String(result?.transport_estimate_rule_name || '交通费用预估表').trim()
|
||||
const routeText = [origin, destination].filter(Boolean).join('-')
|
||||
const modeText = mode ? `${mode}往返` : '往返'
|
||||
const routeModeText = routeText ? `${routeText}${modeText}` : modeText
|
||||
const displayBasis = routeModeText && basis.startsWith(routeModeText)
|
||||
? basis.slice(routeModeText.length).trim()
|
||||
: basis
|
||||
const basisSuffix = displayBasis ? `(${displayBasis})` : ''
|
||||
return {
|
||||
mode,
|
||||
amount,
|
||||
amountDisplay,
|
||||
routeType: '往返',
|
||||
origin,
|
||||
destination,
|
||||
queryDate: String(result?.travel_date || '').trim(),
|
||||
source: String(result?.transport_estimate_source || 'basic_rule_transport_estimate').trim(),
|
||||
confidence: String(result?.transport_estimate_confidence || 'basic_rule').trim(),
|
||||
basis,
|
||||
ruleCode: String(result?.transport_estimate_rule_code || '').trim(),
|
||||
ruleName,
|
||||
ruleVersion: String(result?.transport_estimate_rule_version || '').trim(),
|
||||
basisText: `当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《${ruleName}》${routeModeText}${basisSuffix}暂估 ${amountDisplay}元用于申请阶段预算占用,最终报销以实际票据金额为准`
|
||||
}
|
||||
}
|
||||
|
||||
function ensureApplicationPolicyFields(fields = {}) {
|
||||
const nextFields = { ...fields }
|
||||
if (!String(nextFields.lodgingDailyCap || '').trim()) {
|
||||
nextFields.lodgingDailyCap = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
if (!String(nextFields.subsidyDailyCap || '').trim()) {
|
||||
nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
|
||||
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode, nextFields.location, null, nextFields.time)
|
||||
}
|
||||
if (!String(nextFields.policyEstimate || '').trim()) {
|
||||
nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
return nextFields
|
||||
}
|
||||
|
||||
function resolveApplicationDays(text) {
|
||||
const value = resolveFirstMatch(text, [
|
||||
/(?:出差|申请)?(?<value>\d+)\s*天/u,
|
||||
/(?<value>\d+)\s*(?:个)?工作日/u
|
||||
])
|
||||
return value ? `${value}天` : ''
|
||||
}
|
||||
|
||||
function resolveApplicationTime(text, daysText = '', options = {}) {
|
||||
const range = text.match(
|
||||
/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—|–|--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||||
)
|
||||
if (range) {
|
||||
return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
|
||||
}
|
||||
|
||||
const shortMonthDayRange = resolveShortMonthDayRange(text, options)
|
||||
if (shortMonthDayRange) {
|
||||
return shortMonthDayRange
|
||||
}
|
||||
|
||||
const single = resolveFirstMatch(text, [
|
||||
/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
|
||||
/(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||||
])
|
||||
if (!single) return ''
|
||||
const normalized = normalizeDateText(single)
|
||||
const endDate = buildEndDateFromDays(normalized, daysText)
|
||||
return endDate && endDate !== normalized ? `${normalized} 至 ${endDate}` : normalized
|
||||
}
|
||||
|
||||
function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
|
||||
const resolvedTime = resolveApplicationTime(text, daysText, options)
|
||||
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
|
||||
return resolvedTime
|
||||
}
|
||||
|
||||
const startDate = resolvePreviewToday(options)
|
||||
const endDate = buildEndDateFromDays(startDate, daysText)
|
||||
return endDate && endDate !== startDate ? `${startDate} 至 ${endDate}` : startDate
|
||||
}
|
||||
|
||||
function resolveApplicationLocation(text) {
|
||||
return resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n]+)/u,
|
||||
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
|
||||
])
|
||||
}
|
||||
|
||||
function looksLikeTransportPromptText(text) {
|
||||
const compact = compactText(text)
|
||||
return /(?:还需要补充|当前还缺|缺少|请先补充|请选择|可以选择|可以选|打算怎么出行|怎么出行).{0,24}(?:出行方式|交通方式|交通工具)/u.test(compact)
|
||||
|| /(?:出行方式|交通方式|交通工具).{0,24}(?:待补充|缺失|请选择|可以选择|可以选)/u.test(compact)
|
||||
}
|
||||
|
||||
function resolveApplicationTransportMode(text) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
const labeledMode = normalizeTransportModeOption(labeled, '')
|
||||
if (labeledMode && !looksLikeTransportPromptText(labeled) && !/(?:请选择|可以选择|可以选)/u.test(String(labeled || ''))) {
|
||||
return labeledMode
|
||||
}
|
||||
const fullTextLooksLikePrompt = looksLikeTransportPromptText(text)
|
||||
const segments = String(text || '')
|
||||
.split(/[\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
for (const segment of segments) {
|
||||
if (looksLikeTransportPromptText(segment)) continue
|
||||
const compactSegment = compactText(segment)
|
||||
if (
|
||||
fullTextLooksLikePrompt
|
||||
&& !/(?:申请|出差|去|到|赴|前往|服务|支撑|支持|部署|上线|实施|拜访|会议)/u.test(compactSegment)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (/高铁|动车|火车|铁路/.test(compactSegment)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compactSegment)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮/.test(compactSegment)) return '轮船'
|
||||
}
|
||||
if (fullTextLooksLikePrompt) return ''
|
||||
const compact = compactText(text)
|
||||
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compact)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮/.test(compact)) return '轮船'
|
||||
return ''
|
||||
}
|
||||
|
||||
function stripKnownContextFromReason(value, context = {}) {
|
||||
const location = String(context.location || '').trim()
|
||||
let cleaned = String(value || '')
|
||||
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
|
||||
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||||
.replace(/(?:\d{1,2}月)?\d{1,2}日?\s*(?:至|到|~|—|–|--|-)\s*(?:\d{1,2}月)?\d{1,2}日?/gu, '')
|
||||
.replace(/\d{1,2}月\d{1,2}日?/gu, '')
|
||||
.replace(/(?:出差|申请)?\d+\s*天/gu, '')
|
||||
.replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[::]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '')
|
||||
.replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '')
|
||||
.replace(/[,,、。;;]+/g, ',')
|
||||
.replace(/^\s*(去|到|前往)/u, '')
|
||||
.replace(/^[,\s]+|[,\s]+$/g, '')
|
||||
.trim()
|
||||
|
||||
if (location) {
|
||||
const escapedLocation = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
cleaned = cleaned
|
||||
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||||
.replace(new RegExp(`^(?:去|到|前往)${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
function pickBusinessReasonSegment(text) {
|
||||
const segments = String(text || '')
|
||||
.split(/[,,、。;;\n]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item && !isSystemGeneratedReasonText(item))
|
||||
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
|
||||
}
|
||||
|
||||
function isSystemGeneratedReasonText(value = '') {
|
||||
const compact = compactText(value)
|
||||
return compact.startsWith('小财管家继续执行')
|
||||
|| /请直接生成申请单核对结果|信息足够时生成申请单|入库或提交审批前|请直接生成报销核对结果/.test(compact)
|
||||
|| compact.startsWith('处理要求')
|
||||
|| compact.startsWith('已识别信息')
|
||||
|| compact.startsWith('用户已补充')
|
||||
|| /^(?:类型|申请类型|费用类型|报销类型)[::]?(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)?$/.test(compact)
|
||||
}
|
||||
|
||||
function resolveApplicationReason(text, context = {}) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?<value>[^,。;;\n]+)/u
|
||||
])
|
||||
if (labeled) return stripKnownContextFromReason(labeled, context)
|
||||
const cleaned = String(text || '')
|
||||
.replace(/^\s*(我想|我要|帮我)?\s*(先)?\s*(发起|提交|申请)?\s*(一笔)?\s*(费用申请|申请)\s*/u, '')
|
||||
const withoutContext = stripKnownContextFromReason(cleaned, context)
|
||||
const businessSegment = pickBusinessReasonSegment(withoutContext)
|
||||
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
|
||||
if (isSystemGeneratedReasonText(withoutContext)) return ''
|
||||
return withoutContext
|
||||
}
|
||||
|
||||
function isApplicationPreviewValueProvided(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
|
||||
}
|
||||
|
||||
function resolveProvidedValue(value, fallback = '') {
|
||||
return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
|
||||
}
|
||||
|
||||
function normalizeApplicationTypeLabel(value, fallback = '') {
|
||||
const label = String(value || '').trim()
|
||||
if (!label || label === '其他费用') return fallback || '费用申请'
|
||||
if (label.endsWith('费用申请') || label.endsWith('申请')) return label
|
||||
if (label.endsWith('费用')) return `${label}申请`
|
||||
if (label.endsWith('费')) return `${label.slice(0, -1)}费用申请`
|
||||
return `${label}申请`
|
||||
}
|
||||
|
||||
export function normalizeTransportModeOption(value, fallback = '') {
|
||||
const text = String(value || '').trim()
|
||||
if (/飞机|机票|航班/.test(text)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
|
||||
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
|
||||
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
|
||||
}
|
||||
|
||||
function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', currentFields = {}) {
|
||||
const currentTransportMode = isApplicationPreviewValueProvided(currentFields.transportMode)
|
||||
? String(currentFields.transportMode).trim()
|
||||
: ''
|
||||
const explicitTransportMode = resolveApplicationTransportMode(rawText)
|
||||
if (!explicitTransportMode) {
|
||||
return currentTransportMode
|
||||
}
|
||||
|
||||
const ontologyTransportMode = normalizeTransportModeOption(ontologyFields.transportMode, '')
|
||||
if (ontologyTransportMode && ontologyTransportMode === explicitTransportMode) {
|
||||
return ontologyTransportMode
|
||||
}
|
||||
return currentTransportMode || explicitTransportMode
|
||||
}
|
||||
|
||||
function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||
const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 0)
|
||||
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
||||
return `${numericAmount}元`
|
||||
}
|
||||
|
||||
const display = String(fields.amountDisplay || '').trim()
|
||||
if (display && display !== '待补充') {
|
||||
const normalized = display.replace(/^¥\s*/, '').replace(/,/g, '').trim()
|
||||
return normalized.endsWith('元') ? normalized : `${normalized}元`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeTypedOntologyAmount(value, fallback = '') {
|
||||
const amount = Number(value || 0)
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
return `${amount}元`
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function buildMissingFields(fields) {
|
||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
|
||||
.filter((item) => item.key !== 'applicationType' && item.required !== false)
|
||||
.filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
|
||||
.map((item) => resolveApplicationFieldLabel(item, fields))
|
||||
}
|
||||
import {
|
||||
APPLICATION_POLICY_PENDING_TEXT,
|
||||
APPLICATION_PREVIEW_FIELD_DEFINITIONS,
|
||||
APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
|
||||
buildMissingFields,
|
||||
buildTransportEstimateFromPolicyResult,
|
||||
buildTransportPolicyText,
|
||||
ensureApplicationPolicyFields,
|
||||
formatDailyPolicyMoney,
|
||||
formatPolicyMoney,
|
||||
isApplicationPreviewValueProvided,
|
||||
isTravelApplicationType,
|
||||
normalizeAmountFromOntology,
|
||||
normalizeApplicationTypeLabel,
|
||||
normalizeTypedOntologyAmount,
|
||||
parseApplicationDaysValue,
|
||||
parseMoneyNumber,
|
||||
resolveApplicationAmount,
|
||||
resolveApplicationDays,
|
||||
resolveApplicationFieldLabel,
|
||||
resolveApplicationLocation,
|
||||
resolveApplicationReason,
|
||||
resolveApplicationSourceValidationIssues,
|
||||
resolveApplicationTimeWithDefault,
|
||||
resolveApplicationTransportMode,
|
||||
resolveApplicationTripDateParts,
|
||||
resolveApplicationType,
|
||||
resolveApplicationValidationIssues,
|
||||
resolveCurrentUserDepartment,
|
||||
resolveCurrentUserGrade,
|
||||
resolveCurrentUserManagerName,
|
||||
resolveCurrentUserPosition,
|
||||
resolveDaysFromDateRange,
|
||||
resolveModelRefinedTransportMode,
|
||||
resolveProvidedValue
|
||||
} from './expenseApplicationPreviewParsing.js'
|
||||
export {
|
||||
APPLICATION_PREVIEW_FIELD_DEFINITIONS,
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
applicationDateRangesOverlap,
|
||||
normalizeTransportModeOption,
|
||||
resolveApplicationDateRange,
|
||||
resolveApplicationDaysFromDateRange,
|
||||
resolveApplicationTimeLabel,
|
||||
shouldRequireApplicationModelReview
|
||||
} from './expenseApplicationPreviewParsing.js'
|
||||
|
||||
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
|
||||
742
web/src/utils/expenseApplicationPreviewParsing.js
Normal file
742
web/src/utils/expenseApplicationPreviewParsing.js
Normal file
@@ -0,0 +1,742 @@
|
||||
import { buildMockApplicationTransportEstimate } from './expenseApplicationEstimate.js'
|
||||
import { getTodayDateValue } from './workbenchComposerDate.js'
|
||||
|
||||
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||||
{ key: 'applicationType', label: '申请类型' },
|
||||
{ key: 'applicant', label: '姓名', editable: false, required: false },
|
||||
{ key: 'grade', label: '职级', highlight: true, editable: false, required: false },
|
||||
{ key: 'department', label: '部门', editable: false, required: false },
|
||||
{ key: 'position', label: '岗位', editable: false, required: false },
|
||||
{ key: 'managerName', label: '直属领导', editable: false, required: false },
|
||||
{ key: 'time', label: '申请时间' },
|
||||
{ key: 'location', label: '地点' },
|
||||
{ key: 'reason', label: '事由' },
|
||||
{ key: 'days', label: '天数' },
|
||||
{ key: 'transportMode', label: '出行方式' },
|
||||
{ key: 'lodgingDailyCap', label: '住宿上限/天', highlight: true, editable: false, required: false },
|
||||
{ key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
|
||||
{ key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
|
||||
{ key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
|
||||
{ key: 'amount', label: '系统预估费用', highlight: true, editable: false, required: false }
|
||||
]
|
||||
|
||||
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
||||
|
||||
export const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
||||
export const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
|
||||
|
||||
export function resolveApplicationTimeLabel(applicationType = '') {
|
||||
const label = String(applicationType || '').trim()
|
||||
if (/差旅|出差/.test(label)) return '出发时间'
|
||||
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
|
||||
return '申请时间'
|
||||
}
|
||||
|
||||
export function resolveApplicationFieldLabel(item, fields = {}) {
|
||||
if (item.key === 'time') {
|
||||
return resolveApplicationTimeLabel(fields.applicationType)
|
||||
}
|
||||
return item.label
|
||||
}
|
||||
|
||||
export function isTravelApplicationType(applicationType = '') {
|
||||
return /差旅|出差/.test(String(applicationType || '').trim())
|
||||
}
|
||||
|
||||
export function resolveApplicationTripDateParts(fields = {}) {
|
||||
const timeText = String(fields.time || '').trim()
|
||||
const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || timeText)
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, fields.days)
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate: inferredEndDate || explicitEndDate || startDate
|
||||
}
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function looksLikeStructuredTravelApplication(text) {
|
||||
const source = String(text || '')
|
||||
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(source)
|
||||
&& /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(source)
|
||||
&& /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
|
||||
}
|
||||
|
||||
function resolveFirstMatch(text, patterns = []) {
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern)
|
||||
const value = String(match?.groups?.value || match?.[1] || '').trim()
|
||||
if (value) return value.replace(/[,。;;]$/, '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeDateText(value) {
|
||||
return String(value || '').replace(/[/.]/g, '-').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function parseIsoDate(value) {
|
||||
const match = normalizeDateText(value).match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
|
||||
if (!match) return null
|
||||
const [, year, month, day] = match
|
||||
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function buildEndDateFromDays(startText, daysText = '') {
|
||||
const days = parseApplicationDaysValue(daysText)
|
||||
const start = parseIsoDate(startText)
|
||||
if (!days || !start) return ''
|
||||
const end = new Date(start.getTime())
|
||||
end.setUTCDate(end.getUTCDate() + Math.max(days - 1, 0))
|
||||
return formatIsoDate(end)
|
||||
}
|
||||
|
||||
function buildDateFromMonthDay(year, month, day) {
|
||||
const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return parseIsoDate(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function resolveShortMonthDayRange(text, options = {}) {
|
||||
const match = String(text || '').match(
|
||||
/(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?\s*(?:至|到|~|—|–|--|-)\s*(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日/u
|
||||
)
|
||||
if (!match?.groups) return ''
|
||||
|
||||
const referenceYear = Number(resolvePreviewToday(options).slice(0, 4))
|
||||
const startMonth = Number(match.groups.startMonth)
|
||||
const startDay = Number(match.groups.startDay)
|
||||
const endMonth = Number(match.groups.endMonth || match.groups.startMonth)
|
||||
const endDay = Number(match.groups.endDay)
|
||||
const startDate = buildDateFromMonthDay(referenceYear, startMonth, startDay)
|
||||
const endYear = endMonth < startMonth ? referenceYear + 1 : referenceYear
|
||||
const endDate = buildDateFromMonthDay(endYear, endMonth, endDay)
|
||||
if (!startDate || !endDate) return ''
|
||||
return startDate === endDate ? startDate : `${startDate} 至 ${endDate}`
|
||||
}
|
||||
|
||||
export function resolveDaysFromDateRange(rangeText) {
|
||||
const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
|
||||
if (!match) return ''
|
||||
const start = parseIsoDate(match[1])
|
||||
const end = parseIsoDate(match[2])
|
||||
if (!start || !end) return ''
|
||||
const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
|
||||
return diffDays >= 0 ? `${diffDays + 1}天` : ''
|
||||
}
|
||||
|
||||
export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
export function resolveApplicationValidationIssues(fields = {}) {
|
||||
const issues = []
|
||||
const rangeDaysText = resolveDaysFromDateRange(fields.time)
|
||||
const rangeDays = parseApplicationDaysValue(rangeDaysText)
|
||||
const explicitDays = parseApplicationDaysValue(fields.days)
|
||||
if (rangeDays && explicitDays && rangeDays !== explicitDays) {
|
||||
issues.push({
|
||||
code: 'time_days_conflict',
|
||||
field: 'days',
|
||||
message: `申请时间 ${fields.time} 按自然日为 ${rangeDays} 天,但填写的是 ${explicitDays} 天。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function shouldTrustModelApplicationFields(preview = {}) {
|
||||
const status = String(preview?.modelReviewStatus || '').trim()
|
||||
const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
|
||||
return Boolean(preview?.modelRefined)
|
||||
|| status === 'completed'
|
||||
|| strategy === 'llm_primary'
|
||||
}
|
||||
|
||||
export function resolveApplicationSourceValidationIssues(sourceText = '', fields = {}, preview = {}) {
|
||||
if (shouldTrustModelApplicationFields(preview)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const issues = []
|
||||
const locationCandidates = extractApplicationLocationCandidates(sourceText)
|
||||
if (locationCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'location_candidates_conflict',
|
||||
field: 'location',
|
||||
message: `当前输入里同时出现多个地点:${locationCandidates.join('、')}。请确认本次申请地点。`
|
||||
})
|
||||
}
|
||||
|
||||
const transportCandidates = extractApplicationTransportCandidates(sourceText)
|
||||
if (transportCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'transport_candidates_conflict',
|
||||
field: 'transportMode',
|
||||
message: `当前输入里同时出现多个出行方式:${transportCandidates.join('、')}。请确认本次申请使用哪一种。`
|
||||
})
|
||||
}
|
||||
|
||||
const amountCandidates = extractApplicationAmountCandidates(sourceText)
|
||||
if (amountCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'amount_candidates_conflict',
|
||||
field: 'amount',
|
||||
message: `当前输入里同时出现多个金额:${amountCandidates.join('、')}。请确认本次申请金额。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
export function shouldRequireApplicationModelReview(rawText = '') {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = compactText(text)
|
||||
if (!compact) return false
|
||||
|
||||
const hasDateRange = /(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(?:\d{1,2}月)?\d{1,2}日?)\s*(?:至|到|~|—|–|--|-)\s*(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|\d{1,2}月?\d{1,2}日?)/u.test(text)
|
||||
const hasTravelIntent = /差旅|出差|去|到|赴|前往/.test(compact)
|
||||
const hasTransport = /高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮/.test(compact)
|
||||
const hasBusinessPurpose = /服务|支撑|支持|辅助|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(compact)
|
||||
const hasInlinePurpose = hasBusinessPurpose && !/(?:事由|申请事由|出差事由|原因|用途)\s*[::]/.test(text)
|
||||
const hasMultipleClauses = /[,,。;;\n]/.test(text) || compact.length >= 24
|
||||
const signalCount = [hasDateRange, hasTravelIntent, hasTransport, hasBusinessPurpose].filter(Boolean).length
|
||||
|
||||
return (hasInlinePurpose && signalCount >= 2) || (hasMultipleClauses && signalCount >= 3)
|
||||
}
|
||||
|
||||
export function resolveApplicationDateRange(rangeText, daysText = '') {
|
||||
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || '')
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, daysText)
|
||||
const endDate = inferredEndDate || explicitEndDate || startDate
|
||||
const start = parseIsoDate(startDate)
|
||||
const end = parseIsoDate(endDate)
|
||||
if (!start || !end) {
|
||||
return null
|
||||
}
|
||||
const orderedStart = start.getTime() <= end.getTime() ? start : end
|
||||
const orderedEnd = start.getTime() <= end.getTime() ? end : start
|
||||
return {
|
||||
startDate: formatIsoDate(orderedStart),
|
||||
endDate: formatIsoDate(orderedEnd),
|
||||
startTime: orderedStart.getTime(),
|
||||
endTime: orderedEnd.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
export function applicationDateRangesOverlap(leftRange, rightRange) {
|
||||
if (!leftRange || !rightRange) {
|
||||
return false
|
||||
}
|
||||
return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
|
||||
}
|
||||
|
||||
function resolvePreviewToday(options = {}) {
|
||||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||||
if (options.now instanceof Date && !Number.isNaN(options.now.getTime())) {
|
||||
return getTodayDateValue(options.now)
|
||||
}
|
||||
return getTodayDateValue()
|
||||
}
|
||||
|
||||
export function resolveApplicationType(text) {
|
||||
const compact = compactText(text)
|
||||
if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
|
||||
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
|
||||
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
|
||||
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
|
||||
if (/会务|会议|发布会|展会/.test(compact)) return '会务费用申请'
|
||||
if (/采购|办公用品|文具|设备|耗材/.test(compact)) return '采购费用申请'
|
||||
if (/培训|课程|学习/.test(compact)) return '培训费用申请'
|
||||
return '费用申请'
|
||||
}
|
||||
|
||||
export function resolveApplicationAmount(text) {
|
||||
const compact = compactText(text)
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
|
||||
/(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
|
||||
])
|
||||
const normalized = normalizeApplicationAmountText(labeled)
|
||||
if (normalized) return normalized
|
||||
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeApplicationAmountText(value) {
|
||||
const text = String(value || '').replace(/[,,]/g, '').trim()
|
||||
const match = text.match(/(?<number>\d+(?:\.\d+)?)\s*(?<unit>万|千|k|K)?/u)
|
||||
if (!match?.groups) return ''
|
||||
let amount = Number(match.groups.number)
|
||||
if (!Number.isFinite(amount) || amount <= 0) return ''
|
||||
const unit = String(match.groups.unit || '').toLowerCase()
|
||||
if (unit === '万') amount *= 10000
|
||||
if (unit === '千' || unit === 'k') amount *= 1000
|
||||
return `${Number.isInteger(amount) ? amount : Number(amount.toFixed(2))}元`
|
||||
}
|
||||
|
||||
function extractApplicationLocationCandidates(text) {
|
||||
const candidates = []
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
if (labeled) candidates.push(normalizeLocationCandidate(labeled))
|
||||
|
||||
const compact = compactText(text)
|
||||
const patterns = [
|
||||
/(?:去|到|赴|前往)(?<value>[\u4e00-\u9fa5]{1,24})/gu,
|
||||
/(?<value>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)/gu
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
for (const match of compact.matchAll(pattern)) {
|
||||
candidates.push(normalizeLocationCandidate(match.groups?.value || ''))
|
||||
}
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
.filter((item) => !isInvalidApplicationLocationCandidate(item))
|
||||
}
|
||||
|
||||
function normalizeLocationCandidate(value) {
|
||||
let cleaned = String(value || '').replace(/\s+/g, '')
|
||||
for (const marker of ['前往', '去', '到', '赴']) {
|
||||
if (cleaned.includes(marker)) {
|
||||
cleaned = cleaned.slice(cleaned.lastIndexOf(marker) + marker.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
cleaned = cleaned
|
||||
.replace(/^(?:去|到|赴|前往)/u, '')
|
||||
.replace(/(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$/u, '')
|
||||
.replace(/[::,,。;;、\s]/g, '')
|
||||
return cleaned.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
|
||||
}
|
||||
|
||||
function isInvalidApplicationLocationCandidate(value) {
|
||||
const compact = compactText(value)
|
||||
if (!compact) return true
|
||||
if (/^(?:类型|申请类型|费用类型|报销类型)$/.test(compact)) return true
|
||||
if (/^(?:费用申请|差旅费用申请|交通费用申请|住宿费用申请|会务费用申请|采购费用申请|培训费用申请|报销申请|申请)$/.test(compact)) return true
|
||||
if (/(?:类型|申请类型|费用类型|报销类型)/.test(compact)) return true
|
||||
if (/(?:交通方式|出行方式|交通工具|预算|金额|费用|票据|附件|天数|时间|事由|原因|用途)/.test(compact)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function extractApplicationTransportCandidates(text) {
|
||||
const compact = compactText(text)
|
||||
return uniqueApplicationCandidates([
|
||||
resolveApplicationTransportMode(resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])),
|
||||
/高铁|动车|火车|铁路/.test(compact) ? '火车' : '',
|
||||
/飞机|机票|航班/.test(compact) ? '飞机' : '',
|
||||
/轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : ''
|
||||
])
|
||||
}
|
||||
|
||||
function extractApplicationAmountCandidates(text) {
|
||||
const candidates = []
|
||||
const source = String(text || '')
|
||||
const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu
|
||||
for (const match of source.matchAll(labelPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
const amountPattern = /(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/gu
|
||||
for (const match of source.matchAll(amountPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
}
|
||||
|
||||
function uniqueApplicationCandidates(values) {
|
||||
return values
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((item, index, list) => list.indexOf(item) === index)
|
||||
}
|
||||
|
||||
export function resolveCurrentUserGrade(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.grade
|
||||
|| currentUser.employeeGrade
|
||||
|| currentUser.employee_grade
|
||||
|| currentUser.profileGrade
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
export function resolveCurrentUserDepartment(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.department
|
||||
|| currentUser.departmentName
|
||||
|| currentUser.department_name
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
export function resolveCurrentUserPosition(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.position
|
||||
|| currentUser.employeePosition
|
||||
|| currentUser.employee_position
|
||||
|| currentUser.jobTitle
|
||||
|| currentUser.job_title
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
export function resolveCurrentUserManagerName(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.managerName
|
||||
|| currentUser.manager_name
|
||||
|| currentUser.directManagerName
|
||||
|| currentUser.direct_manager_name
|
||||
|| currentUser.leaderName
|
||||
|| currentUser.leader_name
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
export function parseApplicationDaysValue(value) {
|
||||
const match = String(value || '').match(/\d+/)
|
||||
const days = match ? Number(match[0]) : parseChineseNumber(value)
|
||||
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
|
||||
}
|
||||
|
||||
function parseChineseNumber(value) {
|
||||
const digits = {
|
||||
一: 1,
|
||||
二: 2,
|
||||
两: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9
|
||||
}
|
||||
const text = String(value || '').match(/[一二两三四五六七八九十]{1,3}/)?.[0] || ''
|
||||
if (!text) return 0
|
||||
if (text === '十') return 10
|
||||
if (text.includes('十')) {
|
||||
const [left, right] = text.split('十')
|
||||
const tens = left ? digits[left] || 0 : 1
|
||||
const ones = right ? digits[right] || 0 : 0
|
||||
return tens * 10 + ones
|
||||
}
|
||||
return digits[text] || 0
|
||||
}
|
||||
|
||||
export function parseMoneyNumber(value) {
|
||||
const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
|
||||
const amount = Number(normalized)
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
export function formatPolicyMoney(value) {
|
||||
const amount = parseMoneyNumber(value)
|
||||
if (amount === null) return String(value || '').trim()
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export function formatDailyPolicyMoney(value) {
|
||||
const display = formatPolicyMoney(value)
|
||||
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
|
||||
export function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
|
||||
const mode = String(transportMode || '').trim()
|
||||
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
||||
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
return estimate.basisText
|
||||
}
|
||||
|
||||
export function buildTransportEstimateFromPolicyResult(result = {}, fields = {}) {
|
||||
const amount = parseMoneyNumber(result?.transport_estimated_amount)
|
||||
if (!amount || amount <= 0) return null
|
||||
const amountDisplay = formatPolicyMoney(amount)
|
||||
const mode = String(result?.transport_mode || fields.transportMode || '').trim()
|
||||
const origin = String(result?.transport_origin || '').trim()
|
||||
const destination = String(result?.transport_destination || result?.matched_city || fields.location || '').trim()
|
||||
const basis = String(result?.transport_estimate_basis || '').trim()
|
||||
const ruleName = String(result?.transport_estimate_rule_name || '交通费用预估表').trim()
|
||||
const routeText = [origin, destination].filter(Boolean).join('-')
|
||||
const modeText = mode ? `${mode}往返` : '往返'
|
||||
const routeModeText = routeText ? `${routeText}${modeText}` : modeText
|
||||
const displayBasis = routeModeText && basis.startsWith(routeModeText)
|
||||
? basis.slice(routeModeText.length).trim()
|
||||
: basis
|
||||
const basisSuffix = displayBasis ? `(${displayBasis})` : ''
|
||||
return {
|
||||
mode,
|
||||
amount,
|
||||
amountDisplay,
|
||||
routeType: '往返',
|
||||
origin,
|
||||
destination,
|
||||
queryDate: String(result?.travel_date || '').trim(),
|
||||
source: String(result?.transport_estimate_source || 'basic_rule_transport_estimate').trim(),
|
||||
confidence: String(result?.transport_estimate_confidence || 'basic_rule').trim(),
|
||||
basis,
|
||||
ruleCode: String(result?.transport_estimate_rule_code || '').trim(),
|
||||
ruleName,
|
||||
ruleVersion: String(result?.transport_estimate_rule_version || '').trim(),
|
||||
basisText: `当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《${ruleName}》${routeModeText}${basisSuffix}暂估 ${amountDisplay}元用于申请阶段预算占用,最终报销以实际票据金额为准`
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureApplicationPolicyFields(fields = {}) {
|
||||
const nextFields = { ...fields }
|
||||
if (!String(nextFields.lodgingDailyCap || '').trim()) {
|
||||
nextFields.lodgingDailyCap = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
if (!String(nextFields.subsidyDailyCap || '').trim()) {
|
||||
nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
|
||||
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode, nextFields.location, null, nextFields.time)
|
||||
}
|
||||
if (!String(nextFields.policyEstimate || '').trim()) {
|
||||
nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
return nextFields
|
||||
}
|
||||
|
||||
export function resolveApplicationDays(text) {
|
||||
const value = resolveFirstMatch(text, [
|
||||
/(?:出差|申请)?(?<value>\d+)\s*天/u,
|
||||
/(?<value>\d+)\s*(?:个)?工作日/u
|
||||
])
|
||||
return value ? `${value}天` : ''
|
||||
}
|
||||
|
||||
function resolveApplicationTime(text, daysText = '', options = {}) {
|
||||
const range = text.match(
|
||||
/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—|–|--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||||
)
|
||||
if (range) {
|
||||
return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
|
||||
}
|
||||
|
||||
const shortMonthDayRange = resolveShortMonthDayRange(text, options)
|
||||
if (shortMonthDayRange) {
|
||||
return shortMonthDayRange
|
||||
}
|
||||
|
||||
const single = resolveFirstMatch(text, [
|
||||
/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
|
||||
/(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||||
])
|
||||
if (!single) return ''
|
||||
const normalized = normalizeDateText(single)
|
||||
const endDate = buildEndDateFromDays(normalized, daysText)
|
||||
return endDate && endDate !== normalized ? `${normalized} 至 ${endDate}` : normalized
|
||||
}
|
||||
|
||||
export function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
|
||||
const resolvedTime = resolveApplicationTime(text, daysText, options)
|
||||
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
|
||||
return resolvedTime
|
||||
}
|
||||
|
||||
const startDate = resolvePreviewToday(options)
|
||||
const endDate = buildEndDateFromDays(startDate, daysText)
|
||||
return endDate && endDate !== startDate ? `${startDate} 至 ${endDate}` : startDate
|
||||
}
|
||||
|
||||
export function resolveApplicationLocation(text) {
|
||||
return resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n]+)/u,
|
||||
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
|
||||
])
|
||||
}
|
||||
|
||||
function looksLikeTransportPromptText(text) {
|
||||
const compact = compactText(text)
|
||||
return /(?:还需要补充|当前还缺|缺少|请先补充|请选择|可以选择|可以选|打算怎么出行|怎么出行).{0,24}(?:出行方式|交通方式|交通工具)/u.test(compact)
|
||||
|| /(?:出行方式|交通方式|交通工具).{0,24}(?:待补充|缺失|请选择|可以选择|可以选)/u.test(compact)
|
||||
}
|
||||
|
||||
export function resolveApplicationTransportMode(text) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
const labeledMode = normalizeTransportModeOption(labeled, '')
|
||||
if (labeledMode && !looksLikeTransportPromptText(labeled) && !/(?:请选择|可以选择|可以选)/u.test(String(labeled || ''))) {
|
||||
return labeledMode
|
||||
}
|
||||
const fullTextLooksLikePrompt = looksLikeTransportPromptText(text)
|
||||
const segments = String(text || '')
|
||||
.split(/[\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
for (const segment of segments) {
|
||||
if (looksLikeTransportPromptText(segment)) continue
|
||||
const compactSegment = compactText(segment)
|
||||
if (
|
||||
fullTextLooksLikePrompt
|
||||
&& !/(?:申请|出差|去|到|赴|前往|服务|支撑|支持|部署|上线|实施|拜访|会议)/u.test(compactSegment)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (/高铁|动车|火车|铁路/.test(compactSegment)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compactSegment)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮/.test(compactSegment)) return '轮船'
|
||||
}
|
||||
if (fullTextLooksLikePrompt) return ''
|
||||
const compact = compactText(text)
|
||||
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compact)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮/.test(compact)) return '轮船'
|
||||
return ''
|
||||
}
|
||||
|
||||
function stripKnownContextFromReason(value, context = {}) {
|
||||
const location = String(context.location || '').trim()
|
||||
let cleaned = String(value || '')
|
||||
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
|
||||
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||||
.replace(/(?:\d{1,2}月)?\d{1,2}日?\s*(?:至|到|~|—|–|--|-)\s*(?:\d{1,2}月)?\d{1,2}日?/gu, '')
|
||||
.replace(/\d{1,2}月\d{1,2}日?/gu, '')
|
||||
.replace(/(?:出差|申请)?\d+\s*天/gu, '')
|
||||
.replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[::]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '')
|
||||
.replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '')
|
||||
.replace(/[,,、。;;]+/g, ',')
|
||||
.replace(/^\s*(去|到|前往)/u, '')
|
||||
.replace(/^[,\s]+|[,\s]+$/g, '')
|
||||
.trim()
|
||||
|
||||
if (location) {
|
||||
const escapedLocation = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
cleaned = cleaned
|
||||
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||||
.replace(new RegExp(`^(?:去|到|前往)${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
function pickBusinessReasonSegment(text) {
|
||||
const segments = String(text || '')
|
||||
.split(/[,,、。;;\n]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item && !isSystemGeneratedReasonText(item))
|
||||
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
|
||||
}
|
||||
|
||||
function isSystemGeneratedReasonText(value = '') {
|
||||
const compact = compactText(value)
|
||||
return compact.startsWith('小财管家继续执行')
|
||||
|| /请直接生成申请单核对结果|信息足够时生成申请单|入库或提交审批前|请直接生成报销核对结果/.test(compact)
|
||||
|| compact.startsWith('处理要求')
|
||||
|| compact.startsWith('已识别信息')
|
||||
|| compact.startsWith('用户已补充')
|
||||
|| /^(?:类型|申请类型|费用类型|报销类型)[::]?(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)?$/.test(compact)
|
||||
}
|
||||
|
||||
export function resolveApplicationReason(text, context = {}) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?<value>[^,。;;\n]+)/u
|
||||
])
|
||||
if (labeled) return stripKnownContextFromReason(labeled, context)
|
||||
const cleaned = String(text || '')
|
||||
.replace(/^\s*(我想|我要|帮我)?\s*(先)?\s*(发起|提交|申请)?\s*(一笔)?\s*(费用申请|申请)\s*/u, '')
|
||||
const withoutContext = stripKnownContextFromReason(cleaned, context)
|
||||
const businessSegment = pickBusinessReasonSegment(withoutContext)
|
||||
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
|
||||
if (isSystemGeneratedReasonText(withoutContext)) return ''
|
||||
return withoutContext
|
||||
}
|
||||
|
||||
export function isApplicationPreviewValueProvided(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
|
||||
}
|
||||
|
||||
export function resolveProvidedValue(value, fallback = '') {
|
||||
return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
|
||||
}
|
||||
|
||||
export function normalizeApplicationTypeLabel(value, fallback = '') {
|
||||
const label = String(value || '').trim()
|
||||
if (!label || label === '其他费用') return fallback || '费用申请'
|
||||
if (label.endsWith('费用申请') || label.endsWith('申请')) return label
|
||||
if (label.endsWith('费用')) return `${label}申请`
|
||||
if (label.endsWith('费')) return `${label.slice(0, -1)}费用申请`
|
||||
return `${label}申请`
|
||||
}
|
||||
|
||||
export function normalizeTransportModeOption(value, fallback = '') {
|
||||
const text = String(value || '').trim()
|
||||
if (/飞机|机票|航班/.test(text)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
|
||||
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
|
||||
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||
const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 0)
|
||||
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
||||
return `${numericAmount}元`
|
||||
}
|
||||
|
||||
const display = String(fields.amountDisplay || '').trim()
|
||||
if (display && display !== '待补充') {
|
||||
const normalized = display.replace(/^¥\s*/, '').replace(/,/g, '').trim()
|
||||
return normalized.endsWith('元') ? normalized : `${normalized}元`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function normalizeTypedOntologyAmount(value, fallback = '') {
|
||||
const amount = Number(value || 0)
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
return `${amount}元`
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function buildMissingFields(fields) {
|
||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
|
||||
.filter((item) => item.key !== 'applicationType' && item.required !== false)
|
||||
.filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
|
||||
.map((item) => resolveApplicationFieldLabel(item, fields))
|
||||
}
|
||||
124
web/src/utils/expenseClaimAttachmentSync.js
Normal file
124
web/src/utils/expenseClaimAttachmentSync.js
Normal file
@@ -0,0 +1,124 @@
|
||||
function normalizeAttachmentMatchName(value) {
|
||||
const fileName = String(value || '')
|
||||
.trim()
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.pop() || ''
|
||||
return fileName
|
||||
.toLowerCase()
|
||||
.replace(/[^\w.\-\u4e00-\u9fff]+/g, '_')
|
||||
.replace(/^[_\.]+|[_\.]+$/g, '')
|
||||
}
|
||||
|
||||
function isSystemGeneratedExpenseItem(item = {}) {
|
||||
const itemType = String(item?.itemType || item?.item_type || '').trim()
|
||||
return Boolean(
|
||||
item?.isSystemGenerated ||
|
||||
item?.is_system_generated ||
|
||||
itemType === 'travel_allowance'
|
||||
)
|
||||
}
|
||||
|
||||
function findCreatedAttachmentItem(items = [], usedItemIds = new Set()) {
|
||||
return (Array.isArray(items) ? items : []).find((item) => {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
const itemType = String(item?.itemType || item?.item_type || '').trim()
|
||||
return (
|
||||
itemId &&
|
||||
!usedItemIds.has(itemId) &&
|
||||
!invoiceId &&
|
||||
itemType !== 'travel_allowance' &&
|
||||
!item?.isSystemGenerated &&
|
||||
!item?.is_system_generated
|
||||
)
|
||||
}) || null
|
||||
}
|
||||
|
||||
export async function syncExpenseClaimFilesToDraft({
|
||||
claimId = '',
|
||||
files = [],
|
||||
fetchExpenseClaimDetail,
|
||||
createExpenseClaimItem,
|
||||
uploadExpenseClaimItemAttachment
|
||||
} = {}) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
if (!normalizedClaimId || !safeFiles.length || typeof uploadExpenseClaimItemAttachment !== 'function') {
|
||||
return { uploadedCount: 0, skippedCount: safeFiles.length, uploadedFileNames: [], skippedFileNames: safeFiles.map((file) => file?.name || '') }
|
||||
}
|
||||
if (typeof fetchExpenseClaimDetail !== 'function') {
|
||||
throw new Error('缺少单据详情查询服务,暂时无法自动归集附件。')
|
||||
}
|
||||
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const exactMatchBuckets = new Map()
|
||||
const normalizedMatchBuckets = new Map()
|
||||
const placeholderQueue = []
|
||||
const emptyAttachmentQueue = []
|
||||
const usedItemIds = new Set()
|
||||
const uploadedFileNames = []
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
if (!itemId) continue
|
||||
|
||||
if (!invoiceId && !isSystemGeneratedExpenseItem(item)) {
|
||||
emptyAttachmentQueue.push(item)
|
||||
continue
|
||||
}
|
||||
if (!invoiceId || invoiceId.includes('/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
placeholderQueue.push(item)
|
||||
const exactBucket = exactMatchBuckets.get(invoiceId) || []
|
||||
exactBucket.push(item)
|
||||
exactMatchBuckets.set(invoiceId, exactBucket)
|
||||
|
||||
const normalizedInvoiceName = normalizeAttachmentMatchName(invoiceId)
|
||||
if (normalizedInvoiceName) {
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizedInvoiceName) || []
|
||||
normalizedBucket.push(item)
|
||||
normalizedMatchBuckets.set(normalizedInvoiceName, normalizedBucket)
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of safeFiles) {
|
||||
const exactBucket = exactMatchBuckets.get(file.name) || []
|
||||
const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || []
|
||||
const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const emptyFallbackMatch = emptyAttachmentQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
let targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch || emptyFallbackMatch
|
||||
let targetItemId = String(targetItem?.id || '').trim()
|
||||
|
||||
if (!targetItemId && typeof createExpenseClaimItem === 'function') {
|
||||
const updatedClaim = await createExpenseClaimItem(normalizedClaimId, {})
|
||||
targetItem = findCreatedAttachmentItem(updatedClaim?.items, usedItemIds)
|
||||
targetItemId = String(targetItem?.id || '').trim()
|
||||
}
|
||||
if (!targetItemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
usedItemIds.add(targetItemId)
|
||||
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
|
||||
uploadedFileNames.push(String(file?.name || '').trim())
|
||||
}
|
||||
|
||||
const uploadedSet = new Set(uploadedFileNames)
|
||||
const skippedFileNames = safeFiles
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter((name) => !uploadedSet.has(name))
|
||||
|
||||
return {
|
||||
uploadedCount: uploadedFileNames.length,
|
||||
skippedCount: Math.max(0, safeFiles.length - uploadedFileNames.length),
|
||||
uploadedFileNames,
|
||||
skippedFileNames
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@
|
||||
v-else-if="activeView === 'documents' && detailMode && selectedRequest"
|
||||
:request="selectedRequest"
|
||||
:back-label="detailBackLabel"
|
||||
@back-to-requests="closeRequestDetail"
|
||||
@back-to-requests="handleDocumentDetailBack"
|
||||
@open-assistant="openSmartEntry"
|
||||
@request-updated="handleRequestUpdated"
|
||||
@request-deleted="handleDetailRequestDeleted"
|
||||
@@ -362,6 +362,7 @@ const {
|
||||
smartEntryRevealToken,
|
||||
smartEntrySessionId,
|
||||
toast,
|
||||
detailReturnTarget,
|
||||
topBarView
|
||||
} = useAppShell()
|
||||
|
||||
@@ -370,6 +371,7 @@ const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
|
||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||
const isAiShellMode = computed(() => workbenchMode.value === 'ai')
|
||||
const isWorkbenchAiMode = computed(() => activeView.value === 'workbench' && workbenchMode.value === 'ai')
|
||||
const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation'])
|
||||
const DETAIL_TOPBAR_FALLBACKS = {
|
||||
audit: {
|
||||
title: '规则中心详情',
|
||||
@@ -412,6 +414,40 @@ const resolvedDetailKpis = computed(() => (
|
||||
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
|
||||
))
|
||||
|
||||
function resolveDocumentDetailReturnTarget(value) {
|
||||
const target = String(value || '').trim()
|
||||
return DOCUMENT_DETAIL_RETURN_TARGETS.has(target) ? target : ''
|
||||
}
|
||||
|
||||
function resolveActiveAiConversationSnapshot() {
|
||||
const conversationId = String(aiActiveConversationId.value || '').trim()
|
||||
if (!conversationId) {
|
||||
return null
|
||||
}
|
||||
const history = aiConversationHistory.value.length
|
||||
? aiConversationHistory.value
|
||||
: loadAiWorkbenchConversationHistory(currentUser.value || {})
|
||||
return history.find((item) => String(item.id || item.conversationId || '').trim() === conversationId) || null
|
||||
}
|
||||
|
||||
async function handleDocumentDetailBack() {
|
||||
const shouldRestoreConversation = detailReturnTarget.value === 'conversation'
|
||||
const activeConversation = shouldRestoreConversation ? resolveActiveAiConversationSnapshot() : null
|
||||
const navigation = closeRequestDetail()
|
||||
if (navigation && typeof navigation.then === 'function') {
|
||||
await navigation
|
||||
}
|
||||
|
||||
if (!shouldRestoreConversation || !activeConversation) {
|
||||
return
|
||||
}
|
||||
|
||||
workbenchMode.value = 'ai'
|
||||
sidebarCollapsed.value = false
|
||||
await nextTick()
|
||||
dispatchAiSidebarCommand('open-recent', activeConversation)
|
||||
}
|
||||
|
||||
function openWorkbenchDocument(payload = {}) {
|
||||
const payloadClaimId = String(payload.claimId || payload.claim_id || '').trim()
|
||||
const payloadId = String(payload.id || '').trim()
|
||||
@@ -436,13 +472,12 @@ function openWorkbenchDocument(payload = {}) {
|
||||
|| String(item.claimNo || '').trim() === requestId
|
||||
|| String(item.documentNo || '').trim() === requestId
|
||||
))
|
||||
const returnTo = (
|
||||
String(payload.returnTo || '').trim() === 'workbench'
|
||||
|| String(payload.source || '').trim() === 'workbench'
|
||||
const explicitReturnTo = resolveDocumentDetailReturnTarget(payload.returnTo)
|
||||
const fallbackToWorkbench = (
|
||||
String(payload.source || '').trim() === 'workbench'
|
||||
|| activeView.value === 'workbench'
|
||||
)
|
||||
? 'workbench'
|
||||
: ''
|
||||
const returnTo = explicitReturnTo || (fallbackToWorkbench ? 'workbench' : '')
|
||||
const payloadIdIsBusinessNo = isBusinessDocumentReference(payloadId)
|
||||
const fallbackClaimId = payloadClaimId || (payloadClaimNo || payloadIdIsBusinessNo ? '' : payloadId || requestId)
|
||||
const fallbackClaimNo = payloadClaimNo || (payloadIdIsBusinessNo ? payloadId : fallbackClaimId ? '' : requestId)
|
||||
|
||||
@@ -265,7 +265,6 @@ import {
|
||||
fetchAllApprovalExpenseClaims,
|
||||
fetchAllArchivedExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import { countClaimRisks, resolveArchiveRiskTone } from '../utils/archiveCenterListFilters.js'
|
||||
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
|
||||
import {
|
||||
buildDocumentViewedStatePatch,
|
||||
@@ -279,88 +278,16 @@ import {
|
||||
readViewedDocumentKeys,
|
||||
writeDocumentScope
|
||||
} from '../utils/documentCenterNewState.js'
|
||||
import { sortDocumentRowsByLatestTime } from '../utils/documentCenterSort.js'
|
||||
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
|
||||
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
|
||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||
const DOCUMENT_TYPE_ALL = 'all'
|
||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
const SCENE_ALL = 'all'
|
||||
const DOCUMENT_SCOPE_ALL = '全部'
|
||||
const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
||||
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
||||
const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
|
||||
const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
|
||||
const DOCUMENT_CENTER_QUERY_KEYS = new Set([
|
||||
'dc_page',
|
||||
'dc_page_size',
|
||||
'dc_scope',
|
||||
'dc_status',
|
||||
'dc_doc_type',
|
||||
'dc_scene',
|
||||
'dc_q',
|
||||
'dc_start',
|
||||
'dc_end'
|
||||
])
|
||||
const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
|
||||
const RISK_TONE_META = {
|
||||
high: { label: '高风险', tone: 'high' },
|
||||
medium: { label: '中风险', tone: 'medium' },
|
||||
low: { label: '低风险', tone: 'low' },
|
||||
none: { label: '无风险', tone: 'none' }
|
||||
}
|
||||
const FILTER_CONFIG_BY_SCOPE = {
|
||||
[DOCUMENT_SCOPE_ALL]: {
|
||||
searchPlaceholder: '搜索单号、事项、费用场景...',
|
||||
sceneFallbackLabel: '单据场景',
|
||||
dateLabel: '单据时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: true
|
||||
},
|
||||
[DOCUMENT_SCOPE_APPLICATION]: {
|
||||
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
|
||||
sceneFallbackLabel: '申请场景',
|
||||
dateLabel: '申请时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_REIMBURSEMENT]: {
|
||||
searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
|
||||
sceneFallbackLabel: '费用场景',
|
||||
dateLabel: '报销时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_REVIEW]: {
|
||||
searchPlaceholder: '搜索审核单号、事项、当前环节...',
|
||||
sceneFallbackLabel: '审核场景',
|
||||
dateLabel: '审核时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_ARCHIVE]: {
|
||||
searchPlaceholder: '搜索归档单号、事项、费用场景...',
|
||||
sceneFallbackLabel: '归档场景',
|
||||
dateLabel: '归档时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
}
|
||||
}
|
||||
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
const pageSizeValues = pageSizeOptions.map((item) => item.value)
|
||||
const documentTypeOptions = [
|
||||
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
|
||||
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
|
||||
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
|
||||
]
|
||||
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
|
||||
import {
|
||||
DOCUMENT_CENTER_QUERY_KEYS, DOCUMENT_LOADING_MIN_VISIBLE_MS, DOCUMENT_SCOPE_ALL,
|
||||
DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_ARCHIVE, DOCUMENT_SCOPE_REIMBURSEMENT,
|
||||
DOCUMENT_SCOPE_REVIEW, DOCUMENT_TYPE_ALL, DOCUMENT_TYPE_APPLICATION,
|
||||
DOCUMENT_TYPE_REIMBURSEMENT, FILTER_CONFIG_BY_SCOPE, SCENE_ALL,
|
||||
buildDocumentCenterEmptyState, buildDocumentRow, documentTypeOptions,
|
||||
filterDocumentRows, hasDocumentCenterActiveFilters, mergeDocumentRows,
|
||||
pageSizeOptions, pageSizeValues, routeQueryEquals, scopeTabs
|
||||
} from '../utils/documentCenterViewModel.js'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
@@ -440,14 +367,6 @@ function buildDocumentCenterRouteQuery() {
|
||||
return nextQuery
|
||||
}
|
||||
|
||||
function routeQueryEquals(left, right) {
|
||||
const leftEntries = Object.entries(left || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
|
||||
const rightEntries = Object.entries(right || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
|
||||
if (leftEntries.length !== rightEntries.length) return false
|
||||
const rightMap = new Map(rightEntries)
|
||||
return leftEntries.every(([key, value]) => rightMap.get(key) === value)
|
||||
}
|
||||
|
||||
const initialScopeTab = resolveInitialScopeTab()
|
||||
const initialAppliedStart = readDocumentCenterQueryText('dc_start')
|
||||
const initialAppliedEnd = readDocumentCenterQueryText('dc_end')
|
||||
@@ -494,7 +413,11 @@ const dateRangeLabel = computed(() => {
|
||||
const ownedRows = computed(() =>
|
||||
excludeArchivedDocumentRows(
|
||||
props.filteredRequests
|
||||
.map((item) => buildDocumentRow(item, { source: 'owned' }))
|
||||
.map((item) => buildDocumentRow(item, {
|
||||
source: 'owned',
|
||||
currentUser: currentUser.value,
|
||||
viewedDocumentKeys: viewedDocumentKeys.value
|
||||
}))
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
@@ -570,33 +493,16 @@ const statusFilterLabel = computed(() =>
|
||||
statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部风险'
|
||||
)
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = listKeyword.value.trim().toLowerCase()
|
||||
|
||||
return sortDocumentRowsByLatestTime(activeScopeRows.value.filter((row) => {
|
||||
const matchesKeyword = !keyword || [
|
||||
row.documentNo,
|
||||
row.documentTypeLabel,
|
||||
row.typeLabel,
|
||||
row.initiatorName,
|
||||
row.reason,
|
||||
row.node,
|
||||
row.statusLabel,
|
||||
row.riskLabel
|
||||
].filter(Boolean).join('').toLowerCase().includes(keyword)
|
||||
|
||||
const matchesDocumentType =
|
||||
!showDocumentTypeFilter.value
|
||||
|| activeDocumentType.value === DOCUMENT_TYPE_ALL
|
||||
|| row.documentTypeCode === activeDocumentType.value
|
||||
|
||||
const matchesScene = activeScene.value === SCENE_ALL || row.typeCode === activeScene.value
|
||||
const matchesRiskLevel = matchesRiskLevelTab(row, activeStatusTab.value)
|
||||
const matchesDateRange = matchesAppliedDateRange(row)
|
||||
|
||||
return matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange
|
||||
}))
|
||||
})
|
||||
const filteredRows = computed(() => filterDocumentRows(activeScopeRows.value, {
|
||||
keyword: listKeyword.value,
|
||||
showDocumentTypeFilter: showDocumentTypeFilter.value,
|
||||
activeDocumentType: activeDocumentType.value,
|
||||
activeScene: activeScene.value,
|
||||
activeStatusTab: activeStatusTab.value,
|
||||
activeScopeTab: activeScopeTab.value,
|
||||
appliedStart: appliedStart.value,
|
||||
appliedEnd: appliedEnd.value
|
||||
}))
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
|
||||
const pageSummary = computed(() => `共 ${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`)
|
||||
@@ -636,229 +542,22 @@ const documentSummary = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const emptyState = computed(() => {
|
||||
const filtered = hasActiveFilters()
|
||||
if (
|
||||
activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION
|
||||
|| activeDocumentType.value === DOCUMENT_TYPE_APPLICATION
|
||||
) {
|
||||
return {
|
||||
eyebrow: '申请单',
|
||||
title: '当前还没有申请单数据',
|
||||
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
|
||||
icon: 'mdi mdi-file-sign-outline',
|
||||
actionLabel: '',
|
||||
actionIcon: '',
|
||||
tone: 'theme',
|
||||
artLabel: 'APPLY',
|
||||
tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: filtered ? '筛选结果为空' : '单据中心',
|
||||
title: filtered ? '没有符合当前条件的单据' : `“${activeScopeTab.value}”里暂时没有单据`,
|
||||
desc: filtered
|
||||
? '可以清空当前分类下的筛选条件后再看看。'
|
||||
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
|
||||
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
|
||||
actionLabel: '',
|
||||
actionIcon: '',
|
||||
tone: 'theme',
|
||||
artLabel: filtered ? 'FILTER' : 'DOCS',
|
||||
tips: ['单据中心已接入当前报销单据', '归档视角会同步已归档数据']
|
||||
}
|
||||
})
|
||||
|
||||
function resolveArchivedDocumentNode(normalized, documentTypeCode) {
|
||||
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
|
||||
return '申请归档'
|
||||
}
|
||||
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款') {
|
||||
return '已付款'
|
||||
}
|
||||
return normalized.node || normalized.workflowNode || '财务归档'
|
||||
}
|
||||
|
||||
function resolveArchivedStatusLabel(normalized) {
|
||||
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款' || normalized.node === '已付款') {
|
||||
return '已付款'
|
||||
}
|
||||
return '已归档'
|
||||
}
|
||||
|
||||
function buildDocumentRow(request, options = {}) {
|
||||
const normalized = normalizeRequestForUi(request)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
const archived = Boolean(options.archived)
|
||||
const statusGroup = resolveStatusGroup(normalized, archived)
|
||||
const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
|
||||
const riskMeta = buildDocumentRiskMeta(normalized)
|
||||
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
|
||||
const claimId = normalized.claimId || normalized.id || documentNo
|
||||
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
|
||||
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
|
||||
const createdSortTime = resolveDocumentSortTime(createdAtSource)
|
||||
const updatedSortTime = resolveDocumentSortTime(updatedAtSource)
|
||||
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
||||
const documentTypeLabel =
|
||||
normalized.documentTypeLabel
|
||||
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
||||
const initiatorName = String(
|
||||
normalized.person
|
||||
|| normalized.employeeName
|
||||
|| normalized.profileName
|
||||
|| normalized.applicant
|
||||
|| request?.employee_name
|
||||
|| request?.employeeName
|
||||
|| request?.person
|
||||
|| ''
|
||||
).trim() || '待补充'
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
rawRequest: request,
|
||||
documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
|
||||
documentTypeCode,
|
||||
documentTypeLabel,
|
||||
claimId,
|
||||
documentNo,
|
||||
initiatorName,
|
||||
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
|
||||
statusGroup,
|
||||
statusLabel,
|
||||
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
|
||||
riskTone: riskMeta.tone,
|
||||
riskLabel: riskMeta.label,
|
||||
riskCount: riskMeta.count,
|
||||
riskTags: riskMeta.tags,
|
||||
source: options.source || 'owned',
|
||||
archived,
|
||||
createdAtDisplay: formatDocumentListTime(createdAtSource),
|
||||
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
|
||||
isNewDocument: archived
|
||||
? false
|
||||
: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
|
||||
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
|
||||
createdSortTime,
|
||||
updatedSortTime,
|
||||
sortTime: Math.max(createdSortTime, updatedSortTime)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusGroup(row, archived) {
|
||||
if (archived) return 'completed'
|
||||
if (row.approvalKey === 'draft') return 'draft'
|
||||
if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
|
||||
if (row.approvalKey === 'supplement') return 'supplement'
|
||||
if (row.approvalKey === 'pending_payment') return 'pending_payment'
|
||||
if (row.approvalKey === 'in_progress') return 'in_progress'
|
||||
if (row.approvalKey === 'completed') return 'completed'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function resolveStatusLabel(row, statusGroup) {
|
||||
if (statusGroup === 'pending_submit') return '待提交'
|
||||
if (statusGroup === 'pending_payment') return '待付款'
|
||||
return row.approval || row.approvalStatus || '处理中'
|
||||
}
|
||||
|
||||
function resolveStatusTone(row, statusGroup) {
|
||||
if (statusGroup === 'pending_submit') return 'warning'
|
||||
return row.approvalTone || 'neutral'
|
||||
}
|
||||
|
||||
function resolveDocumentRiskFlags(row) {
|
||||
if (Array.isArray(row?.riskFlags)) {
|
||||
return row.riskFlags
|
||||
}
|
||||
if (Array.isArray(row?.risk_flags_json)) {
|
||||
return row.risk_flags_json
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function buildDocumentRiskMeta(row) {
|
||||
const riskFlags = resolveDocumentRiskFlags(row)
|
||||
const riskSummary = row?.riskSummary || row?.risk
|
||||
// 列表风险标签按当前查看者可见性过滤,与详情页口径一致:
|
||||
// 申请人看不到的预算治理等风险不计入列表展示的风险等级。
|
||||
const viewerOptions = currentUser.value
|
||||
? { request: row || {}, currentUser: currentUser.value }
|
||||
: null
|
||||
const count = countClaimRisks(riskFlags, riskSummary, viewerOptions)
|
||||
if (!count) {
|
||||
const meta = RISK_TONE_META.none
|
||||
return {
|
||||
...meta,
|
||||
count: 0,
|
||||
tags: [{ ...meta }]
|
||||
}
|
||||
}
|
||||
|
||||
const tone = resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions)
|
||||
const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium
|
||||
return {
|
||||
...meta,
|
||||
count,
|
||||
tags: [{ tone: meta.tone, label: `${meta.label} ${count}项` }]
|
||||
}
|
||||
}
|
||||
|
||||
function matchesRiskLevelTab(row, tab) {
|
||||
if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (tab === '全部') return true
|
||||
if (tab === '高风险') return row.riskTone === 'high'
|
||||
if (tab === '中风险') return row.riskTone === 'medium'
|
||||
if (tab === '低风险') return row.riskTone === 'low'
|
||||
if (tab === '无风险') return row.riskTone === 'none'
|
||||
return true
|
||||
}
|
||||
|
||||
function matchesAppliedDateRange(row) {
|
||||
if (!appliedStart.value || !appliedEnd.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
|
||||
return Boolean(date) && date >= appliedStart.value && date <= appliedEnd.value
|
||||
}
|
||||
|
||||
function mergeDocumentRows(rows) {
|
||||
const rowMap = new Map()
|
||||
|
||||
rows.filter(Boolean).forEach((row) => {
|
||||
const key = row.claimId || row.documentNo || row.documentKey
|
||||
const current = rowMap.get(key)
|
||||
if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
|
||||
rowMap.set(key, row)
|
||||
}
|
||||
})
|
||||
|
||||
return sortDocumentRowsByLatestTime(Array.from(rowMap.values()))
|
||||
}
|
||||
|
||||
function resolveSourcePriority(row) {
|
||||
if (row.archived) return 3
|
||||
if (row.source === 'approval') return 2
|
||||
return 1
|
||||
}
|
||||
const emptyState = computed(() => buildDocumentCenterEmptyState({
|
||||
hasActiveFilters: hasActiveFilters(),
|
||||
activeScopeTab: activeScopeTab.value,
|
||||
activeDocumentType: activeDocumentType.value
|
||||
}))
|
||||
|
||||
function hasActiveFilters() {
|
||||
return Boolean(
|
||||
listKeyword.value.trim()
|
||||
|| activeStatusTab.value !== '全部'
|
||||
|| (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL)
|
||||
|| activeScene.value !== SCENE_ALL
|
||||
|| appliedStart.value
|
||||
|| appliedEnd.value
|
||||
)
|
||||
return hasDocumentCenterActiveFilters({
|
||||
listKeyword: listKeyword.value,
|
||||
activeStatusTab: activeStatusTab.value,
|
||||
showDocumentTypeFilter: showDocumentTypeFilter.value,
|
||||
activeDocumentType: activeDocumentType.value,
|
||||
activeScene: activeScene.value,
|
||||
appliedStart: appliedStart.value,
|
||||
appliedEnd: appliedEnd.value
|
||||
})
|
||||
}
|
||||
|
||||
function toggleFilter(key) {
|
||||
@@ -993,7 +692,11 @@ async function loadSupportingRows() {
|
||||
approvalRows.value = excludeArchivedDocumentRows(
|
||||
extractExpenseClaimItems(approvalResult.value)
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, { source: 'approval' }))
|
||||
.map((item) => buildDocumentRow(item, {
|
||||
source: 'approval',
|
||||
currentUser: currentUser.value,
|
||||
viewedDocumentKeys: viewedDocumentKeys.value
|
||||
}))
|
||||
.filter(Boolean)
|
||||
)
|
||||
} else {
|
||||
@@ -1003,7 +706,12 @@ async function loadSupportingRows() {
|
||||
if (archiveResult.status === 'fulfilled') {
|
||||
archiveRows.value = extractExpenseClaimItems(archiveResult.value)
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
|
||||
.map((item) => buildDocumentRow(item, {
|
||||
source: 'archive',
|
||||
archived: true,
|
||||
currentUser: currentUser.value,
|
||||
viewedDocumentKeys: viewedDocumentKeys.value
|
||||
}))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
archiveRows.value = []
|
||||
|
||||
@@ -2,112 +2,18 @@
|
||||
<section class="approval-page">
|
||||
<div class="approval-detail">
|
||||
<div class="detail-scroll">
|
||||
<article class="detail-hero panel">
|
||||
<div class="hero-banner">
|
||||
<div class="hero-banner-main">
|
||||
<div class="applicant-card">
|
||||
<div class="portrait">
|
||||
<img src="/assets/person.png" alt="" />
|
||||
</div>
|
||||
<div class="applicant-copy">
|
||||
<div class="applicant-name-row">
|
||||
<h2>{{ profile.name }}</h2>
|
||||
<span class="identity-badge">{{ profile.identity }}</span>
|
||||
</div>
|
||||
<div class="applicant-profile-meta">
|
||||
<div class="applicant-profile-meta__org">
|
||||
<span class="applicant-meta-item">
|
||||
<em>部门</em>
|
||||
<strong>{{ profile.department }}</strong>
|
||||
</span>
|
||||
<span class="applicant-meta-item applicant-meta-item--sub">
|
||||
<em>直属上司</em>
|
||||
<strong>{{ profile.manager }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="applicant-profile-meta__role">
|
||||
<span class="applicant-meta-item">
|
||||
<em>职级</em>
|
||||
<strong>{{ profile.grade }}</strong>
|
||||
</span>
|
||||
<span class="applicant-meta-item">
|
||||
<em>岗位</em>
|
||||
<strong>{{ profile.position }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-fact-grid">
|
||||
<div v-for="item in heroFactItems" :key="item.key" class="hero-fact">
|
||||
<div class="hero-fact-label">
|
||||
<i v-if="item.icon" :class="item.icon"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<strong :class="item.valueClass">{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="progress-card panel">
|
||||
<div class="progress-block">
|
||||
<div class="progress-head">
|
||||
<h3>{{ isApplicationDocument ? '申请进度' : '报销进度' }}</h3>
|
||||
</div>
|
||||
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
|
||||
<div
|
||||
v-for="step in progressSteps"
|
||||
:key="step.label"
|
||||
class="progress-step"
|
||||
:class="{ active: step.active, current: step.current, done: step.done }"
|
||||
>
|
||||
<span>
|
||||
<i
|
||||
v-if="step.current"
|
||||
v-motion
|
||||
class="current-progress-ring"
|
||||
:initial="currentProgressRingMotion.initial"
|
||||
:enter="currentProgressRingMotion.enter"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<i v-if="step.done" class="mdi mdi-check"></i>
|
||||
<template v-else>{{ step.index }}</template>
|
||||
</span>
|
||||
<div class="progress-step-copy" :title="step.title || step.detail || step.time">
|
||||
<strong>{{ step.label }}</strong>
|
||||
<small class="progress-step-status">{{ step.time }}</small>
|
||||
<em v-if="step.detail" class="progress-step-meta">{{ step.detail }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<TravelRequestDetailHero :profile="profile" :hero-fact-items="heroFactItems" />
|
||||
<TravelRequestProgressCard
|
||||
:is-application-document="isApplicationDocument"
|
||||
:progress-steps="progressSteps"
|
||||
:current-progress-ring-motion="currentProgressRingMotion"
|
||||
/>
|
||||
<div class="detail-grid">
|
||||
<section class="detail-left">
|
||||
<article v-if="!isApplicationDocument" class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
<h3>关联单据信息</h3>
|
||||
<p>展示本次报销关联的前置申请,便于核对申请内容、天数、事由和预计金额。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="relatedApplicationFactItems.length" class="application-detail-facts related-application-facts">
|
||||
<div
|
||||
v-for="item in relatedApplicationFactItems"
|
||||
:key="item.key"
|
||||
class="application-detail-fact related-application-fact"
|
||||
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="related-application-empty">
|
||||
<strong>暂未识别到关联申请单</strong>
|
||||
<p>差旅报销应先关联已审批的申请单,请核对本单据是否由申请单生成或已在智能录入中完成关联。</p>
|
||||
</div>
|
||||
</article>
|
||||
<TravelRequestRelatedApplicationCard
|
||||
:is-application-document="isApplicationDocument"
|
||||
:related-application-fact-items="relatedApplicationFactItems"
|
||||
/>
|
||||
<article class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
@@ -674,12 +580,7 @@
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<Transition name="shared-confirm">
|
||||
<div
|
||||
v-if="attachmentPreviewOpen"
|
||||
class="attachment-preview-mask"
|
||||
role="presentation"
|
||||
@click.self="closeAttachmentPreview"
|
||||
>
|
||||
<div v-if="attachmentPreviewOpen" class="attachment-preview-mask" role="presentation" @click.self="closeAttachmentPreview">
|
||||
<section class="attachment-preview-card" role="dialog" aria-modal="true" @click.stop>
|
||||
<div class="attachment-preview-head">
|
||||
<div>
|
||||
@@ -723,18 +624,8 @@
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ attachmentPreviewError }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType.startsWith('image/')"
|
||||
:src="attachmentPreviewUrl"
|
||||
:alt="attachmentPreviewName || '附件图片'"
|
||||
class="attachment-preview-image"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType === 'application/pdf'"
|
||||
:src="attachmentPreviewUrl"
|
||||
class="attachment-preview-frame"
|
||||
title="附件预览"
|
||||
></iframe>
|
||||
<img v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType.startsWith('image/')" :src="attachmentPreviewUrl" :alt="attachmentPreviewName || '附件图片'" class="attachment-preview-image" />
|
||||
<iframe v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType === 'application/pdf'" :src="attachmentPreviewUrl" class="attachment-preview-frame" title="附件预览"></iframe>
|
||||
<div v-else class="attachment-preview-state">
|
||||
<i class="mdi mdi-file-outline"></i>
|
||||
<span>当前附件暂不支持直接预览。</span>
|
||||
@@ -747,32 +638,20 @@
|
||||
</div>
|
||||
<div v-if="currentAttachmentPreviewInsight" class="attachment-insight-content">
|
||||
<div class="attachment-insight-pills">
|
||||
<span :class="['attachment-recognition-pill', currentAttachmentPreviewInsight.requirementTone]">
|
||||
{{ currentAttachmentPreviewInsight.requirementLabel }}
|
||||
</span>
|
||||
<span :class="['attachment-recognition-pill', currentAttachmentPreviewInsight.requirementTone]">{{ currentAttachmentPreviewInsight.requirementLabel }}</span>
|
||||
</div>
|
||||
<p v-if="currentAttachmentPreviewInsight.message" class="attachment-recognition-message">
|
||||
{{ currentAttachmentPreviewInsight.message }}
|
||||
</p>
|
||||
<p v-if="currentAttachmentPreviewInsight.message" class="attachment-recognition-message">{{ currentAttachmentPreviewInsight.message }}</p>
|
||||
<div v-if="currentAttachmentPreviewInsight.fields.length" class="attachment-insight-section">
|
||||
<span>字段结果</span>
|
||||
<ul>
|
||||
<li v-for="field in currentAttachmentPreviewInsight.fields" :key="field">{{ field }}</li>
|
||||
</ul>
|
||||
<ul><li v-for="field in currentAttachmentPreviewInsight.fields" :key="field">{{ field }}</li></ul>
|
||||
</div>
|
||||
<div v-if="currentAttachmentPreviewInsight.ruleBasis.length" class="attachment-insight-section">
|
||||
<span>规则依据</span>
|
||||
<ul>
|
||||
<li v-for="basis in currentAttachmentPreviewInsight.ruleBasis" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
<ul><li v-for="basis in currentAttachmentPreviewInsight.ruleBasis" :key="basis">{{ basis }}</li></ul>
|
||||
</div>
|
||||
<div v-if="currentAttachmentPreviewRiskCards.length" class="attachment-insight-section risk">
|
||||
<span>风险点</span>
|
||||
<article
|
||||
v-for="card in currentAttachmentPreviewRiskCards"
|
||||
:key="card.id"
|
||||
:class="['attachment-risk-card', card.tone]"
|
||||
>
|
||||
<article v-for="card in currentAttachmentPreviewRiskCards" :key="card.id" :class="['attachment-risk-card', card.tone]">
|
||||
<strong>{{ card.risk }}</strong>
|
||||
<p>{{ card.suggestion }}</p>
|
||||
</article>
|
||||
@@ -808,22 +687,10 @@
|
||||
@confirm="confirmSubmitRequest"
|
||||
>
|
||||
<div class="submit-confirm-summary" aria-label="提交前核对摘要">
|
||||
<div class="submit-confirm-row">
|
||||
<span>单据编号</span>
|
||||
<strong>{{ request.documentNo || request.id }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>{{ isApplicationDocument ? '申请类型' : '报销类型' }}</span>
|
||||
<strong>{{ request.typeLabel }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
|
||||
<strong>{{ submitConfirmAmountDisplay }}</strong>
|
||||
</div>
|
||||
<div v-if="!isApplicationDocument" class="submit-confirm-row">
|
||||
<span>费用明细</span>
|
||||
<strong>{{ expenseItems.length }} 条 / {{ uploadedExpenseCount }} 张单据</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row"><span>单据编号</span><strong>{{ request.documentNo || request.id }}</strong></div>
|
||||
<div class="submit-confirm-row"><span>{{ isApplicationDocument ? '申请类型' : '报销类型' }}</span><strong>{{ request.typeLabel }}</strong></div>
|
||||
<div class="submit-confirm-row"><span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span><strong>{{ submitConfirmAmountDisplay }}</strong></div>
|
||||
<div v-if="!isApplicationDocument" class="submit-confirm-row"><span>费用明细</span><strong>{{ expenseItems.length }} 条 / {{ uploadedExpenseCount }} 张单据</strong></div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
@@ -924,223 +791,4 @@
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-responsive.css"></style>
|
||||
|
||||
<style>
|
||||
/* 强力锁定表格中输入框的高度,解决 scoped 模式下有前缀的 Element Plus 子组件无法被 :deep 成功匹配的局限性 */
|
||||
.detail-expense-table .editor-control .el-input__wrapper,
|
||||
.detail-expense-table .editor-control .el-select__wrapper,
|
||||
.detail-expense-table .editor-select .el-select__wrapper,
|
||||
.detail-expense-table .editor-date-picker .el-input__wrapper {
|
||||
box-sizing: border-box !important;
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-control:not(.risk-note-editor-input),
|
||||
.detail-expense-table .editor-date-picker.editor-control,
|
||||
.detail-expense-table .editor-select {
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__wrapper {
|
||||
gap: 4px !important;
|
||||
padding-right: 7px !important;
|
||||
padding-left: 7px !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__inner,
|
||||
.detail-expense-table .editor-input-control.editor-control .el-input__inner,
|
||||
.detail-expense-table .editor-select .el-select__selected-item,
|
||||
.detail-expense-table .editor-select .el-select__placeholder {
|
||||
height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix {
|
||||
flex: 0 0 14px !important;
|
||||
width: 14px !important;
|
||||
min-width: 14px !important;
|
||||
margin: 0 !important;
|
||||
color: #94a3b8 !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
|
||||
height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner {
|
||||
width: 14px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__wrapper {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__inner {
|
||||
height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix,
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix {
|
||||
min-height: var(--expense-editor-control-height, 34px) !important;
|
||||
height: var(--expense-editor-control-height, 34px) !important;
|
||||
}
|
||||
|
||||
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
|
||||
height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
line-height: var(--expense-editor-control-line-height, 16px) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper.el-popper {
|
||||
border: 1px solid rgba(148, 163, 184, .32) !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, .14) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-picker-panel {
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
color: #334155 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-picker__header {
|
||||
height: 38px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 10px !important;
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-picker-panel__icon-btn {
|
||||
appearance: none !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
margin: 0 1px !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: transparent !important;
|
||||
color: #64748b !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
transition: background-color 160ms var(--ease), color 160ms var(--ease) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-picker-panel__icon-btn:hover {
|
||||
background: var(--theme-primary-soft) !important;
|
||||
color: var(--theme-primary-active) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-picker__header-label {
|
||||
color: #0f172a !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 800 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-picker-panel__content {
|
||||
margin: 8px 10px 10px !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table th {
|
||||
border-bottom: 1px solid #edf2f7 !important;
|
||||
color: #64748b !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 800 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td {
|
||||
width: 32px !important;
|
||||
height: 30px !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td .el-date-table-cell {
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td .el-date-table-cell__text {
|
||||
width: 26px !important;
|
||||
height: 26px !important;
|
||||
border-radius: 4px !important;
|
||||
color: #334155 !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 26px !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.available:hover .el-date-table-cell__text {
|
||||
background: var(--theme-primary-soft) !important;
|
||||
color: var(--theme-primary-active) !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.today .el-date-table-cell__text {
|
||||
color: var(--theme-primary-active) !important;
|
||||
font-weight: 850 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.current .el-date-table-cell__text,
|
||||
.detail-editor-date-popper .el-date-table td.selected .el-date-table-cell__text {
|
||||
background: var(--theme-primary) !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 850 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.prev-month .el-date-table-cell__text,
|
||||
.detail-editor-date-popper .el-date-table td.next-month .el-date-table-cell__text {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.detail-editor-date-popper .el-date-table td.disabled .el-date-table-cell__text {
|
||||
background: #f8fafc !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
</style>
|
||||
<style src="../assets/styles/views/travel-request-detail-date-popper.css"></style>
|
||||
|
||||
@@ -9,10 +9,10 @@ import AuditVersionTimelineDrawer from '../../components/audit/AuditVersionTimel
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { isFinanceUser, isManagerUser, isPlatformAdminUser } from '../../utils/accessControl.js'
|
||||
import { useAuditAssetData } from './useAuditAssetData.js'
|
||||
import { buildAuditDetailTopBar } from './auditViewDetailTopBar.js'
|
||||
import { useAuditListFilters } from './auditViewListFilters.js'
|
||||
import { useAuditViewPermissions } from './useAuditViewPermissions.js'
|
||||
import { useAuditRuleReviewFlow } from './useAuditRuleReviewFlow.js'
|
||||
import { useAuditRuleVersionActions } from './useAuditRuleVersionActions.js'
|
||||
import { useAuditRiskRuleActions } from './useAuditRiskRuleActions.js'
|
||||
@@ -74,9 +74,6 @@ export default {
|
||||
{ label: '否', value: false }
|
||||
]
|
||||
|
||||
const isAdmin = computed(() => isPlatformAdminUser(currentUser.value))
|
||||
const isRuleManager = computed(() => isManagerUser(currentUser.value))
|
||||
const isFinance = computed(() => isFinanceUser(currentUser.value))
|
||||
const activeMeta = computed(() => TAB_META[activeType.value])
|
||||
const activeTabLabel = computed(() => activeMeta.value.label)
|
||||
const searchPlaceholder = computed(() => activeMeta.value.searchPlaceholder)
|
||||
@@ -89,117 +86,41 @@ export default {
|
||||
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
|
||||
const showOnlineColumn = computed(() => false)
|
||||
const showEnabledColumn = computed(() => activeMeta.value.showEnabledColumn === true)
|
||||
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
|
||||
const selectedSkillUsesSpreadsheet = computed(
|
||||
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
|
||||
)
|
||||
const selectedSkillUsesJsonRisk = computed(
|
||||
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
|
||||
)
|
||||
const canManageSelected = computed(
|
||||
() => isRuleManager.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
|
||||
)
|
||||
const canAdminOperateSelected = computed(
|
||||
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
|
||||
)
|
||||
const canEditSelected = computed(
|
||||
() =>
|
||||
Boolean(selectedSkill.value) &&
|
||||
!selectedSkill.value?.isPreviewMock &&
|
||||
(isAdmin.value || isFinance.value)
|
||||
)
|
||||
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
|
||||
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
|
||||
const riskRuleInReview = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'review'
|
||||
)
|
||||
const riskRuleGenerationBusy = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'generating'
|
||||
)
|
||||
const riskRuleGenerationFailed = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'failed'
|
||||
)
|
||||
const canOpenRiskRuleTest = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
canAdminOperateSelected.value &&
|
||||
Boolean(selectedSkill.value?.id) &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!riskRuleGenerationFailed.value
|
||||
)
|
||||
const canDeleteRiskRule = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
canAdminOperateSelected.value &&
|
||||
Boolean(selectedSkill.value?.id) &&
|
||||
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
|
||||
)
|
||||
const canOpenRiskRuleReviewSubmit = computed(() => false)
|
||||
const canSubmitRiskRuleReview = computed(
|
||||
() =>
|
||||
canOpenRiskRuleReviewSubmit.value &&
|
||||
riskRuleTestPassed.value
|
||||
)
|
||||
const canReturnRiskRule = computed(() => false)
|
||||
const riskRuleHasPublishableRevision = computed(() => {
|
||||
const revision = selectedSkill.value?.configJson?.revision_draft
|
||||
return selectedSkillUsesJsonRisk.value && revision &&
|
||||
revision.generation_status === 'completed' &&
|
||||
normalizeText(selectedSkill.value?.workingVersion).replace('-', '') &&
|
||||
selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion
|
||||
const {
|
||||
detailBusy,
|
||||
isAdmin,
|
||||
selectedSkillIsRule,
|
||||
selectedSkillUsesSpreadsheet,
|
||||
selectedSkillUsesJsonRisk,
|
||||
canManageSelected,
|
||||
canAdminOperateSelected,
|
||||
canEditSelected,
|
||||
latestRiskRuleTestSummary,
|
||||
riskRuleTestPassed,
|
||||
riskRuleInReview,
|
||||
riskRuleGenerationBusy,
|
||||
riskRuleGenerationFailed,
|
||||
canOpenRiskRuleTest,
|
||||
canDeleteRiskRule,
|
||||
canOpenRiskRuleReviewSubmit,
|
||||
canSubmitRiskRuleReview,
|
||||
canReturnRiskRule,
|
||||
riskRuleHasPublishableRevision,
|
||||
canPublishRiskRule,
|
||||
canToggleRiskRuleEnabled,
|
||||
canEditRiskRuleDraft,
|
||||
canCreateRiskRuleRevision,
|
||||
canEditMarkdown,
|
||||
isDisplayingWorkingVersion,
|
||||
canUploadSpreadsheet,
|
||||
canDownloadSpreadsheet,
|
||||
canEditSpreadsheetInline,
|
||||
selectedSpreadsheetFileName
|
||||
} = useAuditViewPermissions({
|
||||
currentUser,
|
||||
selectedSkill,
|
||||
actionState
|
||||
})
|
||||
const canPublishRiskRule = computed(
|
||||
() =>
|
||||
Boolean(riskRuleHasPublishableRevision.value) &&
|
||||
canManageSelected.value &&
|
||||
riskRuleTestPassed.value &&
|
||||
!detailBusy.value
|
||||
)
|
||||
const canToggleRiskRuleEnabled = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
|
||||
)
|
||||
const canEditRiskRuleDraft = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
(canEditSelected.value || canManageSelected.value) &&
|
||||
!detailBusy.value &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
|
||||
)
|
||||
const canCreateRiskRuleRevision = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
(canEditSelected.value || canManageSelected.value) &&
|
||||
!detailBusy.value &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!riskRuleGenerationFailed.value &&
|
||||
Boolean(normalizeText(selectedSkill.value?.publishedVersion).replace('-', ''))
|
||||
)
|
||||
const canEditMarkdown = computed(() => selectedSkillIsRule.value && canEditSelected.value)
|
||||
const isDisplayingWorkingVersion = computed(
|
||||
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
||||
)
|
||||
const canUploadSpreadsheet = computed(
|
||||
() =>
|
||||
canEditSelected.value &&
|
||||
selectedSkillUsesSpreadsheet.value &&
|
||||
!detailBusy.value
|
||||
)
|
||||
const canDownloadSpreadsheet = computed(
|
||||
() =>
|
||||
selectedSkillUsesSpreadsheet.value &&
|
||||
Boolean(selectedSkill.value?.id) &&
|
||||
!detailBusy.value
|
||||
)
|
||||
const canEditSpreadsheetInline = computed(
|
||||
() =>
|
||||
selectedSkillUsesSpreadsheet.value &&
|
||||
(selectedSkill.value?.isPreviewMock || canEditSelected.value)
|
||||
)
|
||||
const selectedSpreadsheetFileName = computed(
|
||||
() =>
|
||||
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
|
||||
)
|
||||
const {
|
||||
versionSwitchTarget,
|
||||
versionTimelineOpen,
|
||||
@@ -228,7 +149,6 @@ export default {
|
||||
actionState,
|
||||
toast
|
||||
})
|
||||
const detailBusy = computed(() => Boolean(actionState.value))
|
||||
const {
|
||||
loading,
|
||||
errorMessage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
586
web/src/views/scripts/employeeManagementModel.js
Normal file
586
web/src/views/scripts/employeeManagementModel.js
Normal file
@@ -0,0 +1,586 @@
|
||||
import {
|
||||
appendEmployeeBankUpdatePayload,
|
||||
createEmployeeBankFormFields,
|
||||
getEmployeeBankSearchFields,
|
||||
mapEmployeeBankFormFields
|
||||
} from './employeeBankFields.js'
|
||||
|
||||
export const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
|
||||
export const FALLBACK_ROLE_OPTIONS = [
|
||||
{
|
||||
id: 'manager',
|
||||
code: 'manager',
|
||||
label: '管理员',
|
||||
desc: '可以维护员工档案、组织结构和角色权限。'
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
code: 'finance',
|
||||
label: '财务人员',
|
||||
desc: '可以处理复核、查看财务知识与风险校验结果。'
|
||||
},
|
||||
{
|
||||
id: 'approver',
|
||||
code: 'approver',
|
||||
label: '审批负责人',
|
||||
desc: '可以处理单据中心中的待审单据。'
|
||||
},
|
||||
{
|
||||
id: 'executive',
|
||||
code: 'executive',
|
||||
label: '高级财务人员',
|
||||
desc: '可以查看跨部门预算、经营看板与关键财务审批结果。'
|
||||
},
|
||||
{
|
||||
id: 'budget_monitor',
|
||||
code: 'budget_monitor',
|
||||
label: '预算监控员',
|
||||
desc: '可以查看本部门预算执行、预警和占用情况。'
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
code: 'user',
|
||||
label: '使用者',
|
||||
desc: '可以发起费用申请、报销、查看个人单据和使用 AI 助手。'
|
||||
}
|
||||
]
|
||||
|
||||
export function createEmployeeForm() {
|
||||
return {
|
||||
name: '',
|
||||
employeeNo: '',
|
||||
gender: '',
|
||||
age: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
joinDate: '',
|
||||
location: '',
|
||||
position: '',
|
||||
grade: '',
|
||||
department: '',
|
||||
organizationUnitCode: '',
|
||||
manager: '',
|
||||
managerEmployeeNo: '',
|
||||
financeOwner: '',
|
||||
costCenter: '',
|
||||
...createEmployeeBankFormFields(),
|
||||
roleCodes: [],
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlaceholderManagerName(name) {
|
||||
const normalized = normalizeText(name)
|
||||
return !normalized || normalized === 'CEO' || normalized === '无'
|
||||
}
|
||||
|
||||
export function resolveManagerEmployeeNo(employee, roster = []) {
|
||||
const fromApi = normalizeText(employee?.managerEmployeeNo)
|
||||
if (fromApi) {
|
||||
return fromApi
|
||||
}
|
||||
|
||||
const managerName = normalizeText(employee?.manager)
|
||||
if (isPlaceholderManagerName(managerName)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const matches = roster.filter((item) => normalizeText(item.name) === managerName)
|
||||
if (matches.length === 1) {
|
||||
return matches[0].employeeNo
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function enrichEmployeeRecord(employee, roster = []) {
|
||||
if (!employee) {
|
||||
return employee
|
||||
}
|
||||
|
||||
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
|
||||
if (!managerEmployeeNo || managerEmployeeNo === employee.managerEmployeeNo) {
|
||||
return employee
|
||||
}
|
||||
|
||||
return {
|
||||
...employee,
|
||||
managerEmployeeNo
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeEmployeeRecords(listItem, detailItem, roster = []) {
|
||||
if (!listItem && !detailItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!listItem) {
|
||||
return enrichEmployeeRecord(detailItem, roster)
|
||||
}
|
||||
|
||||
if (!detailItem) {
|
||||
return enrichEmployeeRecord(listItem, roster)
|
||||
}
|
||||
|
||||
const managerEmployeeNo =
|
||||
normalizeText(detailItem.managerEmployeeNo) ||
|
||||
normalizeText(listItem.managerEmployeeNo) ||
|
||||
resolveManagerEmployeeNo(detailItem, roster) ||
|
||||
resolveManagerEmployeeNo(listItem, roster)
|
||||
|
||||
const history =
|
||||
Array.isArray(detailItem.history) && detailItem.history.length
|
||||
? detailItem.history
|
||||
: listItem.history || []
|
||||
|
||||
const permissions =
|
||||
Array.isArray(detailItem.permissions) && detailItem.permissions.length
|
||||
? detailItem.permissions
|
||||
: listItem.permissions || []
|
||||
|
||||
return enrichEmployeeRecord(
|
||||
{
|
||||
...listItem,
|
||||
...detailItem,
|
||||
manager: detailItem.manager || listItem.manager,
|
||||
managerEmployeeNo: managerEmployeeNo || null,
|
||||
history,
|
||||
permissions,
|
||||
roleCodes: detailItem.roleCodes?.length ? detailItem.roleCodes : listItem.roleCodes,
|
||||
roles: detailItem.roles?.length ? detailItem.roles : listItem.roles,
|
||||
organization: detailItem.organization || listItem.organization,
|
||||
department: detailItem.department || listItem.department
|
||||
},
|
||||
roster
|
||||
)
|
||||
}
|
||||
|
||||
export function buildEmployeeForm(employee, roster = []) {
|
||||
if (!employee) {
|
||||
return createEmployeeForm()
|
||||
}
|
||||
|
||||
const birthDate = employee.birthDate || ''
|
||||
const managerName = employee.manager || ''
|
||||
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
|
||||
|
||||
return {
|
||||
name: employee.name || '',
|
||||
employeeNo: employee.employeeNo || '',
|
||||
gender: employee.gender || '',
|
||||
age:
|
||||
employee.age !== null && employee.age !== undefined && employee.age !== ''
|
||||
? String(employee.age)
|
||||
: calculateAgeFromDate(birthDate),
|
||||
birthDate,
|
||||
phone: employee.phone || '',
|
||||
email: employee.email || '',
|
||||
joinDate: employee.joinDate || '',
|
||||
location: employee.location || '',
|
||||
position: employee.position || '',
|
||||
grade: employee.grade || '',
|
||||
department: resolveOrganizationUnitName(employee),
|
||||
organizationUnitCode: resolveOrganizationUnitCode(employee),
|
||||
manager: managerName,
|
||||
managerEmployeeNo,
|
||||
financeOwner: employee.financeOwner || '',
|
||||
costCenter: employee.costCenter || '',
|
||||
...mapEmployeeBankFormFields(employee),
|
||||
roleCodes: [...(employee.roleCodes || [])],
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
export function normalizeNullableText(value) {
|
||||
const text = normalizeText(value)
|
||||
return text || null
|
||||
}
|
||||
|
||||
export function isValidEmail(value) {
|
||||
const normalized = normalizeText(value)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(normalized)
|
||||
}
|
||||
|
||||
export function isValidIsoDate(value) {
|
||||
const normalized = normalizeText(value)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/u.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = normalized.split('-')
|
||||
const year = Number.parseInt(yearText, 10)
|
||||
const month = Number.parseInt(monthText, 10)
|
||||
const day = Number.parseInt(dayText, 10)
|
||||
|
||||
if ([year, month, day].some((item) => Number.isNaN(item))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsed = new Date(year, month - 1, day)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.getFullYear() === year &&
|
||||
parsed.getMonth() === month - 1 &&
|
||||
parsed.getDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
export function sameValues(left, right) {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
export function formatEmployeeHistoryTime(value) {
|
||||
const raw = normalizeText(value)
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const chineseMatched = raw.match(
|
||||
/^(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2})时(\d{1,2})分(?:\d{1,2}秒)?$/
|
||||
)
|
||||
if (chineseMatched) {
|
||||
const [, year, month, day, hour, minute] = chineseMatched
|
||||
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
|
||||
}
|
||||
|
||||
const isoMatched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2}))?/)
|
||||
if (isoMatched) {
|
||||
const [, year, month, day, hour = '0', minute = '0'] = isoMatched
|
||||
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
|
||||
}
|
||||
|
||||
return raw.replace(/(\d{1,2}分)\d{1,2}秒$/, '$1')
|
||||
}
|
||||
|
||||
export function resolveOrganizationUnitCode(employee) {
|
||||
return normalizeText(employee?.organization?.code)
|
||||
}
|
||||
|
||||
export function resolveOrganizationUnitName(employee) {
|
||||
return normalizeText(employee?.department) || normalizeText(employee?.organization?.name)
|
||||
}
|
||||
|
||||
export function captureEmployeeDetailSnapshot(form) {
|
||||
return {
|
||||
roleCodes: [...(form.roleCodes || [])].sort(),
|
||||
organizationUnitCode: normalizeText(form.organizationUnitCode) || ''
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrganizationOptions(metaOrganizations) {
|
||||
if (!Array.isArray(metaOrganizations) || !metaOrganizations.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return metaOrganizations
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
unitType: item.unitType,
|
||||
label: `${item.name}(${item.code})`
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||
}
|
||||
|
||||
export function calculateAgeFromDate(dateString) {
|
||||
if (!dateString) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const birthDate = new Date(`${dateString}T00:00:00`)
|
||||
if (Number.isNaN(birthDate.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthDate.getFullYear()
|
||||
const hasBirthdayPassed =
|
||||
today.getMonth() > birthDate.getMonth() ||
|
||||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate())
|
||||
|
||||
if (!hasBirthdayPassed) {
|
||||
age -= 1
|
||||
}
|
||||
|
||||
return age >= 0 ? String(age) : ''
|
||||
}
|
||||
|
||||
export function calculateBirthDateFromAge(ageValue, existingBirthDate = '') {
|
||||
const age = Number.parseInt(String(ageValue ?? '').trim(), 10)
|
||||
if (Number.isNaN(age) || age < 0 || age > 120) {
|
||||
return existingBirthDate || ''
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
let month = '01'
|
||||
let day = '01'
|
||||
|
||||
if (existingBirthDate && isValidIsoDate(existingBirthDate)) {
|
||||
const [, monthText, dayText] = existingBirthDate.split('-')
|
||||
month = monthText
|
||||
day = dayText
|
||||
}
|
||||
|
||||
let birthYear = today.getFullYear() - age
|
||||
let candidate = `${birthYear}-${month}-${day}`
|
||||
|
||||
if (Number(calculateAgeFromDate(candidate)) > age) {
|
||||
birthYear -= 1
|
||||
candidate = `${birthYear}-${month}-${day}`
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function matchKeyword(employee, keyword) {
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fields = [
|
||||
employee.name,
|
||||
employee.employeeNo,
|
||||
employee.department,
|
||||
employee.position,
|
||||
employee.email,
|
||||
employee.manager,
|
||||
employee.financeOwner,
|
||||
...getEmployeeBankSearchFields(employee),
|
||||
employee.syncState
|
||||
]
|
||||
|
||||
const roles = Array.isArray(employee.roles) ? employee.roles : []
|
||||
const haystack = [...fields, ...roles]
|
||||
.map((val) => String(val || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
}
|
||||
|
||||
export function uniqueSorted(values) {
|
||||
return [...new Set(values.filter(Boolean))].sort((left, right) => {
|
||||
return String(left).localeCompare(String(right), 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRoleOptions(metaRoles, employees) {
|
||||
const options = Array.isArray(metaRoles) && metaRoles.length ? metaRoles : FALLBACK_ROLE_OPTIONS
|
||||
const existingLabels = new Set(options.map((item) => item.label))
|
||||
const unknownRoles = uniqueSorted(employees.flatMap((item) => item.roles || [])).filter(
|
||||
(label) => !existingLabels.has(label)
|
||||
)
|
||||
|
||||
return [
|
||||
...options,
|
||||
...unknownRoles.map((label) => ({
|
||||
id: label,
|
||||
code: label,
|
||||
label,
|
||||
desc: '该角色来自当前员工数据。'
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
export function buildStatusTabs(employees) {
|
||||
return DEFAULT_STATUS_TABS.map((label) => ({
|
||||
label,
|
||||
count:
|
||||
label === '全部员工'
|
||||
? employees.length
|
||||
: employees.filter((item) => item.status === label).length
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildEmployeeSummary(employees) {
|
||||
return {
|
||||
total: employees.length,
|
||||
active: employees.filter((item) => item.status === '在职').length,
|
||||
onboarding: employees.filter((item) => item.status === '试用中').length,
|
||||
disabled: employees.filter((item) => item.status === '停用').length,
|
||||
followUp: employees.filter((item) => item.syncState !== '已同步').length,
|
||||
departments: uniqueSorted(employees.map((item) => item.department)).length
|
||||
}
|
||||
}
|
||||
|
||||
export function mapSimpleFilterOptions(values, allLabel) {
|
||||
return [
|
||||
{ label: allLabel, value: '' },
|
||||
...values.map((value) => ({ label: value, value }))
|
||||
]
|
||||
}
|
||||
|
||||
export function buildEmployeeStatusActionCopy(options = {}) {
|
||||
const employeeName = options.selectedEmployee?.name || '该员工'
|
||||
if (options.selectedEmployeeDisabled) {
|
||||
return {
|
||||
buttonLabel: options.actionState === 'disable' ? '启用中...' : '启用账号',
|
||||
buttonIcon: 'mdi mdi-account-check-outline',
|
||||
badge: '启用账号',
|
||||
badgeTone: 'info',
|
||||
title: `确认启用 ${employeeName} 的账号吗?`,
|
||||
description: '启用后该员工将恢复登录能力,并重新获得个人业务入口访问权限。',
|
||||
confirmText: '确认启用',
|
||||
busyText: '启用中...',
|
||||
confirmTone: 'primary',
|
||||
confirmIcon: 'mdi mdi-account-check-outline',
|
||||
successMessage: '员工账号已启用。',
|
||||
failureMessage: '启用账号失败,请稍后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buttonLabel: options.actionState === 'disable' ? '停用中...' : '停用账号',
|
||||
buttonIcon: 'mdi mdi-account-cancel-outline',
|
||||
badge: '停用账号',
|
||||
badgeTone: 'warning',
|
||||
title: `确认停用 ${employeeName} 的账号吗?`,
|
||||
description: '停用后该员工将无法继续登录系统,相关个人操作入口也会立即失效。',
|
||||
confirmText: '确认停用',
|
||||
busyText: '停用中...',
|
||||
confirmTone: 'danger',
|
||||
confirmIcon: 'mdi mdi-account-cancel-outline',
|
||||
successMessage: '员工账号已停用。',
|
||||
failureMessage: '停用账号失败,请稍后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEmployeeEmptyState(options = {}) {
|
||||
const hasEmployeeFilters = Boolean(options.hasEmployeeFilters)
|
||||
const activeTab = options.activeTab || DEFAULT_STATUS_TABS[0]
|
||||
if (!options.employeeCount) {
|
||||
return {
|
||||
eyebrow: '员工台账',
|
||||
title: '员工目录暂时还是空的',
|
||||
desc: '当前环境还没有同步任何员工档案。完成目录接入后,这里会展示员工基础信息、角色和状态。',
|
||||
icon: 'mdi mdi-account-group-outline',
|
||||
actionLabel: '重新加载',
|
||||
actionIcon: 'mdi mdi-refresh',
|
||||
tone: 'sky',
|
||||
artLabel: 'PEOPLE',
|
||||
tips: ['支持按部门、职级和角色统一维护', '点击列表行即可进入档案和权限详情']
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: hasEmployeeFilters ? '筛选结果为空' : '员工状态为空',
|
||||
title: hasEmployeeFilters ? '当前条件下没有匹配员工' : `“${activeTab}”里暂时没有员工`,
|
||||
desc: hasEmployeeFilters
|
||||
? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。'
|
||||
: '这个状态标签下目前还没有记录,你可以切换到其他状态继续查看。',
|
||||
icon: hasEmployeeFilters ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline',
|
||||
actionLabel: hasEmployeeFilters ? '清空筛选' : '查看全部员工',
|
||||
actionIcon: hasEmployeeFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted',
|
||||
tone: hasEmployeeFilters ? 'primary' : 'slate',
|
||||
artLabel: hasEmployeeFilters ? 'FILTER' : 'STATUS',
|
||||
tips: hasEmployeeFilters
|
||||
? ['关键词、部门、职级和角色条件会叠加生效', '也可以直接搜索姓名、工号或岗位']
|
||||
: ['员工状态统计会按真实目录数据自动更新', '停用员工仍会保留在台账中便于追溯']
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEmployeeUpdatePayload(options = {}) {
|
||||
const current = options.selectedEmployee
|
||||
const form = options.form || {}
|
||||
const payload = {}
|
||||
|
||||
if (!current) {
|
||||
return payload
|
||||
}
|
||||
|
||||
const nextName = normalizeText(form.name)
|
||||
if (nextName && nextName !== current.name) payload.name = nextName
|
||||
|
||||
const nextGender = normalizeNullableText(form.gender)
|
||||
if (nextGender !== (current.gender || null)) payload.gender = nextGender
|
||||
|
||||
const nextBirthDate = normalizeNullableText(form.birthDate)
|
||||
if (nextBirthDate !== (current.birthDate || null)) payload.birth_date = nextBirthDate
|
||||
|
||||
const nextPhone = normalizeNullableText(form.phone)
|
||||
if (nextPhone !== (current.phone || null)) payload.phone = nextPhone
|
||||
|
||||
const nextEmail = normalizeText(form.email)
|
||||
if (nextEmail && nextEmail !== current.email) payload.email = nextEmail
|
||||
|
||||
const nextJoinDate = normalizeNullableText(form.joinDate)
|
||||
if (nextJoinDate !== (current.joinDate || null)) payload.join_date = nextJoinDate
|
||||
|
||||
const nextLocation = normalizeNullableText(form.location)
|
||||
if (nextLocation !== (current.location || null)) payload.location = nextLocation
|
||||
|
||||
const nextPosition = normalizeText(form.position)
|
||||
if (nextPosition && nextPosition !== current.position) payload.position = nextPosition
|
||||
|
||||
const nextGrade = normalizeText(form.grade)
|
||||
if (nextGrade && nextGrade !== current.grade) payload.grade = nextGrade
|
||||
|
||||
const nextOrganizationCode = normalizeText(form.organizationUnitCode)
|
||||
const currentOrganizationCode =
|
||||
normalizeText(options.employeeDetailSnapshot?.organizationUnitCode) ||
|
||||
resolveOrganizationUnitCode(current) ||
|
||||
''
|
||||
if (nextOrganizationCode !== currentOrganizationCode) {
|
||||
payload.organization_unit_code = nextOrganizationCode
|
||||
}
|
||||
|
||||
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
|
||||
if (nextFinanceOwner !== (current.financeOwner || null)) {
|
||||
payload.finance_owner_name = nextFinanceOwner
|
||||
}
|
||||
|
||||
const nextCostCenter = normalizeNullableText(form.costCenter)
|
||||
if (nextCostCenter !== (current.costCenter || null)) {
|
||||
payload.cost_center = nextCostCenter
|
||||
}
|
||||
|
||||
appendEmployeeBankUpdatePayload(payload, form, current, normalizeNullableText)
|
||||
|
||||
const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo)
|
||||
const currentManagerEmployeeNo =
|
||||
normalizeNullableText(current.managerEmployeeNo) ||
|
||||
resolveManagerEmployeeNo(current, options.employees || []) ||
|
||||
null
|
||||
if (nextManagerEmployeeNo !== currentManagerEmployeeNo) {
|
||||
payload.manager_employee_no = nextManagerEmployeeNo || ''
|
||||
}
|
||||
|
||||
const nextRoleCodes = [...(form.roleCodes || [])].sort()
|
||||
const currentRoleCodes = [
|
||||
...(options.employeeDetailSnapshot?.roleCodes || current.roleCodes || [])
|
||||
].sort()
|
||||
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
|
||||
payload.role_codes = [...(form.roleCodes || [])]
|
||||
}
|
||||
|
||||
const nextPassword = normalizeText(form.password)
|
||||
if (nextPassword) payload.password = nextPassword
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function padDatePart(value) {
|
||||
return String(Number(value)).padStart(2, '0')
|
||||
}
|
||||
114
web/src/views/scripts/stewardPlanFields.js
Normal file
114
web/src/views/scripts/stewardPlanFields.js
Normal file
@@ -0,0 +1,114 @@
|
||||
export const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([
|
||||
'amount',
|
||||
'attachments',
|
||||
'employee_no',
|
||||
'employee_name',
|
||||
'department_name'
|
||||
])
|
||||
|
||||
export const FLOW_EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费'
|
||||
}
|
||||
|
||||
const FIELD_DISPLAY_CONFIG = {
|
||||
expense_type: {
|
||||
label: '费用类型',
|
||||
hint: '例如差旅、交通、住宿、业务招待'
|
||||
},
|
||||
time_range: {
|
||||
label: '发生时间',
|
||||
hint: '申请时填出差起止日期,报销时填费用发生日期'
|
||||
},
|
||||
location: {
|
||||
label: '地点',
|
||||
hint: '出差城市或费用发生地点'
|
||||
},
|
||||
reason: {
|
||||
label: '事由',
|
||||
hint: '出差、报销或业务活动的具体原因'
|
||||
},
|
||||
amount: {
|
||||
label: '金额',
|
||||
hint: '申请时为预计金额,报销时为实际报销金额'
|
||||
},
|
||||
transport_mode: {
|
||||
label: '出行方式',
|
||||
hint: '例如高铁、飞机、自驾、出租车'
|
||||
},
|
||||
attachments: {
|
||||
label: '附件/凭证',
|
||||
hint: '发票、行程单、付款截图或其他证明材料'
|
||||
},
|
||||
customer_name: {
|
||||
label: '客户或项目对象',
|
||||
hint: '涉及的客户、单位或项目名称'
|
||||
},
|
||||
merchant_name: {
|
||||
label: '商户/开票方',
|
||||
hint: '发票或付款凭证上的商户名称'
|
||||
},
|
||||
department_name: {
|
||||
label: '所属部门',
|
||||
hint: '申请人或费用归属部门'
|
||||
},
|
||||
employee_name: {
|
||||
label: '申请人',
|
||||
hint: '发起申请或报销的员工姓名'
|
||||
},
|
||||
employee_no: {
|
||||
label: '员工编号',
|
||||
hint: '公司内部员工编号'
|
||||
}
|
||||
}
|
||||
|
||||
const FIELD_ALIASES = {
|
||||
occurred_date: 'time_range',
|
||||
business_time: 'time_range',
|
||||
reason_value: 'reason',
|
||||
transport_type: 'transport_mode',
|
||||
application_transport_mode: 'transport_mode'
|
||||
}
|
||||
|
||||
const FIELD_VALUE_DISPLAY_CONFIG = {
|
||||
expense_type: {
|
||||
travel: '差旅',
|
||||
business_entertainment: '业务招待',
|
||||
transportation: '交通费',
|
||||
traffic: '交通费',
|
||||
accommodation: '住宿费',
|
||||
meal: '餐饮费'
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFieldKey(field) {
|
||||
const key = String(field || '').trim()
|
||||
return FIELD_ALIASES[key] || key
|
||||
}
|
||||
|
||||
export function resolveFieldDisplay(field, taskType = '') {
|
||||
const key = normalizeFieldKey(field)
|
||||
const config = FIELD_DISPLAY_CONFIG[key] || {
|
||||
label: key.replace(/_/g, ' '),
|
||||
hint: ''
|
||||
}
|
||||
if (key === 'amount') {
|
||||
return {
|
||||
key,
|
||||
label: taskType === 'expense_application' ? '预计金额' : '报销金额',
|
||||
hint: taskType === 'expense_application'
|
||||
? '本次申请预计发生的费用'
|
||||
: '本次需要报销的实际金额'
|
||||
}
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: config.label,
|
||||
hint: config.hint
|
||||
}
|
||||
}
|
||||
|
||||
export function formatStewardFieldDisplayValue(field, value) {
|
||||
const key = normalizeFieldKey(field)
|
||||
const normalizedValue = String(value || '').trim()
|
||||
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
|
||||
}
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import {
|
||||
APPLICATION_NON_BLOCKING_MISSING_FIELDS,
|
||||
FLOW_EXPENSE_TYPE_LABELS,
|
||||
formatStewardFieldDisplayValue,
|
||||
normalizeFieldKey,
|
||||
resolveFieldDisplay
|
||||
} from './stewardPlanFields.js'
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
expense_application: '费用申请',
|
||||
@@ -21,88 +28,6 @@ const AGENT_LABELS = {
|
||||
expense: '报销助手'
|
||||
}
|
||||
|
||||
const FIELD_DISPLAY_CONFIG = {
|
||||
expense_type: {
|
||||
label: '费用类型',
|
||||
hint: '例如差旅、交通、住宿、业务招待'
|
||||
},
|
||||
time_range: {
|
||||
label: '发生时间',
|
||||
hint: '申请时填出差起止日期,报销时填费用发生日期'
|
||||
},
|
||||
location: {
|
||||
label: '地点',
|
||||
hint: '出差城市或费用发生地点'
|
||||
},
|
||||
reason: {
|
||||
label: '事由',
|
||||
hint: '出差、报销或业务活动的具体原因'
|
||||
},
|
||||
amount: {
|
||||
label: '金额',
|
||||
hint: '申请时为预计金额,报销时为实际报销金额'
|
||||
},
|
||||
transport_mode: {
|
||||
label: '出行方式',
|
||||
hint: '例如高铁、飞机、自驾、出租车'
|
||||
},
|
||||
attachments: {
|
||||
label: '附件/凭证',
|
||||
hint: '发票、行程单、付款截图或其他证明材料'
|
||||
},
|
||||
customer_name: {
|
||||
label: '客户或项目对象',
|
||||
hint: '涉及的客户、单位或项目名称'
|
||||
},
|
||||
merchant_name: {
|
||||
label: '商户/开票方',
|
||||
hint: '发票或付款凭证上的商户名称'
|
||||
},
|
||||
department_name: {
|
||||
label: '所属部门',
|
||||
hint: '申请人或费用归属部门'
|
||||
},
|
||||
employee_name: {
|
||||
label: '申请人',
|
||||
hint: '发起申请或报销的员工姓名'
|
||||
},
|
||||
employee_no: {
|
||||
label: '员工编号',
|
||||
hint: '公司内部员工编号'
|
||||
}
|
||||
}
|
||||
|
||||
const FIELD_ALIASES = {
|
||||
occurred_date: 'time_range',
|
||||
business_time: 'time_range',
|
||||
reason_value: 'reason',
|
||||
transport_type: 'transport_mode',
|
||||
application_transport_mode: 'transport_mode'
|
||||
}
|
||||
|
||||
const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([
|
||||
'amount',
|
||||
'attachments',
|
||||
'employee_no',
|
||||
'employee_name',
|
||||
'department_name'
|
||||
])
|
||||
|
||||
const FIELD_VALUE_DISPLAY_CONFIG = {
|
||||
expense_type: {
|
||||
travel: '差旅',
|
||||
business_entertainment: '业务招待',
|
||||
transportation: '交通费',
|
||||
traffic: '交通费',
|
||||
accommodation: '住宿费',
|
||||
meal: '餐饮费'
|
||||
}
|
||||
}
|
||||
|
||||
const FLOW_EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费'
|
||||
}
|
||||
|
||||
export function buildStewardPlanRequest({
|
||||
rawText = '',
|
||||
files = [],
|
||||
@@ -774,39 +699,6 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function normalizeFieldKey(field) {
|
||||
const key = String(field || '').trim()
|
||||
return FIELD_ALIASES[key] || key
|
||||
}
|
||||
|
||||
function resolveFieldDisplay(field, taskType = '') {
|
||||
const key = normalizeFieldKey(field)
|
||||
const config = FIELD_DISPLAY_CONFIG[key] || {
|
||||
label: key.replace(/_/g, ' '),
|
||||
hint: ''
|
||||
}
|
||||
if (key === 'amount') {
|
||||
return {
|
||||
key,
|
||||
label: taskType === 'expense_application' ? '预计金额' : '报销金额',
|
||||
hint: taskType === 'expense_application'
|
||||
? '本次申请预计发生的费用'
|
||||
: '本次需要报销的实际金额'
|
||||
}
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: config.label,
|
||||
hint: config.hint
|
||||
}
|
||||
}
|
||||
|
||||
function formatStewardFieldDisplayValue(field, value) {
|
||||
const key = normalizeFieldKey(field)
|
||||
const normalizedValue = String(value || '').trim()
|
||||
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
|
||||
}
|
||||
|
||||
function buildRemainingTaskText(normalized, currentTaskId) {
|
||||
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
|
||||
if (!remainingTasks.length) {
|
||||
|
||||
34
web/src/views/scripts/stewardTypewriter.js
Normal file
34
web/src/views/scripts/stewardTypewriter.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const DEFAULT_TYPEWRITER_CHUNK_SIZE = 3
|
||||
|
||||
function findLineStart(chars, index) {
|
||||
let cursor = Math.max(0, index)
|
||||
while (cursor > 0 && chars[cursor - 1] !== '\n') {
|
||||
cursor -= 1
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
function findNextParagraphStart(chars, index) {
|
||||
let cursor = Math.max(0, index)
|
||||
while (cursor < chars.length) {
|
||||
if (chars[cursor] === '\n' && chars[cursor + 1] && chars[cursor + 1] !== '|') {
|
||||
return cursor + 1
|
||||
}
|
||||
cursor += 1
|
||||
}
|
||||
return chars.length
|
||||
}
|
||||
|
||||
function isMarkdownTableLine(chars, lineStart) {
|
||||
return chars.slice(lineStart, lineStart + 2).join('').trimStart().startsWith('|')
|
||||
}
|
||||
|
||||
export function resolveStewardTypewriterNextIndex(chars = [], index = 0, chunkSize = DEFAULT_TYPEWRITER_CHUNK_SIZE) {
|
||||
const safeIndex = Math.max(0, Math.min(Number(index) || 0, chars.length))
|
||||
const lineStart = findLineStart(chars, safeIndex)
|
||||
if (isMarkdownTableLine(chars, lineStart) || chars[safeIndex + 1] === '|') {
|
||||
return findNextParagraphStart(chars, lineStart)
|
||||
}
|
||||
const safeChunkSize = Math.max(1, Number(chunkSize) || DEFAULT_TYPEWRITER_CHUNK_SIZE)
|
||||
return Math.min(chars.length, safeIndex + safeChunkSize)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
|
||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||
import {
|
||||
FLOW_MISSING_SLOT_LABELS,
|
||||
FLOW_STEP_FALLBACKS
|
||||
} from './travelReimbursementConversationSessionModel.js'
|
||||
|
||||
let messageSeed = 0
|
||||
|
||||
export function nowTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
messageSeed += 1
|
||||
const message = {
|
||||
id: `msg-${messageSeed}`,
|
||||
role,
|
||||
text,
|
||||
attachments,
|
||||
time: nowTime(),
|
||||
meta: [],
|
||||
citations: [],
|
||||
suggestedActions: [],
|
||||
suggestedActionsLocked: false,
|
||||
selectedSuggestedActionKey: '',
|
||||
selectedSuggestedActionLabel: '',
|
||||
querySelectionLocked: false,
|
||||
selectedQueryRecordId: '',
|
||||
queryPayload: null,
|
||||
draftPayload: null,
|
||||
reviewPayload: null,
|
||||
reviewPanelScope: '',
|
||||
riskFlags: [],
|
||||
pendingAttachmentAssociation: null,
|
||||
applicationPreview: null,
|
||||
budgetReport: null,
|
||||
stewardPlan: null,
|
||||
operationFeedback: null,
|
||||
...extras
|
||||
}
|
||||
message.meta = filterVisibleMessageMeta(message.meta)
|
||||
return message
|
||||
}
|
||||
|
||||
export function buildExpenseIntentConfirmationMessage(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
return [
|
||||
text
|
||||
? `我看到了「${text}」这类业务事项描述。`
|
||||
: '我看到了这类业务事项描述。',
|
||||
'但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。',
|
||||
'如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildExpenseSceneSelectionMessage(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text)
|
||||
const prefix = hasBusinessTime
|
||||
? '我已看到你提供了业务发生时间和报销意图。'
|
||||
: '我已识别到这是报销申请。'
|
||||
|
||||
return [
|
||||
`${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续。`,
|
||||
'差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。',
|
||||
'选完后我会把下一步需要准备的内容整理给你。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatMessageTime(value) {
|
||||
if (!value) {
|
||||
return nowTime()
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return nowTime()
|
||||
}
|
||||
|
||||
return parsed.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
export function formatSemanticEntityValue(entity) {
|
||||
const normalizedValue = String(entity?.normalized_value || '').trim()
|
||||
const rawValue = String(entity?.value || '').trim()
|
||||
const entityType = String(entity?.type || '').trim()
|
||||
|
||||
if (entityType === 'amount') {
|
||||
const numericValue = Number(normalizedValue || rawValue)
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元`
|
||||
}
|
||||
}
|
||||
|
||||
return rawValue || normalizedValue
|
||||
}
|
||||
|
||||
export function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
|
||||
if (!semanticParse || typeof semanticParse !== 'object') {
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : []
|
||||
const entityMap = new Map()
|
||||
for (const item of entities) {
|
||||
const entityType = String(item?.type || '').trim()
|
||||
if (!entityType || entityMap.has(entityType)) continue
|
||||
entityMap.set(entityType, item)
|
||||
}
|
||||
|
||||
const extractedParts = []
|
||||
const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object'
|
||||
? semanticParse.time_range_json
|
||||
: {}
|
||||
const startDate = String(timeRange.start_date || '').trim()
|
||||
const endDate = String(timeRange.end_date || '').trim()
|
||||
if (startDate) {
|
||||
extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`)
|
||||
}
|
||||
|
||||
const amountEntity = entityMap.get('amount')
|
||||
if (amountEntity) {
|
||||
const amountValue = formatSemanticEntityValue(amountEntity)
|
||||
if (amountValue) {
|
||||
extractedParts.push(`金额 ${amountValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
const expenseTypeEntity = entityMap.get('expense_type')
|
||||
if (expenseTypeEntity) {
|
||||
const expenseTypeLabel = resolveExpenseTypeLabel(
|
||||
String(expenseTypeEntity?.normalized_value || '').trim(),
|
||||
String(expenseTypeEntity?.value || '').trim()
|
||||
)
|
||||
if (expenseTypeLabel) {
|
||||
extractedParts.push(`费用类型 ${expenseTypeLabel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const customerEntity = entityMap.get('customer')
|
||||
if (customerEntity) {
|
||||
const customerValue = formatSemanticEntityValue(customerEntity)
|
||||
if (customerValue) {
|
||||
extractedParts.push(`客户 ${customerValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : []
|
||||
const missingLabels = missingSlots
|
||||
.map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (extractedParts.length && missingLabels.length) {
|
||||
return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}`
|
||||
}
|
||||
if (extractedParts.length) {
|
||||
return `已提取${extractedParts.join('、')}`
|
||||
}
|
||||
if (missingLabels.length) {
|
||||
return `已完成信息提取;待补充 ${missingLabels.join('、')}`
|
||||
}
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
export function sanitizeRequest(request) {
|
||||
if (!request || typeof request !== 'object') return null
|
||||
|
||||
const normalized = {
|
||||
claimId: String(request.claimId || request.claim_id || '').trim(),
|
||||
claimNo: String(request.claimNo || request.claim_no || request.documentNo || '').trim(),
|
||||
id: String(request.id || '').trim(),
|
||||
typeLabel: String(request.typeLabel || request.category || '').trim(),
|
||||
reason: String(request.reason || request.title || '').trim(),
|
||||
entity: String(request.entity || '').trim(),
|
||||
city: String(request.city || request.location || '').trim(),
|
||||
period: String(request.period || '').trim(),
|
||||
applyTime: String(request.applyTime || request.occurredAt || '').trim(),
|
||||
amount: String(request.amount || '').trim(),
|
||||
node: String(request.node || '').trim(),
|
||||
approval: String(request.approval || '').trim(),
|
||||
travel: String(request.travel || '').trim(),
|
||||
applicationEditMode: Boolean(request.applicationEditMode || request.application_edit_mode)
|
||||
}
|
||||
|
||||
return Object.values(normalized).some(Boolean) ? normalized : null
|
||||
}
|
||||
|
||||
export function resolveStatusLabel(status) {
|
||||
if (status === 'succeeded') return '已完成'
|
||||
if (status === 'blocked') return '已阻断'
|
||||
return '失败'
|
||||
}
|
||||
|
||||
export function resolveStatusTone(status) {
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'blocked') return 'warning'
|
||||
return 'note'
|
||||
}
|
||||
|
||||
export function buildMessageMeta(payload, fileNames = []) {
|
||||
const items = []
|
||||
|
||||
if (payload?.trace_summary?.degraded) {
|
||||
items.push('已降级')
|
||||
}
|
||||
|
||||
if (payload?.requires_confirmation) {
|
||||
items.push('待确认')
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
items.push(`附件: ${fileNames.length}`)
|
||||
}
|
||||
|
||||
return filterVisibleMessageMeta(items)
|
||||
}
|
||||
|
||||
export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
|
||||
const payload = messageJson?.orchestrator_payload
|
||||
if (payload) {
|
||||
return buildMessageMeta(payload, attachmentNames)
|
||||
}
|
||||
|
||||
const items = []
|
||||
if (messageJson?.status) {
|
||||
items.push(`状态: ${messageJson.status}`)
|
||||
}
|
||||
if (attachmentNames.length) {
|
||||
items.push(`附件: ${attachmentNames.length}`)
|
||||
}
|
||||
return filterVisibleMessageMeta(items)
|
||||
}
|
||||
|
||||
export function buildRestoredMessageId(sourceId = '') {
|
||||
const normalizedId = String(sourceId || '').trim()
|
||||
return `restored-${normalizedId || ++messageSeed}`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
import { isBudgetMonitorUser, isExecutiveUser, isPlatformAdminUser } from '../../utils/accessControl.js'
|
||||
import {
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_START_APPLICATION,
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
export const SESSION_TYPE_EXPENSE = 'expense'
|
||||
export const SESSION_TYPE_APPLICATION = 'application'
|
||||
export const SESSION_TYPE_APPROVAL = 'approval'
|
||||
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
export const SESSION_TYPE_BUDGET = 'budget'
|
||||
export const SESSION_TYPE_STEWARD = 'steward'
|
||||
|
||||
export const ASSISTANT_SESSION_TYPES = [
|
||||
SESSION_TYPE_STEWARD,
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_APPROVAL,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
SESSION_TYPE_BUDGET
|
||||
]
|
||||
|
||||
export const ASSISTANT_SESSION_MODE_OPTIONS = [
|
||||
{
|
||||
key: SESSION_TYPE_STEWARD,
|
||||
label: '小财管家',
|
||||
icon: 'mdi mdi-account-tie-outline',
|
||||
description: '统一拆解多任务、归集附件,并调度申请助手和报销助手'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_APPLICATION,
|
||||
label: '申请助手',
|
||||
icon: 'mdi mdi-file-plus-outline',
|
||||
description: '只处理费用申请、事前审批、申请材料和申请状态'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_EXPENSE,
|
||||
label: '报销助手',
|
||||
icon: 'mdi mdi-receipt-text-plus-outline',
|
||||
description: '只处理报销发起、票据识别、草稿归集和报销状态'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_APPROVAL,
|
||||
label: '审核助手',
|
||||
icon: 'mdi mdi-clipboard-check-outline',
|
||||
description: '只处理待审单据、风险解释、审批动作和审核意见'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_KNOWLEDGE,
|
||||
label: '财务知识助手',
|
||||
icon: 'mdi mdi-book-open-page-variant-outline',
|
||||
description: '只处理财务制度、标准规则、票据要求和政策解释'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_BUDGET,
|
||||
label: '预算编制助手',
|
||||
icon: 'mdi mdi-calculator-variant-outline',
|
||||
description: '帮助你进行预算编制与预算相关问题的整理'
|
||||
}
|
||||
]
|
||||
|
||||
export function canUseBudgetAssistantSession(user = null) {
|
||||
return Boolean(isPlatformAdminUser(user) || isBudgetMonitorUser(user) || isExecutiveUser(user))
|
||||
}
|
||||
|
||||
function canUseAssistantSessionType(sessionType, user = null) {
|
||||
const normalized = String(sessionType || '').trim()
|
||||
if (normalized === SESSION_TYPE_BUDGET) {
|
||||
return canUseBudgetAssistantSession(user)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function filterAssistantSessionModes(sessionModes = [], user = null) {
|
||||
return Array.isArray(sessionModes)
|
||||
? sessionModes.filter((mode) => canUseAssistantSessionType(mode?.key, user))
|
||||
: []
|
||||
}
|
||||
|
||||
export function filterAssistantSessionTypes(sessionTypes = [], user = null) {
|
||||
return Array.isArray(sessionTypes)
|
||||
? sessionTypes.filter((sessionType) => canUseAssistantSessionType(String(sessionType || '').trim(), user))
|
||||
: []
|
||||
}
|
||||
|
||||
export function normalizeAssistantSessionType(sessionType, fallback = SESSION_TYPE_EXPENSE) {
|
||||
const normalized = String(sessionType || '').trim()
|
||||
if (ASSISTANT_SESSION_TYPES.includes(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
const fallbackType = String(fallback || '').trim()
|
||||
return ASSISTANT_SESSION_TYPES.includes(fallbackType) ? fallbackType : SESSION_TYPE_EXPENSE
|
||||
}
|
||||
|
||||
export function resolveAssistantSessionMode(sessionType) {
|
||||
const normalized = normalizeAssistantSessionType(sessionType)
|
||||
return ASSISTANT_SESSION_MODE_OPTIONS.find((item) => item.key === normalized) || ASSISTANT_SESSION_MODE_OPTIONS[1]
|
||||
}
|
||||
|
||||
export const aiAvatar = '/assets/header.png'
|
||||
export const userAvatar = '/assets/person.png'
|
||||
|
||||
export const SOURCE_LABELS = {
|
||||
workbench: '来自个人工作台',
|
||||
topbar: '来自发起报销',
|
||||
application: '来自发起申请',
|
||||
budget: '来自预算中心',
|
||||
detail: '来自智能录入',
|
||||
upload: '来自附件上传',
|
||||
requests: '来自报销列表'
|
||||
}
|
||||
|
||||
|
||||
export const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
accounts_payable: '应付',
|
||||
budget: '预算',
|
||||
knowledge: '知识',
|
||||
unknown: '通用'
|
||||
}
|
||||
|
||||
export const INTENT_LABELS = {
|
||||
query: '查询',
|
||||
explain: '解释',
|
||||
compare: '对比',
|
||||
risk_check: '风险检查',
|
||||
draft: '信息核对',
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
export const FLOW_STEP_FALLBACKS = {
|
||||
intent: {
|
||||
title: '意图识别',
|
||||
tool: 'IntentRecognizer',
|
||||
runningText: '正在识别业务意图...',
|
||||
completedText: '意图识别完成'
|
||||
},
|
||||
extraction: {
|
||||
title: '信息提取',
|
||||
tool: 'SemanticExtractor',
|
||||
runningText: '正在提取时间、金额、费用类型和待补项...',
|
||||
completedText: '信息提取完成'
|
||||
},
|
||||
ocr: {
|
||||
title: '票据/OCR识别',
|
||||
tool: 'OCRService',
|
||||
runningText: '正在识别票据附件...',
|
||||
completedText: '票据识别完成'
|
||||
},
|
||||
'expense-review-preview': {
|
||||
title: '报销信息核对',
|
||||
tool: 'user_agent.expense_review_preview',
|
||||
runningText: '正在整理识别结果和右侧核对信息...',
|
||||
completedText: '核对信息已整理'
|
||||
},
|
||||
'expense-claim-draft': {
|
||||
title: '保存报销草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
runningText: '正在把已确认信息保存为草稿...',
|
||||
completedText: '草稿已保存'
|
||||
},
|
||||
'draft-risk-review': {
|
||||
title: '草稿风险识别',
|
||||
tool: 'RuleEngine',
|
||||
runningText: '正在对草稿执行规则校验...',
|
||||
completedText: '已完成草稿风险识别'
|
||||
},
|
||||
'application-submit-success': {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
runningText: '正在提交费用申请...',
|
||||
completedText: '申请单提交成功'
|
||||
},
|
||||
'attachment-association': {
|
||||
title: '票据关联草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
runningText: '正在把本次票据关联到已保存草稿...',
|
||||
completedText: '票据已归集到草稿'
|
||||
},
|
||||
'expense-scene-selection': {
|
||||
title: '报销场景确认',
|
||||
tool: 'UserConfirmation',
|
||||
runningText: '等待用户选择报销场景...',
|
||||
completedText: '已进入场景选择,等待用户确认'
|
||||
},
|
||||
'expense-intent-confirmation': {
|
||||
title: '报销意图确认',
|
||||
tool: 'UserConfirmation',
|
||||
runningText: '等待用户确认是否发起报销...',
|
||||
completedText: '用户已确认报销意图'
|
||||
}
|
||||
}
|
||||
export const ASSISTANT_DISPLAY_NAME = '财务助手'
|
||||
|
||||
export const EXPENSE_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '快速发起报销',
|
||||
action: GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
icon: 'mdi mdi-receipt-text-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '查询单据状态',
|
||||
action: GUIDED_ACTION_START_STATUS_QUERY,
|
||||
icon: 'mdi mdi-file-search-outline'
|
||||
},
|
||||
{
|
||||
label: '差旅计算器',
|
||||
action: GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
icon: 'mdi mdi-calculator-variant-outline'
|
||||
}
|
||||
]
|
||||
|
||||
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '快速发起申请',
|
||||
action: GUIDED_ACTION_START_APPLICATION,
|
||||
icon: 'mdi mdi-file-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '查询申请状态',
|
||||
prompt: '帮我查询我的费用申请单状态,筛选最近的 5 条记录。',
|
||||
icon: 'mdi mdi-file-search-outline'
|
||||
},
|
||||
{
|
||||
label: '申请材料清单',
|
||||
prompt: '请告诉我发起费用申请通常需要准备哪些关键信息和附件。',
|
||||
icon: 'mdi mdi-clipboard-text-search-outline'
|
||||
}
|
||||
]
|
||||
|
||||
export const APPROVAL_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '待我审核',
|
||||
prompt: '帮我查询当前待我审核的单据,筛选最近的 5 条记录。',
|
||||
icon: 'mdi mdi-clipboard-list-outline'
|
||||
},
|
||||
{
|
||||
label: '审核风险说明',
|
||||
prompt: '帮我梳理待审核单据中需要重点关注的风险,并按高、中、低风险分类说明。',
|
||||
icon: 'mdi mdi-alert-circle-outline'
|
||||
},
|
||||
{
|
||||
label: '生成审核意见',
|
||||
prompt: '请根据当前待审核单据的风险点,帮我生成一段专业、克制的审核意见草稿。',
|
||||
icon: 'mdi mdi-text-box-edit-outline'
|
||||
}
|
||||
]
|
||||
|
||||
export const BUDGET_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '预算编制查询',
|
||||
prompt: '帮我查询当前部门本季度预算编制情况,重点看差旅、通信、招待费和办公用品。',
|
||||
icon: 'mdi mdi-calculator-variant-outline'
|
||||
},
|
||||
{
|
||||
label: '阈值风险检查',
|
||||
prompt: '帮我检查当前预算的提醒阈值、告警阈值和风险阈值设置是否合理,并指出需要关注的费用类型。',
|
||||
icon: 'mdi mdi-alert-decagram-outline'
|
||||
},
|
||||
{
|
||||
label: '预算调整建议',
|
||||
prompt: '请根据已发生、已占用和剩余预算,帮我整理下一轮预算调整建议。',
|
||||
icon: 'mdi mdi-chart-box-plus-outline'
|
||||
}
|
||||
]
|
||||
|
||||
export const HOT_KNOWLEDGE_QUESTIONS = [
|
||||
'差旅住宿标准按什么规则执行?',
|
||||
'酒店超标后如何申请例外报销?',
|
||||
'招待费报销需要哪些凭证?',
|
||||
'发票抬头不一致还能报销吗?',
|
||||
'电子发票验真失败怎么处理?',
|
||||
'借款多久内需要冲销?',
|
||||
'预算不足还能先提交报销吗?',
|
||||
'会议费和招待费如何区分?',
|
||||
'跨部门项目费用应该怎么归集?',
|
||||
'员工退票手续费是否可以报销?'
|
||||
]
|
||||
export const FLOW_MISSING_SLOT_LABELS = {
|
||||
expense_type: '报销类型',
|
||||
customer_name: '客户名称',
|
||||
time_range: '发生时间',
|
||||
location: '地点',
|
||||
merchant_name: '酒店/商户',
|
||||
amount: '金额',
|
||||
reason: '事由说明',
|
||||
participants: '参与人员',
|
||||
attachments: '票据附件'
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
@@ -0,0 +1,277 @@
|
||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
||||
import { normalizeAssistantSessionType, SESSION_TYPE_EXPENSE } from './travelReimbursementConversationSessionModel.js'
|
||||
import {
|
||||
buildRestoredMessageId,
|
||||
buildStoredMessageMeta,
|
||||
createMessage,
|
||||
formatMessageTime
|
||||
} from './travelReimbursementConversationMessageModel.js'
|
||||
|
||||
export function resolveInitialSessionType(conversation, fallback = SESSION_TYPE_EXPENSE) {
|
||||
const stateJson = conversation?.state_json || conversation?.stateJson || {}
|
||||
const sessionType = String(stateJson?.session_type || '').trim()
|
||||
return normalizeAssistantSessionType(sessionType, fallback)
|
||||
}
|
||||
|
||||
export function buildInitialInsightFromConversation(conversation) {
|
||||
const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : []
|
||||
for (let index = rawMessages.length - 1; index >= 0; index -= 1) {
|
||||
const item = rawMessages[index]
|
||||
const messageJson = item?.message_json || item?.messageJson || {}
|
||||
const orchestratorPayload = messageJson?.orchestrator_payload || null
|
||||
if (!orchestratorPayload) continue
|
||||
const attachmentNames = Array.isArray(messageJson?.attachment_names)
|
||||
? messageJson.attachment_names.filter(Boolean)
|
||||
: []
|
||||
return buildAgentInsight(
|
||||
orchestratorPayload,
|
||||
attachmentNames,
|
||||
buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveInitialConversationId(conversation) {
|
||||
return String(conversation?.conversation_id || conversation?.conversationId || '').trim()
|
||||
}
|
||||
|
||||
export function resolveInitialDraftClaimId(conversation) {
|
||||
return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim()
|
||||
}
|
||||
|
||||
export function resolveKnowledgeRankLabel(index) {
|
||||
return String(index + 1)
|
||||
}
|
||||
|
||||
export function resolveKnowledgeRankTone(index) {
|
||||
if (index === 0) return 'gold'
|
||||
if (index === 1) return 'silver'
|
||||
if (index === 2) return 'bronze'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function parseConversationMessageSequence(message) {
|
||||
const messageJson = message?.message_json || message?.messageJson || {}
|
||||
const sequence = Number.parseInt(messageJson?.sequence, 10)
|
||||
return Number.isFinite(sequence) && sequence > 0 ? sequence : null
|
||||
}
|
||||
|
||||
export function parseConversationMessageTime(message) {
|
||||
const rawValue = message?.created_at || message?.createdAt || ''
|
||||
const timestamp = new Date(rawValue).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
export function resolveConversationMessageRolePriority(message) {
|
||||
return String(message?.role || '').trim() === 'user' ? 0 : 1
|
||||
}
|
||||
|
||||
export function sortConversationMessages(messages) {
|
||||
return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => {
|
||||
const leftSequence = parseConversationMessageSequence(left)
|
||||
const rightSequence = parseConversationMessageSequence(right)
|
||||
if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) {
|
||||
return leftSequence - rightSequence
|
||||
}
|
||||
|
||||
const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right)
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff
|
||||
}
|
||||
|
||||
const leftRunId = String(left?.run_id || left?.runId || '').trim()
|
||||
const rightRunId = String(right?.run_id || right?.runId || '').trim()
|
||||
if (leftRunId && rightRunId && leftRunId === rightRunId) {
|
||||
const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right)
|
||||
if (roleDiff !== 0) {
|
||||
return roleDiff
|
||||
}
|
||||
}
|
||||
|
||||
return String(left?.id || '').localeCompare(String(right?.id || ''))
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeInitialConversationMessages(conversation) {
|
||||
const rawMessages = sortConversationMessages(conversation?.messages)
|
||||
|
||||
const restoredMessages = rawMessages.map((item) => {
|
||||
const messageJson = item?.message_json || item?.messageJson || {}
|
||||
const attachmentNames = Array.isArray(messageJson?.attachment_names)
|
||||
? messageJson.attachment_names.filter(Boolean)
|
||||
: []
|
||||
const orchestratorPayload = messageJson?.orchestrator_payload || null
|
||||
const result = orchestratorPayload?.result || {}
|
||||
|
||||
return createMessage(item.role, item.content, attachmentNames, {
|
||||
id: buildRestoredMessageId(item.id),
|
||||
time: formatMessageTime(item.created_at || item.createdAt),
|
||||
assistantName: String(messageJson?.assistant_name || messageJson?.assistantName || '').trim(),
|
||||
assistantVariant: String(messageJson?.assistant_variant || messageJson?.assistantVariant || '').trim(),
|
||||
meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [],
|
||||
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
|
||||
suggestedActions:
|
||||
item.role === 'assistant' && Array.isArray(result?.suggested_actions)
|
||||
? result.suggested_actions
|
||||
: [],
|
||||
queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null,
|
||||
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
|
||||
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
|
||||
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : []
|
||||
})
|
||||
})
|
||||
return markResolvedSuggestedActionMessages(restoredMessages)
|
||||
}
|
||||
|
||||
export function normalizeSnapshotMessage(message) {
|
||||
const extras = message && typeof message === 'object' ? { ...message } : {}
|
||||
const role = String(extras.role || 'assistant').trim() || 'assistant'
|
||||
const text = String(extras.text || '')
|
||||
const attachments = Array.isArray(extras.attachments) ? extras.attachments.filter(Boolean) : []
|
||||
delete extras.role
|
||||
delete extras.text
|
||||
delete extras.attachments
|
||||
return createMessage(role, text, attachments, extras)
|
||||
}
|
||||
|
||||
export function normalizeSnapshotMessages(messages) {
|
||||
return Array.isArray(messages)
|
||||
? markResolvedSuggestedActionMessages(messages.map(normalizeSnapshotMessage))
|
||||
: []
|
||||
}
|
||||
|
||||
export function serializeSessionMessages(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [],
|
||||
time: message.time,
|
||||
meta: filterVisibleMessageMeta(message.meta),
|
||||
metaTone: message.metaTone || '',
|
||||
citations: Array.isArray(message.citations) ? message.citations : [],
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
suggestedActionsLocked: Boolean(message.suggestedActionsLocked),
|
||||
selectedSuggestedActionKey: String(message.selectedSuggestedActionKey || ''),
|
||||
selectedSuggestedActionLabel: String(message.selectedSuggestedActionLabel || ''),
|
||||
querySelectionLocked: Boolean(message.querySelectionLocked),
|
||||
selectedQueryRecordId: String(message.selectedQueryRecordId || ''),
|
||||
queryPayload: message.queryPayload || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
reviewPayload: message.reviewPayload || null,
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
budgetReport: message.budgetReport || null,
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
operationFeedback: message.operationFeedback || null,
|
||||
assistantName: message.assistantName || '',
|
||||
assistantVariant: message.assistantVariant || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||
}))
|
||||
}
|
||||
|
||||
export function hasMeaningfulSessionMessages(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).some((message) => {
|
||||
if (!message || message.isWelcome) {
|
||||
return false
|
||||
}
|
||||
if (message.role === 'user') {
|
||||
return true
|
||||
}
|
||||
return Boolean(
|
||||
String(message.text || '').trim()
|
||||
|| (Array.isArray(message.suggestedActions) && message.suggestedActions.length)
|
||||
|| message.reviewPayload
|
||||
|| message.queryPayload
|
||||
|| message.draftPayload
|
||||
|| message.applicationPreview
|
||||
|| message.budgetReport
|
||||
|| message.stewardPlan
|
||||
|| message.operationFeedback
|
||||
|| message.pendingAttachmentAssociation
|
||||
|| (Array.isArray(message.riskFlags) && message.riskFlags.length)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function hasActiveSuggestedActionMessage(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).some(
|
||||
(message) =>
|
||||
message?.role === 'assistant'
|
||||
&& Array.isArray(message.suggestedActions)
|
||||
&& message.suggestedActions.length > 0
|
||||
&& !message.suggestedActionsLocked
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveConversationUpdatedAt(conversation) {
|
||||
const timestamp = new Date(conversation?.updated_at || conversation?.updatedAt || 0).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
export function shouldPreferPersistedSessionState(persistedState, snapshot, conversation) {
|
||||
if (!persistedState) {
|
||||
return false
|
||||
}
|
||||
if (!conversation) {
|
||||
return true
|
||||
}
|
||||
if (hasActiveSuggestedActionMessage(persistedState.messages)) {
|
||||
return true
|
||||
}
|
||||
const snapshotUpdatedAt = Number(snapshot?.updatedAt || 0)
|
||||
return snapshotUpdatedAt >= resolveConversationUpdatedAt(conversation)
|
||||
}
|
||||
|
||||
export function markResolvedSuggestedActionMessages(messages) {
|
||||
const items = Array.isArray(messages) ? messages : []
|
||||
const selectedLabels = new Set()
|
||||
|
||||
for (const message of items) {
|
||||
if (message?.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
const text = String(message.text || '').trim()
|
||||
const selectedMatch = text.match(/^选择(.+)$/) || text.match(/用户选择报销场景[::]\s*([^\n\r]+)/)
|
||||
if (selectedMatch?.[1]) {
|
||||
selectedLabels.add(selectedMatch[1].trim())
|
||||
} else if (text === '我要报销') {
|
||||
selectedLabels.add(text)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedLabels.size) {
|
||||
return items
|
||||
}
|
||||
|
||||
return items.map((message) => {
|
||||
if (
|
||||
message?.role !== 'assistant'
|
||||
|| message.suggestedActionsLocked
|
||||
|| !Array.isArray(message.suggestedActions)
|
||||
|| !message.suggestedActions.length
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
const selectedAction = message.suggestedActions.find((action) =>
|
||||
selectedLabels.has(String(action?.label || action?.payload?.expense_type_label || '').trim())
|
||||
)
|
||||
if (!selectedAction) {
|
||||
return message
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
suggestedActionsLocked: true,
|
||||
selectedSuggestedActionKey: buildSuggestedActionKey(selectedAction),
|
||||
selectedSuggestedActionLabel: String(selectedAction.label || selectedAction?.payload?.expense_type_label || '').trim()
|
||||
}
|
||||
})
|
||||
}
|
||||
319
web/src/views/scripts/travelReimbursementCreateReviewModel.js
Normal file
319
web/src/views/scripts/travelReimbursementCreateReviewModel.js
Normal file
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
DATE_INPUT_FORMAT,
|
||||
buildReviewAttachmentStatus,
|
||||
cloneReviewEditFields,
|
||||
createEmptyInlineReviewState,
|
||||
formatAmountDisplay,
|
||||
formatReviewSceneDisplayValue,
|
||||
normalizeReviewRiskLevel,
|
||||
shouldShowReviewFactCard,
|
||||
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel,
|
||||
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel,
|
||||
isTravelReviewPayload as isTravelReviewPayloadModel,
|
||||
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
||||
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
||||
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
||||
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
|
||||
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
|
||||
|
||||
const REVIEW_RISK_LEVEL_META = {
|
||||
high: {
|
||||
label: '高风险',
|
||||
icon: 'mdi mdi-alert-octagon-outline',
|
||||
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
|
||||
},
|
||||
medium: {
|
||||
label: '中风险',
|
||||
icon: 'mdi mdi-alert-circle-outline',
|
||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
||||
},
|
||||
info: {
|
||||
label: '提示',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
||||
},
|
||||
low: {
|
||||
label: '低风险',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReviewPanelScope(scope) {
|
||||
const normalized = String(scope || '').trim()
|
||||
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
|
||||
? normalized
|
||||
: ''
|
||||
}
|
||||
|
||||
export function canExposeReviewPanelScope(scope) {
|
||||
return Boolean(normalizeReviewPanelScope(scope))
|
||||
}
|
||||
|
||||
export function buildBusinessTimeContextFromReviewValues(values = {}) {
|
||||
return buildBusinessTimeContextFromReviewValuesModel(values)
|
||||
}
|
||||
|
||||
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
|
||||
return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState)
|
||||
}
|
||||
|
||||
export function buildReviewCorrectionMessage(fields) {
|
||||
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
|
||||
for (const item of cloneReviewEditFields(fields)) {
|
||||
if (!item.label || (!item.value && !item.required)) {
|
||||
continue
|
||||
}
|
||||
lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
return isTravelReviewPayloadModel(reviewPayload, inlineState)
|
||||
}
|
||||
|
||||
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
|
||||
return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText)
|
||||
}
|
||||
|
||||
export function resolveReviewRiskBriefs(reviewPayload) {
|
||||
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
|
||||
return reviewPayload.risk_briefs.filter((item) => {
|
||||
const title = String(item?.title || '').trim()
|
||||
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
export function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0))
|
||||
const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0))
|
||||
const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount)
|
||||
const attachmentStatus =
|
||||
pendingAttachmentCount > 0
|
||||
? existingAttachmentCount > 0
|
||||
? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount} 份`
|
||||
: `待保存 ${pendingAttachmentCount} 份`
|
||||
: totalAttachmentCount > 0
|
||||
? `已上传 ${totalAttachmentCount} 份`
|
||||
: buildReviewAttachmentStatus(reviewPayload)
|
||||
if (isTravelReviewPayload(reviewPayload, inlineState)) {
|
||||
return [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
label: '发生时间',
|
||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
editor: 'date',
|
||||
modelKey: 'occurred_date',
|
||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
||||
icon: 'mdi mdi-cash',
|
||||
editor: 'amount',
|
||||
modelKey: 'amount',
|
||||
placeholder: '例如 200.00'
|
||||
},
|
||||
{
|
||||
key: 'transport_type',
|
||||
label: '交通类型',
|
||||
value: String(inlineState.transport_type || '').trim() || '待确认',
|
||||
icon: 'mdi mdi-train-car',
|
||||
editor: 'text',
|
||||
modelKey: 'transport_type',
|
||||
placeholder: '例如 火车/高铁、飞机'
|
||||
},
|
||||
{
|
||||
key: 'hotel_name',
|
||||
label: '酒店名称',
|
||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-bed-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'merchant_name',
|
||||
placeholder: '请输入酒店名称'
|
||||
},
|
||||
{
|
||||
key: 'travel_purpose',
|
||||
label: '出差事宜',
|
||||
value: String(inlineState.reason_value || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-briefcase-edit-outline',
|
||||
editor: 'textarea',
|
||||
modelKey: 'reason_value',
|
||||
placeholder: '请填写本次出差的具体工作内容或业务意图',
|
||||
wide: true
|
||||
}
|
||||
]
|
||||
}
|
||||
const cards = [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
label: '发生时间',
|
||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
editor: 'date',
|
||||
modelKey: 'occurred_date',
|
||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
||||
icon: 'mdi mdi-cash',
|
||||
editor: 'amount',
|
||||
modelKey: 'amount',
|
||||
placeholder: '例如 200.00'
|
||||
},
|
||||
{
|
||||
key: 'scene',
|
||||
label: '场景 / 事由',
|
||||
value: formatReviewSceneDisplayValue(inlineState),
|
||||
icon: 'mdi mdi-silverware-fork-knife',
|
||||
editor: 'select',
|
||||
modelKey: 'scene_label',
|
||||
placeholder: '请选择场景'
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: '票据状态',
|
||||
value: attachmentStatus,
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
editor: 'upload',
|
||||
modelKey: 'attachment_names',
|
||||
placeholder: ''
|
||||
}
|
||||
]
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'customer_name',
|
||||
label: '关联客户',
|
||||
value: String(inlineState.customer_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-domain',
|
||||
editor: 'text',
|
||||
modelKey: 'customer_name',
|
||||
placeholder: '请输入客户名称'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'location',
|
||||
label: '业务地点',
|
||||
value: String(inlineState.location || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-map-marker-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'location',
|
||||
placeholder: '请输入业务地点'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'merchant_name',
|
||||
label: '酒店/商户',
|
||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-storefront-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'merchant_name',
|
||||
placeholder: '请输入酒店或商户名称'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'participants',
|
||||
label: '同行人员',
|
||||
value: String(inlineState.participants || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-account-group-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'participants',
|
||||
placeholder: '例如 客户 2 人,我方 1 人'
|
||||
})
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
function normalizeReviewRiskTitle(title, fallbackTitle) {
|
||||
const normalized = String(title || '').trim()
|
||||
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
|
||||
if (!normalized) return fallback
|
||||
const cleaned = normalized
|
||||
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
|
||||
.replace(/(高风险|中风险|低风险)/g, '')
|
||||
.replace(/^[::\-—\s]+|[::\-—\s]+$/g, '')
|
||||
.trim()
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
export function buildReviewRiskItems(reviewPayload) {
|
||||
return resolveReviewRiskBriefs(reviewPayload)
|
||||
.map((brief, index) => {
|
||||
const title = String(brief?.title || '').trim()
|
||||
const content = String(brief?.content || '').trim()
|
||||
const detail = String(brief?.detail || '').trim()
|
||||
const suggestion = String(brief?.suggestion || '').trim()
|
||||
const level = normalizeReviewRiskLevel(brief?.level)
|
||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
|
||||
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
|
||||
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
|
||||
const summary = content || normalizedTitle
|
||||
|
||||
if (!normalizedTitle && !summary) return null
|
||||
|
||||
return {
|
||||
key: `${level}-${normalizedTitle}-${index}`,
|
||||
title: normalizedTitle,
|
||||
summary,
|
||||
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
|
||||
level,
|
||||
levelLabel: meta.label,
|
||||
icon: meta.icon,
|
||||
sourceLabel: meta.label,
|
||||
suggestion: suggestion || meta.suggestion
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildReviewRiskConversationText(item, detailTarget = {}) {
|
||||
const title = String(item?.title || '风险提示').trim()
|
||||
const summary = String(item?.summary || '').trim()
|
||||
const detail = String(item?.detail || '').trim()
|
||||
const suggestion = String(item?.suggestion || '').trim()
|
||||
const isInfo = String(item?.level || '').trim() === 'info'
|
||||
const detailHref = String(detailTarget?.href || '').trim()
|
||||
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
|
||||
const lines = [`${title}`]
|
||||
|
||||
if (summary) {
|
||||
lines.push('', `${isInfo ? '提示内容' : '风险点'}:${summary}`)
|
||||
}
|
||||
if (detail && detail !== summary) {
|
||||
lines.push('', `规则依据:${detail}`)
|
||||
}
|
||||
if (suggestion) {
|
||||
lines.push('', `${isInfo ? '处理建议' : '修改建议'}:${suggestion}`)
|
||||
}
|
||||
if (detailHref) {
|
||||
lines.push('', `[${detailLabel}](${detailHref})`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function buildReviewMainMessageText(message) {
|
||||
const text = String(message?.text || '')
|
||||
if (!message?.reviewPayload) {
|
||||
return text
|
||||
}
|
||||
return text
|
||||
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
191
web/src/views/scripts/travelReimbursementFlowTiming.js
Normal file
191
web/src/views/scripts/travelReimbursementFlowTiming.js
Normal file
@@ -0,0 +1,191 @@
|
||||
export const FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS = 3000
|
||||
|
||||
const FLOW_DURATION_MS_FIELDS = [
|
||||
'duration_ms',
|
||||
'elapsed_ms',
|
||||
'latency_ms',
|
||||
'total_duration_ms',
|
||||
'execution_time_ms'
|
||||
]
|
||||
const FLOW_DURATION_SECOND_FIELDS = [
|
||||
'duration_seconds',
|
||||
'elapsed_seconds',
|
||||
'latency_seconds',
|
||||
'execution_time_seconds'
|
||||
]
|
||||
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
|
||||
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
|
||||
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
|
||||
|
||||
export function formatFlowDuration(ms) {
|
||||
if (ms === null || ms === undefined || ms === '') {
|
||||
return '--'
|
||||
}
|
||||
const numericValue = Number(ms)
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return '--'
|
||||
}
|
||||
if (numericValue < 1000) {
|
||||
return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
if (numericValue < 10000) {
|
||||
return `${(numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
return `${Math.round(numericValue / 1000)}s`
|
||||
}
|
||||
|
||||
function parseFlowTimestamp(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 0
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 0 && value < 10000000000 ? Math.round(value * 1000) : Math.round(value)
|
||||
}
|
||||
const timestamp = new Date(value).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
function normalizeDurationValue(value, unit = 'ms') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
let numericValue = Number(value)
|
||||
let normalizedUnit = unit
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim()
|
||||
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|毫秒|s|秒)?$/i)
|
||||
if (match) {
|
||||
numericValue = Number(match[1])
|
||||
if (match[2]) {
|
||||
normalizedUnit = ['s', '秒'].includes(match[2].toLowerCase()) ? 'seconds' : 'ms'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return null
|
||||
}
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return Math.round(numericValue * 1000)
|
||||
}
|
||||
if (normalizedUnit === 'auto') {
|
||||
return Math.round(numericValue <= 300 ? numericValue * 1000 : numericValue)
|
||||
}
|
||||
return Math.round(numericValue)
|
||||
}
|
||||
|
||||
function readFirstDurationField(source, fields, unit) {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return null
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, field)) {
|
||||
continue
|
||||
}
|
||||
const durationMs = normalizeDurationValue(source[field], unit)
|
||||
if (durationMs) {
|
||||
return durationMs
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDurationFromFields(source) {
|
||||
return (
|
||||
readFirstDurationField(source, FLOW_DURATION_MS_FIELDS, 'ms')
|
||||
|| readFirstDurationField(source, FLOW_DURATION_SECOND_FIELDS, 'seconds')
|
||||
|| readFirstDurationField(source, FLOW_DURATION_AUTO_FIELDS, 'auto')
|
||||
)
|
||||
}
|
||||
|
||||
function readFirstTimestampField(source, fields) {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return 0
|
||||
}
|
||||
for (const field of fields) {
|
||||
const timestamp = parseFlowTimestamp(source[field])
|
||||
if (timestamp) {
|
||||
return timestamp
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function resolveStartedTimestamp(source) {
|
||||
return readFirstTimestampField(source, FLOW_STARTED_AT_FIELDS)
|
||||
}
|
||||
|
||||
export function resolveFinishedTimestamp(source) {
|
||||
return readFirstTimestampField(source, FLOW_FINISHED_AT_FIELDS)
|
||||
}
|
||||
|
||||
function resolveTimeRangeDurationMs(source) {
|
||||
const startedAt = resolveStartedTimestamp(source)
|
||||
const finishedAt = resolveFinishedTimestamp(source)
|
||||
return finishedAt > startedAt ? finishedAt - startedAt : null
|
||||
}
|
||||
|
||||
export function resolveSemanticPhaseDurations(run) {
|
||||
const runStart = resolveStartedTimestamp(run)
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const firstToolStartedAt = toolCalls
|
||||
.map((item) => resolveStartedTimestamp(item))
|
||||
.filter((value) => value > 0)
|
||||
.sort((left, right) => left - right)[0] || 0
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
|
||||
|
||||
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
|
||||
return { intentMs: null, extractionMs: null }
|
||||
}
|
||||
|
||||
const totalMs = semanticFinishedAt - runStart
|
||||
const intentMs = Math.max(120, Math.round(totalMs * 0.35))
|
||||
const extractionMs = Math.max(160, totalMs - intentMs)
|
||||
return {
|
||||
intentMs,
|
||||
extractionMs
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const explicitDuration = resolveDurationFromFields(toolCall)
|
||||
|| resolveTimeRangeDurationMs(toolCall)
|
||||
|| resolveDurationFromFields(response)
|
||||
|| resolveTimeRangeDurationMs(response)
|
||||
if (explicitDuration) {
|
||||
return explicitDuration
|
||||
}
|
||||
|
||||
const startedAt = resolveStartedTimestamp(toolCall)
|
||||
if (!startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextStartedAt = resolveStartedTimestamp(toolCalls[index + 1])
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
|
||||
|
||||
if (!finishedAt || finishedAt <= startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return finishedAt - startedAt
|
||||
}
|
||||
|
||||
export function summarizeVisibleToolText(value) {
|
||||
const text = String(value || '')
|
||||
.replace(/\|[^\n]*\|/g, '')
|
||||
.replace(/\*\*/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) || ''
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
return text.length > 80 ? `${text.slice(0, 80)}...` : text
|
||||
}
|
||||
180
web/src/views/scripts/travelReimbursementFlowToolModel.js
Normal file
180
web/src/views/scripts/travelReimbursementFlowToolModel.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import { summarizeVisibleToolText } from './travelReimbursementFlowTiming.js'
|
||||
|
||||
export function isSubmittedApplicationPayload(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: payload?.draft_payload && typeof payload.draft_payload === 'object'
|
||||
? payload.draft_payload
|
||||
: null
|
||||
return Boolean(
|
||||
draftPayload
|
||||
&& String(draftPayload.draft_type || '').trim() === 'expense_application'
|
||||
&& String(draftPayload.status || '').trim() === 'submitted'
|
||||
)
|
||||
}
|
||||
|
||||
export function isDuplicateApplicationPayload(payload, { applicationSessionActive = false } = {}) {
|
||||
if (!applicationSessionActive) {
|
||||
return false
|
||||
}
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const answer = String(result.answer || result.message || '').trim()
|
||||
return answer.includes('已存在申请单') && answer.includes('系统没有重复创建')
|
||||
}
|
||||
|
||||
export function buildApplicationSubmitSuccessDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: {}
|
||||
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||
const approvalStage = String(draftPayload.approval_stage || '').trim() || '直属领导审批'
|
||||
return claimNo
|
||||
? `申请单 ${claimNo} 已提交成功,当前节点:${approvalStage}`
|
||||
: `申请单提交成功,当前节点:${approvalStage}`
|
||||
}
|
||||
|
||||
export function buildApplicationDuplicateDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const answer = String(result.answer || result.message || '').trim()
|
||||
const claimNo = answer.match(/A[A-HJ-NP-Z2-9]{8}|AP-\d{14}-[A-HJ-NP-Z2-9]{8}|APP-\d{8}-[A-Z0-9]{6}/)?.[0] || ''
|
||||
return claimNo
|
||||
? `已拦截重复申请,已有申请单:${claimNo}`
|
||||
: '已拦截重复申请,未创建新申请单'
|
||||
}
|
||||
|
||||
export function isSavedReimbursementDraftPayload(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: payload?.draft_payload && typeof payload.draft_payload === 'object'
|
||||
? payload.draft_payload
|
||||
: null
|
||||
return Boolean(
|
||||
draftPayload
|
||||
&& String(draftPayload.status || '').trim() === 'draft'
|
||||
&& String(draftPayload.draft_type || '').trim() !== 'expense_application'
|
||||
)
|
||||
}
|
||||
|
||||
export function summarizeDraftRiskReviewDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const reviewPayload = result.review_payload && typeof result.review_payload === 'object'
|
||||
? result.review_payload
|
||||
: {}
|
||||
const riskCount = Array.isArray(reviewPayload.risk_briefs)
|
||||
? reviewPayload.risk_briefs.length
|
||||
: Array.isArray(result.risk_flags)
|
||||
? result.risk_flags.length
|
||||
: 0
|
||||
const missingCount = Array.isArray(reviewPayload.missing_slots)
|
||||
? reviewPayload.missing_slots.length
|
||||
: 0
|
||||
const issueParts = []
|
||||
if (riskCount) {
|
||||
issueParts.push(`${riskCount} 条风险/异常提醒`)
|
||||
}
|
||||
if (missingCount) {
|
||||
issueParts.push(`${missingCount} 项待补充信息`)
|
||||
}
|
||||
if (issueParts.length) {
|
||||
return `已完成草稿规则校验,识别到 ${issueParts.join('、')},可进入详情核对后继续提交。`
|
||||
}
|
||||
return '已完成草稿规则校验,暂未发现明确风险;可继续上传票据或进入详情核对。'
|
||||
}
|
||||
|
||||
function shouldHideToolCall(toolCall) {
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
return (
|
||||
toolName.includes('semantic_ontology')
|
||||
|| toolName.includes('ontology.')
|
||||
|| toolType.includes('semantic_ontology')
|
||||
|| toolType.includes('ontology')
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveToolCallFlowMeta(toolCall, index, { applicationSessionActive = false } = {}) {
|
||||
if (shouldHideToolCall(toolCall)) {
|
||||
return null
|
||||
}
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const responseMessage = String(response.message || '').trim()
|
||||
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
||||
if (
|
||||
applicationSessionActive
|
||||
&& (
|
||||
String(response.status || '').trim() === 'submitted'
|
||||
|| String(response?.draft_payload?.status || '').trim() === 'submitted'
|
||||
)
|
||||
) {
|
||||
return { key: 'application-submit-success', title: '申请单提交成功', tool: 'ApplicationSubmit' }
|
||||
}
|
||||
if (toolType.includes('rule')) {
|
||||
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
||||
}
|
||||
if (toolType.includes('mcp')) {
|
||||
return toolName.includes('standard')
|
||||
? { key, title: '差旅补助标准查询', tool: 'TravelStandard' }
|
||||
: null
|
||||
}
|
||||
if (toolName.includes('knowledge')) {
|
||||
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
||||
}
|
||||
if (toolName.includes('application_review_preview')) {
|
||||
return { key: 'application-review-preview', title: '申请信息核对', tool: 'ApplicationReview' }
|
||||
}
|
||||
if (toolName.includes('expense_review_preview') || response.preview_only) {
|
||||
return { key: 'expense-review-preview', title: '报销信息核对', tool: 'ExpenseReview' }
|
||||
}
|
||||
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
||||
if (
|
||||
response.submission_blocked ||
|
||||
String(response.status || '').trim() === 'submitted' ||
|
||||
responseMessage.includes('AI预审') ||
|
||||
responseMessage.includes('自动检测') ||
|
||||
responseMessage.includes('审批')
|
||||
) {
|
||||
return { key: 'pre-submit-review', title: '自动检测与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
||||
}
|
||||
if (responseMessage.includes('关联')) {
|
||||
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (responseMessage.includes('新建')) {
|
||||
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function summarizeFlowToolCall(toolCall, { applicationSessionActive = false } = {}) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
if (toolName.includes('application_review_preview')) {
|
||||
return '已整理申请核对信息'
|
||||
}
|
||||
if (toolName.includes('expense_review_preview') || response.preview_only) {
|
||||
return '已整理报销核对信息'
|
||||
}
|
||||
if (String(response.status || '').trim() === 'submitted') {
|
||||
return applicationSessionActive
|
||||
? '申请单提交成功'
|
||||
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
}
|
||||
if (response.submission_blocked) {
|
||||
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'
|
||||
}
|
||||
return (
|
||||
summarizeVisibleToolText(response.message || response.summary || response.result_summary)
|
||||
|| String(toolCall?.tool_name || '').trim()
|
||||
|| '工具调用完成'
|
||||
)
|
||||
}
|
||||
722
web/src/views/scripts/travelReimbursementReviewDisplayModel.js
Normal file
722
web/src/views/scripts/travelReimbursementReviewDisplayModel.js
Normal file
@@ -0,0 +1,722 @@
|
||||
import { REVIEW_SLOT_CONFIG } from './travelReimbursementReviewConstants.js'
|
||||
import {
|
||||
formatConfidenceLabel,
|
||||
resolveExpenseTypeLabel
|
||||
} from './travelReimbursementReviewDocuments.js'
|
||||
import {
|
||||
buildReviewSlotMap,
|
||||
formatAmountDisplay,
|
||||
inferPresetSceneFromReview,
|
||||
parseAmountNumber,
|
||||
resolveExpenseTypeCode,
|
||||
resolveReviewExtraMissingLabels,
|
||||
resolveReviewMissingSlotCards,
|
||||
resolveReviewRecognizedSlotCards
|
||||
} from './travelReimbursementReviewFormModel.js'
|
||||
|
||||
function resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver = null) {
|
||||
if (typeof riskBriefResolver === 'function') {
|
||||
return riskBriefResolver(reviewPayload)
|
||||
}
|
||||
return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []
|
||||
}
|
||||
|
||||
export function buildClientTimeContext() {
|
||||
const now = new Date()
|
||||
const locale =
|
||||
typeof navigator !== 'undefined' && typeof navigator.language === 'string'
|
||||
? navigator.language
|
||||
: 'zh-CN'
|
||||
|
||||
return {
|
||||
client_now_iso: now.toISOString(),
|
||||
client_timezone_offset_minutes: now.getTimezoneOffset(),
|
||||
client_locale: locale
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function formatDraftApplyTime(date = new Date()) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function formatDateInputValue(date = new Date()) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildDraftSavedPayload({
|
||||
draftPayload,
|
||||
reviewPayload,
|
||||
inlineState,
|
||||
linkedRequest,
|
||||
currentUser,
|
||||
riskItems = []
|
||||
}) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
|
||||
const typeCode = resolveExpenseTypeCode(inlineState?.expense_type)
|
||||
const amountNumber = parseAmountNumber(inlineState?.amount)
|
||||
const location = String(inlineState?.location || linkedRequest?.city || '').trim()
|
||||
const customerName = String(inlineState?.customer_name || '').trim()
|
||||
const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim()
|
||||
const title =
|
||||
String(inlineState?.reason_value || '').trim()
|
||||
|| String(inlineState?.scene_label || '').trim()
|
||||
|| String(draftPayload?.title || '').trim()
|
||||
|| `${typeLabel}报销草稿`
|
||||
const sceneLabel =
|
||||
String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel
|
||||
const attachmentSummary = documents.length
|
||||
? `${documents.length} 条识别票据 / ${documents.length} 份材料`
|
||||
: String(inlineState?.attachment_names || '').trim()
|
||||
? '1 条识别票据 / 1 份材料'
|
||||
: '待上传票据'
|
||||
|
||||
return {
|
||||
claimId: String(draftPayload?.claim_id || '').trim(),
|
||||
claimNo: String(draftPayload?.claim_no || '').trim(),
|
||||
status: String(draftPayload?.status || '').trim(),
|
||||
approvalStage: String(draftPayload?.approval_stage || '').trim(),
|
||||
person: String(currentUser?.name || '').trim() || '当前用户',
|
||||
dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门',
|
||||
entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.',
|
||||
typeCode,
|
||||
typeLabel,
|
||||
detailVariant: typeCode === 'travel' ? 'travel' : 'general',
|
||||
title,
|
||||
sceneLabel,
|
||||
sceneTarget: location || customerName || '待补充',
|
||||
location,
|
||||
relatedCustomer: customerName,
|
||||
occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充',
|
||||
applyTime: formatDraftApplyTime(),
|
||||
amount: amountNumber === null ? 0 : amountNumber,
|
||||
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
|
||||
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
|
||||
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
|
||||
riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
|
||||
attachmentSummary,
|
||||
expenseTableSummary: documents.length
|
||||
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
|
||||
: '当前尚未上传票据,请在报销页继续补充附件',
|
||||
note: String(draftPayload?.status || '').trim() === 'submitted'
|
||||
? '该报销单已由 AI 工作台提交审批,可在个人报销页面持续跟踪进度。'
|
||||
: '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function countReviewPendingItems(reviewPayload) {
|
||||
return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function countReviewRiskItems(reviewPayload, riskBriefResolver = null) {
|
||||
return resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver).length
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewHeadline(reviewPayload) {
|
||||
if (countReviewPendingItems(reviewPayload)) {
|
||||
return '待补充信息'
|
||||
}
|
||||
if (reviewPayload?.can_proceed) {
|
||||
return '识别结果已整理完成'
|
||||
}
|
||||
return '识别结果摘要'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewSubline(reviewPayload) {
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
|
||||
if (pendingCount) {
|
||||
return `我已把 ${pendingCount} 项待补充内容整理成文字说明,请先核查。`
|
||||
}
|
||||
if (reviewPayload?.can_proceed) {
|
||||
return '当前关键信息已基本齐全,确认无误后可以继续下一步。'
|
||||
}
|
||||
return '已为您整理本轮识别结果,请核查当前识别摘要。'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewStateLabel(reviewPayload) {
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
if (pendingCount) return `待补充 ${pendingCount} 项`
|
||||
if (reviewPayload?.can_proceed) return '可继续处理'
|
||||
return '已识别'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewStateTone(reviewPayload) {
|
||||
return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload)
|
||||
? 'ready'
|
||||
: 'pending'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewDisclosureTitle(reviewPayload) {
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
if (pendingCount) {
|
||||
return `当前有 ${pendingCount} 项待补充,点击展开查看`
|
||||
}
|
||||
return '当前信息已齐全,可展开查看识别摘要'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewDisclosureHint(reviewPayload) {
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
if (pendingCount) {
|
||||
return '展开后可查看待补充字段和处理建议'
|
||||
}
|
||||
return '展开后可查看本轮已识别的关键信息'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function shouldOpenReviewDisclosure(reviewPayload) {
|
||||
return !countReviewPendingItems(reviewPayload)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewTodoSectionTitle(reviewPayload) {
|
||||
return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewTodoSectionMeta(reviewPayload) {
|
||||
const count = buildReviewTodoItems(reviewPayload).length
|
||||
if (countReviewPendingItems(reviewPayload)) {
|
||||
return count ? `${count} 项` : '待确认'
|
||||
}
|
||||
return count ? `${count} 项` : '已齐全'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') {
|
||||
if (slotKey === 'customer_name') {
|
||||
return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户'
|
||||
}
|
||||
if (slotKey === 'participants') return '缺少同行人员'
|
||||
if (slotKey === 'attachments') return '票据状态待补充'
|
||||
if (slotKey === 'amount') return '金额待确认'
|
||||
if (slotKey === 'time_range') return '发生时间待确认'
|
||||
if (slotKey === 'reason') return '场景 / 事由待补充'
|
||||
if (slotKey === 'expense_type') return '报销类型待确认'
|
||||
if (slotKey === 'location') return '业务地点待补充'
|
||||
if (slotKey === 'merchant_name') return '酒店/商户待补充'
|
||||
return '仍有信息待补充'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewAlertChips(reviewPayload, riskBriefResolver = null) {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim()
|
||||
const chips = []
|
||||
|
||||
for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) {
|
||||
chips.push({
|
||||
key: item.key,
|
||||
label: buildReviewAlertLabel(item.key, expenseTypeLabel),
|
||||
tone: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
if (chips.length < 3) {
|
||||
for (const label of resolveReviewExtraMissingLabels(reviewPayload)) {
|
||||
chips.push({
|
||||
key: label,
|
||||
label,
|
||||
tone: 'warning'
|
||||
})
|
||||
if (chips.length >= 3) break
|
||||
}
|
||||
}
|
||||
|
||||
if (chips.length < 3) {
|
||||
for (const risk of resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver)) {
|
||||
if (chips.some((item) => item.label === risk.title)) continue
|
||||
chips.push({
|
||||
key: risk.title,
|
||||
label: risk.title,
|
||||
tone: risk.level === 'high' ? 'danger' : 'warning'
|
||||
})
|
||||
if (chips.length >= 3) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!chips.length && reviewPayload?.can_proceed) {
|
||||
chips.push({
|
||||
key: 'ready',
|
||||
label: '当前识别信息已可继续处理',
|
||||
tone: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewTodoItems(reviewPayload) {
|
||||
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
|
||||
const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload)
|
||||
if (missingItems.length || extraMissingLabels.length) {
|
||||
return [
|
||||
...missingItems.map((item) => {
|
||||
const config = REVIEW_SLOT_CONFIG[item.key] || {}
|
||||
return {
|
||||
key: item.key,
|
||||
icon: config.icon || 'mdi mdi-form-select',
|
||||
title: config.title || item.label,
|
||||
hint: item.hint || config.hint || `请补充${item.label}`,
|
||||
status: config.status || '待补充',
|
||||
tone: 'warning'
|
||||
}
|
||||
}),
|
||||
...extraMissingLabels.map((label, index) => ({
|
||||
key: `extra-missing-${index}-${label}`,
|
||||
icon: label.includes('酒店') || label.includes('住宿') ? 'mdi mdi-bed-outline' : 'mdi mdi-file-alert-outline',
|
||||
title: label,
|
||||
hint: label.includes('必须')
|
||||
? '该票据属于当前差旅提交的必备材料,补齐后才能继续下一步。'
|
||||
: '可以继续补充该材料;如暂时没有,也可以按当前信息处理。',
|
||||
status: label.includes('必须') ? '必须补齐' : '可选补充',
|
||||
tone: 'warning'
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
return resolveReviewRecognizedSlotCards(reviewPayload)
|
||||
.filter((item) => String(item?.value || '').trim())
|
||||
.slice(0, 3)
|
||||
.map((item) => {
|
||||
const config = REVIEW_SLOT_CONFIG[item.key] || {}
|
||||
return {
|
||||
key: item.key,
|
||||
icon: config.icon || 'mdi mdi-check-circle-outline',
|
||||
title: config.title || item.label,
|
||||
hint: `已识别:${item.value}`,
|
||||
status: '已识别',
|
||||
tone: 'ready'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const REVIEW_PENDING_HINT_COPY = {
|
||||
expense_type: '请选择本次报销分类,后续票据会按这个分类继续核对。',
|
||||
customer_name: '请补充客户单位全称。',
|
||||
time_range: '请补充业务发生日期或时间范围。',
|
||||
location: '请补充业务发生地点。',
|
||||
merchant_name: '请补充酒店或商户名称。',
|
||||
amount: '请补充本次费用金额。',
|
||||
reason: '请补充本次费用场景或事由。',
|
||||
participants: '请至少填写 1 名同行人员。',
|
||||
attachments: '请上传或关联对应票据附件。'
|
||||
}
|
||||
|
||||
function normalizeReviewFollowupSentence(text) {
|
||||
const normalized = String(text || '')
|
||||
.replace(/^已识别[::]\s*/, '')
|
||||
.replace(/^建议补充\s*/, '请补充')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!normalized) return ''
|
||||
return /[。!?.!?]$/.test(normalized) ? normalized : `${normalized}。`
|
||||
}
|
||||
|
||||
function buildReviewPlainFollowupItem(item, pendingMode) {
|
||||
const key = String(item?.key || '').trim()
|
||||
const label = String(item?.title || item?.label || '').trim() || '待核查信息'
|
||||
if (pendingMode) {
|
||||
return {
|
||||
key: key || label,
|
||||
label,
|
||||
text: normalizeReviewFollowupSentence(REVIEW_PENDING_HINT_COPY[key] || item?.hint || `请补充${label}`)
|
||||
}
|
||||
}
|
||||
|
||||
const value = normalizeReviewFollowupSentence(item?.hint || '')
|
||||
return {
|
||||
key: key || label,
|
||||
label,
|
||||
text: value || '已识别,请核查是否准确。'
|
||||
}
|
||||
}
|
||||
|
||||
const REVIEW_PENDING_SUMMARY_TEMPLATES = [
|
||||
({ issueSummary }) => `当前还有 ${issueSummary}。请核查对话中的文字说明;如果想先暂存,也可以点击对话文字中的“草稿”。`,
|
||||
({ issueSummary }) => `我这边看到还有 ${issueSummary},建议先把下方内容核对一下;暂时不处理也没关系,可以点击“草稿”先保存。`,
|
||||
({ issueSummary }) => `下方还有 ${issueSummary},需要你确认。信息没补齐前可以先核查说明,后续需要暂存时点“草稿”。`,
|
||||
({ issueSummary }) => `这笔报销还有 ${issueSummary},尚未完全确认。请先看一下下面的补充项;需要中途保存时,可以点“草稿”。`,
|
||||
({ issueSummary }) => `目前还有 ${issueSummary}。你可以先按下面的提示补充,也可以稍后再处理,点击“草稿”即可暂存当前信息。`,
|
||||
({ issueSummary }) => `还有 ${issueSummary},建议先核对下面说明;如果票据或金额暂时不全,可以通过“草稿”保留当前进度。`,
|
||||
({ issueSummary }) => `这次识别结果里还有 ${issueSummary}。请重点看下面几项,暂不提交时可以点“草稿”保存。`,
|
||||
({ issueSummary }) => `我还需要你确认 ${issueSummary}。下面列出了具体内容;如果现在不方便补齐,可以先点“草稿”。`,
|
||||
({ issueSummary }) => `当前还有 ${issueSummary},需要进一步处理。请根据下面提示核查,待补充完再继续;临时保存可点击“草稿”。`,
|
||||
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
|
||||
]
|
||||
|
||||
const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [
|
||||
({ issueSummary }) => `当前还有 ${issueSummary}。草稿已保存,后续上传票据时请关联这张草稿,补齐后再继续提交审批。`,
|
||||
({ issueSummary }) => `这张草稿仍有 ${issueSummary} 需要补充。您可以继续上传或关联票据,系统会归集到已保存草稿中。`,
|
||||
({ issueSummary }) => `草稿已生成,当前还差 ${issueSummary}。请按下方提示补充字段或票据,完整后再进入下一步。`,
|
||||
({ issueSummary }) => `草稿已经留存,下面还有 ${issueSummary} 待处理。新增附件请关联当前草稿,避免重复建单。`,
|
||||
({ issueSummary }) => `当前草稿还有 ${issueSummary}。建议先补齐金额、票据等信息,再从草稿详情继续提交审批。`,
|
||||
({ issueSummary }) => `已保留当前进度,这笔草稿还需要 ${issueSummary}。后续补充内容会作为该草稿的更新处理。`,
|
||||
({ issueSummary }) => `这张单据已进入草稿状态,仍有 ${issueSummary}。请继续补充必要信息,补齐后再发起正式提交。`,
|
||||
({ issueSummary }) => `草稿保存完成后,当前还剩 ${issueSummary}。上传附件时请选择关联这张草稿,系统会继续合并识别结果。`,
|
||||
({ issueSummary }) => `当前草稿待完善:${issueSummary}。请先处理下方项目,确认完整后再继续下一步。`,
|
||||
({ issueSummary }) => `这笔草稿还存在 ${issueSummary}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。`
|
||||
]
|
||||
|
||||
function buildStableTemplateIndex(signature, total) {
|
||||
const source = String(signature || '')
|
||||
let hash = 0
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
hash = ((hash << 5) - hash + source.charCodeAt(index)) >>> 0
|
||||
}
|
||||
return total ? hash % total : 0
|
||||
}
|
||||
|
||||
function buildReviewPendingSummary(pendingCount, riskCount, signature = '', options = {}) {
|
||||
const issueParts = []
|
||||
if (pendingCount) {
|
||||
issueParts.push(`${pendingCount} 项信息待补充`)
|
||||
}
|
||||
if (riskCount) {
|
||||
issueParts.push(`${riskCount} 条风险提醒`)
|
||||
}
|
||||
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
|
||||
const templates = options.savedDraft
|
||||
? REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES
|
||||
: REVIEW_PENDING_SUMMARY_TEMPLATES
|
||||
const templateIndex = buildStableTemplateIndex(signature || issueSummary, templates.length)
|
||||
return templates[templateIndex]({ issueSummary })
|
||||
}
|
||||
|
||||
export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
|
||||
const savedDraft = Boolean(options?.savedDraft)
|
||||
const todoItems = buildReviewTodoItems(reviewPayload)
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
|
||||
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
|
||||
|
||||
if (savedDraft) {
|
||||
const issueParts = []
|
||||
if (riskBriefs.length) {
|
||||
issueParts.push(`${riskBriefs.length} 条风险/异常提醒`)
|
||||
}
|
||||
if (pendingCount || extraMissingCount) {
|
||||
issueParts.push(`${pendingCount || extraMissingCount} 项待补充信息`)
|
||||
}
|
||||
return {
|
||||
lead: '后续处理:',
|
||||
tone: riskBriefs.length || pendingCount || extraMissingCount ? 'danger' : 'neutral',
|
||||
summary: issueParts.length
|
||||
? `自动检测识别到 ${issueParts.join('、')},请进入详情核对;如还有票据可继续上传。`
|
||||
: '自动检测暂未发现明确风险;如还有票据可继续上传。',
|
||||
items: [],
|
||||
notes: []
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingCount || extraMissingCount) {
|
||||
const summarySignature = [
|
||||
pendingCount || extraMissingCount,
|
||||
riskBriefs.length,
|
||||
...todoItems.map((item) => `${item.key}:${item.title}:${item.status}`)
|
||||
].join('|')
|
||||
return {
|
||||
lead: '补充信息:',
|
||||
tone: 'danger',
|
||||
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature, {
|
||||
savedDraft
|
||||
}),
|
||||
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
|
||||
notes: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
|
||||
tone: 'neutral',
|
||||
summary: '',
|
||||
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)),
|
||||
notes: [
|
||||
reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '',
|
||||
riskBriefs.length ? `系统同时保留了 ${riskBriefs.length} 条风险提醒,请在提交前核查。` : ''
|
||||
].filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function resolveReviewPrimaryAction(reviewPayload) {
|
||||
return (
|
||||
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
|
||||
(item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || ''))
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function resolveReviewSaveDraftAction(reviewPayload) {
|
||||
return (
|
||||
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
|
||||
(item) => String(item?.action_type || '') === 'save_draft'
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function resolveReviewFooterActions(reviewPayload) {
|
||||
return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => {
|
||||
const actionType = String(item?.action_type || '').trim()
|
||||
return ['link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewRiskLevelCounts(reviewPayload) {
|
||||
return (Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []).reduce(
|
||||
(counts, item) => {
|
||||
const level = normalizeReviewRiskLevel(item?.level)
|
||||
if (level === 'high' || level === 'medium' || level === 'low') {
|
||||
counts[level] += 1
|
||||
}
|
||||
return counts
|
||||
},
|
||||
{ low: 0, medium: 0, high: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function resolveReviewNextStepAction(reviewPayload) {
|
||||
return (
|
||||
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
|
||||
(item) => String(item?.action_type || '').trim() === 'next_step'
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewNextStepRichCopy(reviewPayload, { detailHref = '' } = {}) {
|
||||
const nextStepAction = resolveReviewNextStepAction(reviewPayload)
|
||||
if (!nextStepAction) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const counts = buildReviewRiskLevelCounts(reviewPayload)
|
||||
const riskSummary = `现存在 ${counts.low} 条低风险,${counts.medium} 条中风险,${counts.high} 条高风险,具体情况请看 [右侧](#review-risk-panel) 风险信息提示窗。`
|
||||
const lines = [`系统识别您的单据已经填写完所有已知信息,${riskSummary}`]
|
||||
|
||||
if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) {
|
||||
const editHref = String(detailHref || '').trim() || '#review-quick-edit'
|
||||
lines.push(
|
||||
`系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果你还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。`
|
||||
)
|
||||
}
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) {
|
||||
const action = resolveReviewPrimaryAction(reviewPayload)
|
||||
if (!action) return '确认'
|
||||
if (action.action_type === 'save_draft') {
|
||||
return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿'
|
||||
}
|
||||
if (action.action_type === 'next_step') {
|
||||
return '继续下一步'
|
||||
}
|
||||
if (action.action_type === 'link_to_existing_draft') {
|
||||
return action.label || '关联到现有草稿'
|
||||
}
|
||||
if (action.action_type === 'create_new_claim_from_documents') {
|
||||
return action.label || '单独建立报销单'
|
||||
}
|
||||
return action.label || '确认'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewIntentText(reviewPayload) {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const expenseType = String(slotMap.expense_type?.value || '').trim()
|
||||
if (expenseType) {
|
||||
return `报销一笔${expenseType}`
|
||||
}
|
||||
return '发起一笔报销'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewSceneValue(reviewPayload) {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim()
|
||||
const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim()
|
||||
return inferPresetSceneFromReview(reviewPayload, reason, expenseType)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
|
||||
if (slotKey === 'customer_name') {
|
||||
return expenseTypeLabel === '业务招待费'
|
||||
? '业务招待费需补充客户单位名称,以便进行合规校验。'
|
||||
: '当前仍缺少客户单位名称,建议补充后再提交。'
|
||||
}
|
||||
if (slotKey === 'participants') {
|
||||
return '缺少同行人员信息,建议补充至少 1 名。'
|
||||
}
|
||||
if (slotKey === 'attachments') {
|
||||
return '尚未上传票据附件,当前无法完成票据核对。'
|
||||
}
|
||||
if (slotKey === 'amount') {
|
||||
return '报销金额仍待确认,提交前需补齐金额信息。'
|
||||
}
|
||||
if (slotKey === 'time_range') {
|
||||
return '业务发生时间仍待确认,建议补充准确日期。'
|
||||
}
|
||||
if (slotKey === 'reason') {
|
||||
return '报销事由说明仍不完整,建议补充业务背景。'
|
||||
}
|
||||
return '当前仍有识别信息待补充,建议先核对后再处理。'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewRiskSummary(reviewPayload, riskBriefResolver = null) {
|
||||
if (resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver).length) {
|
||||
return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。'
|
||||
}
|
||||
return '当前没有需要额外处理的结构化风险点。'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function normalizeReviewRiskLevel(level) {
|
||||
const normalized = String(level || '').trim().toLowerCase()
|
||||
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
|
||||
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
|
||||
if (normalized === 'info' || normalized === 'notice') return 'info'
|
||||
if (normalized === 'low') return 'low'
|
||||
if (normalized === 'high') return normalized
|
||||
return 'low'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildLocalReviewCompletionMessage(reviewPayload) {
|
||||
const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : []
|
||||
if (reviewPayload?.can_proceed && !missingSlots.length) {
|
||||
return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。'
|
||||
}
|
||||
if (missingSlots.length) {
|
||||
return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}。`
|
||||
}
|
||||
return '当前信息已保存,可以继续核对右侧状态。'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewRecognitionNotes(reviewPayload) {
|
||||
const recognized = resolveReviewRecognizedSlotCards(reviewPayload)
|
||||
const notes = []
|
||||
const timeSlot = recognized.find((item) => item.key === 'time_range')
|
||||
const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))]
|
||||
|
||||
if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) {
|
||||
notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`)
|
||||
}
|
||||
|
||||
if (sourceLabels.length) {
|
||||
notes.push(`本轮主要依据:${sourceLabels.join('、')}`)
|
||||
}
|
||||
|
||||
const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
if (documentCards.length) {
|
||||
notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`)
|
||||
} else {
|
||||
notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别')
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewMissingHint(reviewPayload) {
|
||||
if (!countReviewPendingItems(reviewPayload)) {
|
||||
return ''
|
||||
}
|
||||
if (reviewPayload?.can_proceed) {
|
||||
return '当前关键信息已经齐全,这里无需再补充。'
|
||||
}
|
||||
return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewRiskHint(reviewPayload, riskBriefResolver = null) {
|
||||
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver)
|
||||
if (!riskBriefs.length) {
|
||||
return ''
|
||||
}
|
||||
return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewActionHint(reviewPayload) {
|
||||
if (reviewPayload?.can_proceed) {
|
||||
return '如果识别无误,可以继续下一步;如果有偏差,请直接在右侧核对信息中修改。'
|
||||
}
|
||||
return '如果现在信息还不完整,可以先保存草稿;识别错了请直接在右侧核对信息中修改。'
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildReviewStatusTag(reviewPayload) {
|
||||
const missingCount = countReviewPendingItems(reviewPayload)
|
||||
if (reviewPayload?.can_proceed) {
|
||||
return '可继续处理'
|
||||
}
|
||||
if (missingCount > 0) {
|
||||
return `待补充 ${missingCount} 项`
|
||||
}
|
||||
return '待确认'
|
||||
}
|
||||
585
web/src/views/scripts/travelReimbursementReviewFormModel.js
Normal file
585
web/src/views/scripts/travelReimbursementReviewFormModel.js
Normal file
@@ -0,0 +1,585 @@
|
||||
import { TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js'
|
||||
import {
|
||||
EXPENSE_CODE_TO_PRESET_SCENE,
|
||||
EXPENSE_TYPE_LABELS,
|
||||
REVIEW_SCENE_OPTIONS,
|
||||
REVIEW_SCENE_OTHER_OPTION
|
||||
} from './travelReimbursementReviewConstants.js'
|
||||
|
||||
export function cloneReviewEditFields(fields) {
|
||||
const items = Array.isArray(fields) ? fields : []
|
||||
return items.map((item) => ({
|
||||
key: String(item?.key || '').trim(),
|
||||
label: String(item?.label || '').trim(),
|
||||
value: String(item?.value || ''),
|
||||
placeholder: String(item?.placeholder || ''),
|
||||
required: Boolean(item?.required),
|
||||
field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text',
|
||||
group: String(item?.group || 'basic').trim() || 'basic'
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildReviewFormValues(fields) {
|
||||
return cloneReviewEditFields(fields).reduce((result, item) => {
|
||||
if (!item.key) {
|
||||
return result
|
||||
}
|
||||
result[item.key] = String(item.value || '').trim()
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
const ONTOLOGY_REVIEW_FIELD_ALIASES = {
|
||||
expense_type: ['reimbursement_type', 'scene_label', 'expenseType'],
|
||||
time_range: ['business_time', 'businessTime', 'occurred_date', 'occurredDate'],
|
||||
location: ['business_location', 'businessLocation'],
|
||||
reason: ['reason_value', 'reasonValue', 'business_reason', 'businessReason'],
|
||||
transport_mode: ['transport_type', 'transportType', 'transportMode', 'application_transport_mode', 'applicationTransportMode'],
|
||||
attachments: ['attachment_names', 'attachmentNames'],
|
||||
customer_name: ['customerName'],
|
||||
merchant_name: ['merchantName']
|
||||
}
|
||||
|
||||
const ONTOLOGY_REVIEW_CONTEXT_FIELDS = new Set([
|
||||
'expense_type',
|
||||
'time_range',
|
||||
'location',
|
||||
'reason',
|
||||
'amount',
|
||||
'transport_mode',
|
||||
'attachments',
|
||||
'customer_name',
|
||||
'merchant_name',
|
||||
'participants',
|
||||
'application_claim_id',
|
||||
'application_claim_no',
|
||||
'application_reason',
|
||||
'application_location',
|
||||
'application_amount',
|
||||
'application_amount_label',
|
||||
'application_business_time',
|
||||
'application_days',
|
||||
'application_transport_mode',
|
||||
'application_lodging_daily_cap',
|
||||
'application_subsidy_daily_cap',
|
||||
'application_transport_policy',
|
||||
'application_policy_estimate',
|
||||
'application_rule_name',
|
||||
'application_rule_version',
|
||||
'application_date'
|
||||
])
|
||||
|
||||
export function normalizeReviewFormValuesToOntology(values = {}) {
|
||||
const source = values && typeof values === 'object' ? values : {}
|
||||
const normalized = {}
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
const cleanedKey = String(key || '').trim()
|
||||
if (!cleanedKey) return
|
||||
normalized[cleanedKey] = String(value || '').trim()
|
||||
})
|
||||
|
||||
Object.entries(ONTOLOGY_REVIEW_FIELD_ALIASES).forEach(([canonicalKey, aliases]) => {
|
||||
if (normalized[canonicalKey]) return
|
||||
const matchedAlias = aliases.find((alias) => normalized[alias])
|
||||
if (matchedAlias) {
|
||||
normalized[canonicalKey] = normalized[matchedAlias]
|
||||
}
|
||||
})
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(normalized).filter(([key, value]) => ONTOLOGY_REVIEW_CONTEXT_FIELDS.has(key) && String(value || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
export function buildBusinessTimeContextFromReviewValues(values = {}) {
|
||||
const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim()
|
||||
if (!timeText) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matchedDates = timeText.match(/\d{4}-\d{2}-\d{2}/g) || []
|
||||
if (!matchedDates.length) {
|
||||
return null
|
||||
}
|
||||
const startDate = matchedDates[0]
|
||||
const endDate = matchedDates[matchedDates.length - 1] || startDate
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return null
|
||||
}
|
||||
const displayValue = startDate === endDate ? startDate : `${startDate} 至 ${endDate}`
|
||||
return {
|
||||
mode: startDate === endDate ? 'single' : 'range',
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
display_value: displayValue
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
|
||||
if (!reviewPayload || typeof reviewPayload !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const fallbackState = buildInlineReviewState(reviewPayload)
|
||||
const candidateState = inlineState || fallbackState
|
||||
const hasCandidateValue = Object.values(candidateState || {}).some((value) => {
|
||||
if (typeof value === 'number') return value > 0
|
||||
return Boolean(String(value || '').trim())
|
||||
})
|
||||
const state = hasCandidateValue ? candidateState : fallbackState
|
||||
const fields = mergeInlineReviewFields(reviewPayload.edit_fields || [], state)
|
||||
const values = buildReviewFormValues(fields)
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const inheritedTimeRange = String(
|
||||
slotMap.time_range?.normalized_value ||
|
||||
slotMap.time_range?.value ||
|
||||
values.time_range ||
|
||||
values.business_time ||
|
||||
values.occurred_date ||
|
||||
''
|
||||
).trim()
|
||||
if (inheritedTimeRange) {
|
||||
values.time_range = values.time_range || inheritedTimeRange
|
||||
}
|
||||
|
||||
const ontologyValues = normalizeReviewFormValuesToOntology(values)
|
||||
const businessTimeContext = buildBusinessTimeContextFromReviewValues(ontologyValues)
|
||||
return {
|
||||
review_form_values: ontologyValues,
|
||||
...(businessTimeContext ? { business_time_context: businessTimeContext } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReviewEditFieldMap(fields) {
|
||||
return cloneReviewEditFields(fields).reduce((result, item) => {
|
||||
if (!item.key) return result
|
||||
result[item.key] = item
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function createEmptyInlineReviewState() {
|
||||
return {
|
||||
occurred_date: '',
|
||||
amount: '',
|
||||
transport_type: '',
|
||||
scene_label: '',
|
||||
reason_value: '',
|
||||
customer_name: '',
|
||||
location: '',
|
||||
merchant_name: '',
|
||||
participants: '',
|
||||
attachment_names: '',
|
||||
attachment_count: 0,
|
||||
pending_attachment_count: 0,
|
||||
expense_type: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
const expenseType = resolveExpenseTypeCode(
|
||||
inlineState?.expense_type ||
|
||||
buildReviewSlotMap(reviewPayload).expense_type?.normalized_value ||
|
||||
buildReviewSlotMap(reviewPayload).expense_type?.value ||
|
||||
''
|
||||
)
|
||||
if (['travel', 'hotel'].includes(expenseType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => {
|
||||
const documentType = String(item?.document_type || '').trim().toLowerCase()
|
||||
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
|
||||
return (
|
||||
['flight_itinerary', 'train_ticket', 'hotel_invoice'].includes(documentType) ||
|
||||
['travel', 'hotel'].includes(suggestedType)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
const labels = []
|
||||
|
||||
const appendLabel = (label) => {
|
||||
if (label && !labels.includes(label)) {
|
||||
labels.push(label)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of documents) {
|
||||
const documentType = String(item?.document_type || '').trim().toLowerCase()
|
||||
const text = [
|
||||
item?.filename,
|
||||
item?.summary,
|
||||
item?.scene_label,
|
||||
item?.suggested_expense_type,
|
||||
...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : [])
|
||||
].join(' ')
|
||||
const compact = text.replace(/\s+/g, '')
|
||||
|
||||
if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) {
|
||||
appendLabel('飞机')
|
||||
} else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) {
|
||||
appendLabel('火车/高铁')
|
||||
} else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) {
|
||||
appendLabel('打车/网约车')
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = String(fallbackText || '').replace(/\s+/g, '')
|
||||
if (!labels.length) {
|
||||
if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机')
|
||||
if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁')
|
||||
if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车')
|
||||
}
|
||||
|
||||
return labels.join('、')
|
||||
}
|
||||
|
||||
export function resolveReviewRecognizedSlotCards(reviewPayload) {
|
||||
return Array.isArray(reviewPayload?.slot_cards)
|
||||
? reviewPayload.slot_cards.filter((item) => item.status !== 'missing')
|
||||
: []
|
||||
}
|
||||
|
||||
export function resolveReviewMissingSlotCards(reviewPayload) {
|
||||
return Array.isArray(reviewPayload?.slot_cards)
|
||||
? reviewPayload.slot_cards.filter((item) => item.status === 'missing')
|
||||
: []
|
||||
}
|
||||
|
||||
export function resolveReviewExtraMissingLabels(reviewPayload) {
|
||||
const labels = Array.isArray(reviewPayload?.missing_slots)
|
||||
? reviewPayload.missing_slots
|
||||
.map((item) => {
|
||||
if (item && typeof item === 'object') {
|
||||
return String(item.label || item.title || item.key || '').trim()
|
||||
}
|
||||
return String(item || '').trim()
|
||||
})
|
||||
.filter(Boolean)
|
||||
: []
|
||||
if (!labels.length) return []
|
||||
|
||||
const slotLabels = new Set(
|
||||
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).flatMap((item) => [
|
||||
String(item?.label || '').trim(),
|
||||
String(item?.key || '').trim()
|
||||
]).filter(Boolean)
|
||||
)
|
||||
return labels.filter((label) => !slotLabels.has(label))
|
||||
}
|
||||
|
||||
export function buildReviewRecognizedLines(reviewPayload) {
|
||||
return resolveReviewRecognizedSlotCards(reviewPayload)
|
||||
.filter((item) => String(item?.value || '').trim())
|
||||
.map((item) => `${item.label}:${item.value}`)
|
||||
}
|
||||
|
||||
export function buildReviewSlotMap(reviewPayload) {
|
||||
return Object.fromEntries(
|
||||
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item])
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveExpenseTypeCode(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return 'other'
|
||||
if (EXPENSE_TYPE_LABELS[normalized]) return normalized
|
||||
const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized)
|
||||
return matched?.[0] || 'other'
|
||||
}
|
||||
|
||||
export function isValidIsoDateString(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = normalized.split('-')
|
||||
const year = Number(yearText)
|
||||
const month = Number(monthText)
|
||||
const day = Number(dayText)
|
||||
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = new Date(Date.UTC(year, month - 1, day))
|
||||
return (
|
||||
candidate.getUTCFullYear() === year &&
|
||||
candidate.getUTCMonth() === month - 1 &&
|
||||
candidate.getUTCDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
export function parseAmountNumber(value) {
|
||||
const normalized = String(value || '')
|
||||
.replace(/[,,\s]/g, '')
|
||||
.replace(/[¥¥]/g, '')
|
||||
.replace(/元/g, '')
|
||||
.trim()
|
||||
|
||||
if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const amount = Number(normalized)
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
export function normalizeAmountValue(value) {
|
||||
const amount = parseAmountNumber(value)
|
||||
if (amount === null) {
|
||||
return ''
|
||||
}
|
||||
return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元`
|
||||
}
|
||||
|
||||
export function extractAmountInputValue(value) {
|
||||
const amount = parseAmountNumber(value)
|
||||
if (amount === null) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
export function formatAmountDisplay(value) {
|
||||
const amount = parseAmountNumber(value)
|
||||
if (amount === null) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
|
||||
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export function matchPresetSceneFromReason(reason) {
|
||||
const compactReason = String(reason || '').trim().replace(/\s+/g, '')
|
||||
if (!compactReason) {
|
||||
return ''
|
||||
}
|
||||
if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) {
|
||||
return '请客户吃饭'
|
||||
}
|
||||
if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) {
|
||||
const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, '')))
|
||||
if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) {
|
||||
return matchedPreset
|
||||
}
|
||||
}
|
||||
if (/出差|差旅/.test(compactReason)) {
|
||||
return '出差行程'
|
||||
}
|
||||
if (/酒店|住宿/.test(compactReason)) {
|
||||
return '住宿报销'
|
||||
}
|
||||
if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) {
|
||||
return '交通出行'
|
||||
}
|
||||
if (/会务|会议|参会|论坛|展会/.test(compactReason)) {
|
||||
return '会务活动'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') {
|
||||
const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)]
|
||||
if (fromCode) {
|
||||
return fromCode
|
||||
}
|
||||
|
||||
const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '')
|
||||
if (!compactLabel) {
|
||||
return ''
|
||||
}
|
||||
if (/差旅|出差/.test(compactLabel)) {
|
||||
return '出差行程'
|
||||
}
|
||||
if (/住宿|酒店/.test(compactLabel)) {
|
||||
return '住宿报销'
|
||||
}
|
||||
if (/交通/.test(compactLabel)) {
|
||||
return '交通出行'
|
||||
}
|
||||
if (/招待|餐饮|餐费|伙食/.test(compactLabel)) {
|
||||
return '请客户吃饭'
|
||||
}
|
||||
if (/会务|会议/.test(compactLabel)) {
|
||||
return '会务活动'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function mapExpenseTypeLabelToPresetScene(expenseType) {
|
||||
const code = resolveExpenseTypeCode(expenseType)
|
||||
if (EXPENSE_CODE_TO_PRESET_SCENE[code]) {
|
||||
return EXPENSE_CODE_TO_PRESET_SCENE[code]
|
||||
}
|
||||
|
||||
const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '')
|
||||
if (!compactLabel) {
|
||||
return ''
|
||||
}
|
||||
if (compactLabel.includes('差旅') || compactLabel.includes('出差')) {
|
||||
return '出差行程'
|
||||
}
|
||||
if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) {
|
||||
return '住宿报销'
|
||||
}
|
||||
if (compactLabel.includes('交通')) {
|
||||
return '交通出行'
|
||||
}
|
||||
if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) {
|
||||
return '请客户吃饭'
|
||||
}
|
||||
if (compactLabel.includes('会务') || compactLabel.includes('会议')) {
|
||||
return '会务活动'
|
||||
}
|
||||
return matchPresetSceneFromReason(expenseType)
|
||||
}
|
||||
|
||||
export function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
if (documents.length) {
|
||||
const votes = new Map()
|
||||
for (const document of documents) {
|
||||
const preset =
|
||||
mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type)
|
||||
|| mapExpenseTypeLabelToPresetScene(document.suggested_expense_type)
|
||||
if (!preset) {
|
||||
continue
|
||||
}
|
||||
votes.set(preset, (votes.get(preset) || 0) + 1)
|
||||
}
|
||||
if (votes.size) {
|
||||
return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0]
|
||||
}
|
||||
}
|
||||
|
||||
const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : []
|
||||
if (claimGroups.length === 1) {
|
||||
const group = claimGroups[0]
|
||||
const preset =
|
||||
mapExpenseTypeLabelToPresetScene(group.expense_type)
|
||||
|| mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type)
|
||||
if (preset) {
|
||||
return preset
|
||||
}
|
||||
}
|
||||
|
||||
const fromReason = matchPresetSceneFromReason(reasonValue)
|
||||
if (fromReason) {
|
||||
return fromReason
|
||||
}
|
||||
|
||||
const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType)
|
||||
if (fromExpenseType) {
|
||||
return fromExpenseType
|
||||
}
|
||||
|
||||
if (String(reasonValue || '').trim()) {
|
||||
return REVIEW_SCENE_OTHER_OPTION
|
||||
}
|
||||
return '待补充'
|
||||
}
|
||||
|
||||
export function formatReviewSceneDisplayValue(inlineState) {
|
||||
const scene = String(inlineState?.scene_label || '').trim()
|
||||
if (!scene || scene === '待补充') {
|
||||
return '待补充'
|
||||
}
|
||||
if (scene === REVIEW_SCENE_OTHER_OPTION) {
|
||||
const detail = String(inlineState?.reason_value || '').trim()
|
||||
if (!detail) {
|
||||
return REVIEW_SCENE_OTHER_OPTION
|
||||
}
|
||||
return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}:${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}:${detail}`
|
||||
}
|
||||
return scene
|
||||
}
|
||||
|
||||
export function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) {
|
||||
return inferPresetSceneFromReview(reviewPayload, reason, expenseType)
|
||||
}
|
||||
|
||||
export function buildInlineReviewState(reviewPayload) {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields)
|
||||
const attachmentNames = String(
|
||||
editFieldMap.attachment_names?.value ||
|
||||
slotMap.attachments?.value ||
|
||||
(Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '')
|
||||
).trim()
|
||||
const attachmentCount = Array.isArray(reviewPayload?.document_cards)
|
||||
? reviewPayload.document_cards.length
|
||||
: attachmentNames
|
||||
? attachmentNames.split('、').filter(Boolean).length
|
||||
: 0
|
||||
const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim()
|
||||
const reasonValue = String(
|
||||
editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
|
||||
).trim()
|
||||
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
|
||||
const transportType = String(
|
||||
editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue)
|
||||
).trim()
|
||||
|
||||
return {
|
||||
occurred_date: String(
|
||||
editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || ''
|
||||
).trim(),
|
||||
amount: normalizeAmountValue(
|
||||
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
|
||||
),
|
||||
transport_type: transportType,
|
||||
scene_label: sceneLabel,
|
||||
reason_value:
|
||||
sceneLabel === REVIEW_SCENE_OTHER_OPTION
|
||||
? reasonValue
|
||||
: String(slotMap.reason?.raw_value || '').trim() || reasonValue,
|
||||
customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(),
|
||||
location: String(
|
||||
editFieldMap.business_location?.value ||
|
||||
editFieldMap.location?.value ||
|
||||
slotMap.location?.normalized_value ||
|
||||
slotMap.location?.value ||
|
||||
''
|
||||
).trim(),
|
||||
merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(),
|
||||
participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(),
|
||||
attachment_names: attachmentNames,
|
||||
attachment_count: attachmentCount,
|
||||
pending_attachment_count: 0,
|
||||
expense_type: expenseType
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function mergeInlineReviewFields(baseFields, inlineState) {
|
||||
const merged = cloneReviewEditFields(baseFields)
|
||||
const updateMap = {
|
||||
expense_type: inlineState.expense_type,
|
||||
transport_type: inlineState.transport_type,
|
||||
occurred_date: inlineState.occurred_date,
|
||||
amount: inlineState.amount,
|
||||
customer_name: inlineState.customer_name,
|
||||
business_location: inlineState.location,
|
||||
merchant_name: inlineState.merchant_name,
|
||||
participants: inlineState.participants,
|
||||
reason: inlineState.reason_value || inlineState.scene_label,
|
||||
attachment_names: inlineState.attachment_names
|
||||
}
|
||||
|
||||
for (const item of merged) {
|
||||
if (!(item.key in updateMap)) continue
|
||||
item.value = String(updateMap[item.key] || '').trim()
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
352
web/src/views/scripts/travelReimbursementReviewSyncModel.js
Normal file
352
web/src/views/scripts/travelReimbursementReviewSyncModel.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
CATEGORY_CONFIDENCE_KEYWORDS,
|
||||
REVIEW_CATEGORY_PRESET_OPTIONS
|
||||
} from './travelReimbursementReviewConstants.js'
|
||||
import {
|
||||
buildReviewDocumentCorrectionLines,
|
||||
formatConfidenceLabel
|
||||
} from './travelReimbursementReviewDocuments.js'
|
||||
import {
|
||||
buildReviewSlotMap,
|
||||
cloneReviewEditFields,
|
||||
createEmptyInlineReviewState,
|
||||
formatAmountDisplay,
|
||||
mergeInlineReviewFields,
|
||||
resolveExpenseTypeCode,
|
||||
resolveReviewExtraMissingLabels
|
||||
} from './travelReimbursementReviewFormModel.js'
|
||||
|
||||
export function buildReviewAttachmentStatus(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
if (!documents.length) return '未上传'
|
||||
return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份`
|
||||
}
|
||||
|
||||
export function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const slot = slotMap[slotKey]
|
||||
return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing'
|
||||
}
|
||||
|
||||
export function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
|
||||
return [
|
||||
String(inlineState.reason_value || '').trim(),
|
||||
String(inlineState.scene_label || '').trim(),
|
||||
String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(),
|
||||
...documents.map((item) =>
|
||||
[item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function resolveReviewCategoryTextScore(text, categoryCode) {
|
||||
const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode]
|
||||
if (!patterns?.length || !text) {
|
||||
return 0
|
||||
}
|
||||
return patterns.some((pattern) => pattern.test(text))
|
||||
? {
|
||||
travel: 0.84,
|
||||
hotel: 0.82,
|
||||
transport: 0.8,
|
||||
meal: 0.76,
|
||||
meeting: 0.78,
|
||||
entertainment: 0.88,
|
||||
office: 0.74,
|
||||
training: 0.77,
|
||||
communication: 0.7,
|
||||
welfare: 0.72
|
||||
}[categoryCode] || 0
|
||||
: 0
|
||||
}
|
||||
|
||||
export function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
const matchedScores = documents
|
||||
.filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode)
|
||||
.map((item) => Number(item?.avg_score || 0))
|
||||
.filter((score) => Number.isFinite(score) && score > 0)
|
||||
|
||||
if (!matchedScores.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length
|
||||
}
|
||||
|
||||
export function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) {
|
||||
const normalizedLabel = String(selectedLabel || '').trim()
|
||||
if (!normalizedLabel) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const selectedCode = resolveExpenseTypeCode(normalizedLabel)
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const expenseSlot = slotMap.expense_type
|
||||
const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '')
|
||||
let score = 0
|
||||
|
||||
if (recognizedCode === selectedCode) {
|
||||
score = Math.max(score, Number(expenseSlot?.confidence || 0))
|
||||
}
|
||||
|
||||
score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode))
|
||||
score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode))
|
||||
|
||||
if (!score && normalizedLabel) {
|
||||
score = selectedCode === 'other' ? 0.52 : 0.58
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(0.98, Number(score.toFixed(2))))
|
||||
}
|
||||
|
||||
export function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) {
|
||||
const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label)
|
||||
return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({
|
||||
...item,
|
||||
active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel,
|
||||
confidenceLabel: item.is_other
|
||||
? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))
|
||||
: formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)),
|
||||
caption: item.is_other
|
||||
? selectedLabel && !presetLabels.includes(selectedLabel)
|
||||
? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}`
|
||||
: '点击选择更多类型'
|
||||
: `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`,
|
||||
groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多'
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
return formatConfidenceLabel(
|
||||
resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
|
||||
const state = inlineState || createEmptyInlineReviewState()
|
||||
if (slotKey === 'expense_type') return String(state.expense_type || '').trim()
|
||||
if (slotKey === 'customer_name') return String(state.customer_name || '').trim()
|
||||
if (slotKey === 'time_range') return String(state.occurred_date || '').trim()
|
||||
if (slotKey === 'location') return String(state.location || '').trim()
|
||||
if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim()
|
||||
if (slotKey === 'amount') return String(state.amount || '').trim()
|
||||
if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim()
|
||||
if (slotKey === 'participants') return String(state.participants || '').trim()
|
||||
if (slotKey === 'attachments') {
|
||||
return String(state.attachment_names || '').trim()
|
||||
|| (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '')
|
||||
|| (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
|
||||
const actions = Array.isArray(reviewPayload?.confirmation_actions)
|
||||
? reviewPayload.confirmation_actions.map((item) => ({ ...item }))
|
||||
: []
|
||||
const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim()))
|
||||
const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents')
|
||||
|
||||
if (!canProceed || associationPending) {
|
||||
return actions
|
||||
}
|
||||
|
||||
const syncedActions = actions.filter((item) => String(item?.action_type || '').trim() !== 'next_step')
|
||||
if (!syncedActions.some((item) => String(item?.action_type || '').trim() === 'save_draft')) {
|
||||
syncedActions.push({
|
||||
label: '保存为草稿',
|
||||
action_type: 'save_draft',
|
||||
description: '先暂存当前已识别信息,稍后仍可继续补充或提交。',
|
||||
emphasis: 'secondary'
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
...syncedActions,
|
||||
{
|
||||
label: '继续下一步',
|
||||
action_type: 'next_step',
|
||||
description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。',
|
||||
emphasis: 'primary'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
if (!reviewPayload || typeof reviewPayload !== 'object') {
|
||||
return reviewPayload
|
||||
}
|
||||
|
||||
const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => {
|
||||
const value = resolveInlineReviewSlotValue(slot.key, inlineState)
|
||||
const required = Boolean(slot.required)
|
||||
const filled = Boolean(value)
|
||||
return {
|
||||
...slot,
|
||||
value: value || slot.value || '',
|
||||
normalized_value: value || slot.normalized_value || '',
|
||||
raw_value: value || slot.raw_value || '',
|
||||
source: filled ? 'user_form' : slot.source,
|
||||
source_label: filled ? '用户修改' : slot.source_label,
|
||||
confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0),
|
||||
confirmed: filled || Boolean(slot.confirmed),
|
||||
status: required && !filled ? 'missing' : filled ? 'identified' : slot.status,
|
||||
hint: required && !filled ? slot.hint : ''
|
||||
}
|
||||
})
|
||||
const missingSlots = nextSlotCards
|
||||
.filter((slot) => slot.required && slot.status === 'missing')
|
||||
.map((slot) => slot.label || slot.key)
|
||||
const extraMissingSlots = resolveReviewExtraMissingLabels({
|
||||
...reviewPayload,
|
||||
slot_cards: nextSlotCards
|
||||
})
|
||||
const allMissingSlots = [...missingSlots, ...extraMissingSlots]
|
||||
const canProceed = allMissingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
|
||||
|
||||
return {
|
||||
...reviewPayload,
|
||||
can_proceed: canProceed,
|
||||
missing_slots: allMissingSlots,
|
||||
slot_cards: nextSlotCards,
|
||||
edit_fields: mergeInlineReviewFields(reviewPayload.edit_fields || [], inlineState),
|
||||
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeInlineReviewComparableState(state) {
|
||||
const source = state && typeof state === 'object' ? state : {}
|
||||
return {
|
||||
occurred_date: String(source.occurred_date || '').trim(),
|
||||
amount: String(source.amount || '').trim(),
|
||||
transport_type: String(source.transport_type || '').trim(),
|
||||
scene_label: String(source.scene_label || '').trim(),
|
||||
reason_value: String(source.reason_value || '').trim(),
|
||||
customer_name: String(source.customer_name || '').trim(),
|
||||
location: String(source.location || '').trim(),
|
||||
merchant_name: String(source.merchant_name || '').trim(),
|
||||
participants: String(source.participants || '').trim(),
|
||||
attachment_names: String(source.attachment_names || '').trim(),
|
||||
pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)),
|
||||
expense_type: String(source.expense_type || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) {
|
||||
const base = normalizeInlineReviewComparableState(baseState)
|
||||
const next = normalizeInlineReviewComparableState(nextState)
|
||||
const lines = []
|
||||
|
||||
if (base.occurred_date !== next.occurred_date) {
|
||||
lines.push(`发生时间 ${next.occurred_date || '待补充'}`)
|
||||
}
|
||||
if (base.amount !== next.amount) {
|
||||
lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`)
|
||||
}
|
||||
if (base.transport_type !== next.transport_type) {
|
||||
lines.push(`交通类型 ${next.transport_type || '待确认'}`)
|
||||
}
|
||||
if (base.scene_label !== next.scene_label) {
|
||||
lines.push(`场景 ${next.scene_label || '待补充'}`)
|
||||
}
|
||||
if (base.customer_name !== next.customer_name) {
|
||||
lines.push(`关联客户 ${next.customer_name || '待补充'}`)
|
||||
}
|
||||
if (base.location !== next.location) {
|
||||
lines.push(`业务地点 ${next.location || '待补充'}`)
|
||||
}
|
||||
if (base.merchant_name !== next.merchant_name) {
|
||||
lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`)
|
||||
}
|
||||
if (base.participants !== next.participants) {
|
||||
lines.push(`同行人员 ${next.participants || '待补充'}`)
|
||||
}
|
||||
if (base.expense_type !== next.expense_type) {
|
||||
lines.push(`报销分类 ${next.expense_type || '待补充'}`)
|
||||
}
|
||||
if (base.attachment_names !== next.attachment_names || pendingFiles.length) {
|
||||
lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
export function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) {
|
||||
const base = normalizeInlineReviewComparableState(baseState)
|
||||
const next = normalizeInlineReviewComparableState(nextState)
|
||||
const fieldConfigs = [
|
||||
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
|
||||
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
|
||||
{ key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' },
|
||||
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
|
||||
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
|
||||
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
|
||||
{ key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' },
|
||||
{ key: 'participants', label: '同行人员', format: (value) => value || '待补充' },
|
||||
{ key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' }
|
||||
]
|
||||
|
||||
const phrases = fieldConfigs.reduce((result, item) => {
|
||||
if (base[item.key] !== next[item.key]) {
|
||||
result.push(`${item.label}修改为 ${item.format(next[item.key])}`)
|
||||
}
|
||||
return result
|
||||
}, [])
|
||||
|
||||
if (base.attachment_names !== next.attachment_names || pendingFiles.length) {
|
||||
phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`)
|
||||
}
|
||||
|
||||
return phrases
|
||||
}
|
||||
|
||||
export function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) {
|
||||
const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles)
|
||||
const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
|
||||
|
||||
if (documentLines.length) {
|
||||
phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`)
|
||||
}
|
||||
|
||||
if (!phrases.length) {
|
||||
return '右侧核对信息已保存。'
|
||||
}
|
||||
|
||||
return `已将${phrases.join(',')}。`
|
||||
}
|
||||
|
||||
export function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) {
|
||||
const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
|
||||
if (!lines.length) {
|
||||
return '我已校正核对信息,请按最新内容更新。'
|
||||
}
|
||||
return `我已校正核对信息:${lines.join(',')}。请按最新内容更新。`
|
||||
}
|
||||
|
||||
export function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) {
|
||||
const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
|
||||
const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
|
||||
|
||||
if (!inlineLines.length && !documentLines.length) {
|
||||
return '我已校正核对信息,请按最新内容更新。'
|
||||
}
|
||||
|
||||
const parts = []
|
||||
if (inlineLines.length) {
|
||||
parts.push(inlineLines.join(','))
|
||||
}
|
||||
if (documentLines.length) {
|
||||
parts.push(`修正了 ${documentLines.length} 张票据识别信息`)
|
||||
}
|
||||
|
||||
return `我已校正核对信息:${parts.join(';')}。请按最新内容更新。`
|
||||
}
|
||||
230
web/src/views/scripts/travelReimbursementStewardFollowupFlow.js
Normal file
230
web/src/views/scripts/travelReimbursementStewardFollowupFlow.js
Normal file
@@ -0,0 +1,230 @@
|
||||
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
|
||||
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
|
||||
import { SESSION_TYPE_APPLICATION, SESSION_TYPE_EXPENSE } from './travelReimbursementConversationModel.js'
|
||||
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
|
||||
|
||||
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 10
|
||||
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 8
|
||||
const STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4
|
||||
const STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5
|
||||
|
||||
export function useTravelReimbursementStewardFollowupFlow({
|
||||
buildStewardFieldItems,
|
||||
createMessage,
|
||||
formatStewardMissingFieldList,
|
||||
formatStewardOntologyFields,
|
||||
messages,
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
scrollToBottom
|
||||
}) {
|
||||
function buildStewardContinuationAfterAction(message, completedLabel = '当前动作已完成') {
|
||||
const continuation = message?.stewardContinuation || null
|
||||
const remainingTasks = Array.isArray(continuation?.remainingTasks) ? continuation.remainingTasks : []
|
||||
if (!remainingTasks.length) return null
|
||||
|
||||
const nextTask = remainingTasks[0]
|
||||
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
|
||||
const targetSessionType = nextTaskType === 'expense_application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
|
||||
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION ? '继续创建申请单' : '继续填写报销单'
|
||||
const restTasks = remainingTasks.slice(1)
|
||||
return createMessage(
|
||||
'assistant',
|
||||
[
|
||||
`**${completedLabel}。**`,
|
||||
'',
|
||||
'我会重新检查剩余任务队列。',
|
||||
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}。`,
|
||||
'请回复“确定”,我再继续执行。'
|
||||
].join('\n'),
|
||||
[],
|
||||
{
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '等待用户确认'],
|
||||
suggestedActions: [
|
||||
{
|
||||
label: nextLabel,
|
||||
description: '确认后小财管家继续调用对应助手完成下一步。',
|
||||
icon: targetSessionType === SESSION_TYPE_APPLICATION ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline',
|
||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
payload: {
|
||||
session_type: targetSessionType,
|
||||
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
|
||||
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
|
||||
auto_submit: true,
|
||||
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
|
||||
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
|
||||
steward_current_task: nextTask,
|
||||
steward_remaining_tasks: restTasks
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
|
||||
return {
|
||||
planId: planId || `steward-followup-${Date.now()}`,
|
||||
planStatus: 'delegating',
|
||||
summary: '',
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true,
|
||||
thinkingEvents,
|
||||
tasks: [],
|
||||
attachmentGroups: [],
|
||||
confirmationGroups: [],
|
||||
streamStatus
|
||||
}
|
||||
}
|
||||
|
||||
function extractStewardCarryLine(text = '', label = '') {
|
||||
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u'))
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractStewardFollowupNextTitle(text = '') {
|
||||
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u)
|
||||
if (taskMatch?.[1]) return taskMatch[1].trim()
|
||||
const nextMatch = String(text || '').match(/下一步[::]([^。\n]+)/u)
|
||||
return nextMatch?.[1]?.trim() || '下一项财务任务'
|
||||
}
|
||||
|
||||
function buildStewardFollowupThinkingEvents(finalMessage = null, actions = []) {
|
||||
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const firstAction = Array.isArray(actions) ? actions[0] : null
|
||||
const actionPayload = firstAction?.payload && typeof firstAction.payload === 'object' ? firstAction.payload : {}
|
||||
const carryText = String(actionPayload.carry_text || '').trim()
|
||||
const finalText = String(finalMessage?.text || '').trim()
|
||||
const nextTitle = extractStewardFollowupNextTitle(carryText || finalText)
|
||||
const nextSummary = extractStewardCarryLine(carryText, '任务摘要')
|
||||
const nextMissing = extractStewardCarryLine(carryText, '还需要补充')
|
||||
return [
|
||||
{
|
||||
eventId: `${eventPrefix}-review`,
|
||||
title: '复盘结果',
|
||||
content: finalText.includes('申请单已完成')
|
||||
? '申请单已经完成,我把当前出差申请标记为已处理,不会重复创建。'
|
||||
: '当前动作已经完成,我会把已完成事项从任务队列中移除。'
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-next`,
|
||||
title: '读取剩余任务',
|
||||
content: nextSummary ? `剩余队列里的下一项是“${nextTitle}”:${nextSummary}。` : `剩余队列里的下一项是“${nextTitle}”。`
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-gate`,
|
||||
title: '判断下一步条件',
|
||||
content: nextMissing
|
||||
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
|
||||
: '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function waitStewardFollowupTick(intervalMs) {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, intervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function pushStewardContinuationMessage(finalMessage) {
|
||||
if (!finalMessage) return
|
||||
|
||||
const finalText = String(finalMessage.text || '')
|
||||
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const finalActions = Array.isArray(finalMessage.suggestedActions) ? finalMessage.suggestedActions : []
|
||||
finalMessage.text = ''
|
||||
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
|
||||
finalMessage.suggestedActions = []
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
|
||||
messages.value.push(finalMessage)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const typedEvents = []
|
||||
for (const eventData of buildStewardFollowupThinkingEvents(finalMessage, finalActions)) {
|
||||
const event = {
|
||||
eventId: eventData.eventId,
|
||||
stage: 'steward_followup',
|
||||
title: eventData.title,
|
||||
content: '',
|
||||
status: 'running'
|
||||
}
|
||||
typedEvents.push(event)
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const chars = Array.from(eventData.content)
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
|
||||
index = resolveStewardTypewriterNextIndex(chars, index, STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE)
|
||||
event.content = chars.slice(0, index).join('')
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
if (index % STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE === 0 || index === chars.length) nextTick(scrollToBottom)
|
||||
}
|
||||
event.content = eventData.content
|
||||
event.status = 'completed'
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
|
||||
const chars = Array.from(finalText)
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
|
||||
index = resolveStewardTypewriterNextIndex(chars, index, STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE)
|
||||
finalMessage.text = chars.slice(0, index).join('')
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
|
||||
if (index % STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
finalMessage.text = finalText
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
|
||||
finalMessage.suggestedActions = finalActions
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function buildStewardContinuationCarryText(task, restTasks = []) {
|
||||
const taskType = String(task?.task_type || task?.taskType || '').trim()
|
||||
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
|
||||
const missingFields = formatStewardMissingFieldList(task?.missing_fields || task?.missingFields || [], taskType, { includeHints: false })
|
||||
const lines = [
|
||||
taskType === 'expense_application'
|
||||
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}。`
|
||||
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}。`,
|
||||
task.summary ? `任务摘要:${task.summary}` : '',
|
||||
fields ? `已识别信息:${fields}` : '',
|
||||
missingFields ? `还需要补充:${missingFields}` : '',
|
||||
missingFields ? '请先追问上述缺失信息,不要直接生成核对结果,也不要替用户默认填写。' : '请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
|
||||
]
|
||||
if (restTasks.length) {
|
||||
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
|
||||
restTasks.forEach((item, index) => {
|
||||
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
|
||||
})
|
||||
}
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function resolveStewardMissingFieldItems(task) {
|
||||
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) return task.missingFieldItems
|
||||
const fields = task?.missingFields || task?.missing_fields || []
|
||||
const taskType = String(task?.taskType || task?.task_type || '').trim()
|
||||
return buildStewardFieldItems(fields, taskType)
|
||||
}
|
||||
|
||||
return {
|
||||
buildStewardContinuationAfterAction,
|
||||
buildStewardContinuationCarryText,
|
||||
pushStewardContinuationMessage,
|
||||
resolveStewardMissingFieldItems
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user