Compare commits
64 Commits
codex/risk
...
73966b3a7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73966b3a7b | ||
|
|
1f40ce3df3 | ||
|
|
f17098aa58 | ||
|
|
8094333e3b | ||
|
|
0122f3b250 | ||
|
|
dc4cad2baa | ||
|
|
e725b7f19c | ||
|
|
84a8998e59 | ||
|
|
bc743adef3 | ||
|
|
ded8b39ccb | ||
|
|
ba444a514f | ||
|
|
aa965da69d | ||
|
|
1b04ee1c4c | ||
|
|
103f225f54 | ||
|
|
e42dedaba1 | ||
|
|
607e127f59 | ||
|
|
6d33ba5742 | ||
|
|
08a4fa3577 | ||
|
|
d660a961fb | ||
|
|
669d22e71f | ||
|
|
88e91a5900 | ||
|
|
1986b0d945 | ||
|
|
24b5b71b0f | ||
|
|
8b3495455b | ||
|
|
3b74a330a3 | ||
|
|
8158716e23 | ||
|
|
0cda750ff0 | ||
|
|
81e990ab72 | ||
|
|
47c6a4bb73 | ||
|
|
96c2e1099a | ||
|
|
729d833edb | ||
|
|
304bbe1fd4 | ||
|
|
3d69f8501f | ||
|
|
4d04f4e7af | ||
|
|
3131112952 | ||
|
|
a2f67af13e | ||
|
|
0cde1f8990 | ||
|
|
a6674a1e76 | ||
|
|
127d603e7d | ||
|
|
3f17619e0c | ||
|
|
59ba76c74a | ||
|
|
35372c6661 | ||
|
|
38653fa365 | ||
|
|
c28e99b714 | ||
|
|
43432534d8 | ||
|
|
cce19e4c40 | ||
|
|
b8915a29c0 | ||
|
|
4199feb681 | ||
|
|
0fac8b615f | ||
|
|
a3e5295915 | ||
|
|
1f4681f486 | ||
|
|
09a66c72cb | ||
|
|
0d525fa64c | ||
|
|
470f343b29 | ||
|
|
9f7b8b46a3 | ||
|
|
792741709a | ||
|
|
5747e85acf | ||
|
|
8b952c9a26 | ||
| 336fee9d93 | |||
|
|
25724c354f | ||
|
|
e124e4bbcb | ||
|
|
f60cebadb8 | ||
|
|
1cbf3fee44 | ||
|
|
87da5df91b |
51
.env
51
.env
@@ -1,51 +0,0 @@
|
||||
APP_NAME=X-Financial
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
API_V1_PREFIX=/api/v1
|
||||
SETUP_COMPLETED=true
|
||||
VITE_SETUP_COMPLETED=true
|
||||
|
||||
COMPANY_NAME=YGSOFT
|
||||
COMPANY_CODE=123
|
||||
ADMIN_EMAIL='admin@admin.com'
|
||||
VITE_COMPANY_NAME=YGSOFT
|
||||
VITE_COMPANY_CODE=123
|
||||
VITE_ADMIN_EMAIL='admin@admin.com'
|
||||
# Admin login credentials are stored separately under server/.secrets/
|
||||
|
||||
WEB_HOST=10.10.10.122
|
||||
WEB_PORT=5173
|
||||
VITE_WEB_HOST=10.10.10.122
|
||||
VITE_WEB_PORT=5173
|
||||
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8000
|
||||
VITE_SERVER_HOST=0.0.0.0
|
||||
VITE_SERVER_PORT=8000
|
||||
SERVER_STARTUP_TIMEOUT=300
|
||||
SERVER_BLOCKING_STARTUP_TIMEOUT=12
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
|
||||
ONLYOFFICE_ENABLED=true
|
||||
ONLYOFFICE_PUBLIC_URL=http://www.caoxiaozhu.com:8082
|
||||
ONLYOFFICE_BACKEND_URL=http://main:8000
|
||||
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
|
||||
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
|
||||
|
||||
POSTGRES_HOST=10.10.10.189
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=8811614287327Leo
|
||||
VITE_POSTGRES_HOST=10.10.10.189
|
||||
VITE_POSTGRES_PORT=5432
|
||||
VITE_POSTGRES_DB=postgres
|
||||
VITE_POSTGRES_USER=root
|
||||
|
||||
DATABASE_URL='postgresql+psycopg://root:8811614287327Leo@10.10.10.189:5432/postgres'
|
||||
SQLALCHEMY_ECHO=false
|
||||
|
||||
REDIS_URL=
|
||||
VITE_REDIS_URL=
|
||||
|
||||
CORS_ORIGINS='["http://10.10.10.122:5173"]'
|
||||
@@ -48,4 +48,8 @@ SQLALCHEMY_ECHO=false
|
||||
REDIS_URL=
|
||||
VITE_REDIS_URL=
|
||||
|
||||
OCR_DEVICE=
|
||||
OCR_TIMEOUT_SECONDS=180
|
||||
OCR_MAX_CONCURRENT_WORKERS=1
|
||||
|
||||
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]'
|
||||
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -8,6 +8,8 @@ web/.vite/
|
||||
.omx/
|
||||
.claude/
|
||||
.codex/
|
||||
.codex-temp/
|
||||
.superpowers/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -16,3 +18,16 @@ __pycache__/
|
||||
server/.venv/
|
||||
server/.venv-ocr312
|
||||
server/.secrets/
|
||||
server/logs/
|
||||
server/storage/expense_claims/
|
||||
server/storage/finance_reports/
|
||||
server/storage/receipt_folder/
|
||||
test-results/
|
||||
.codex-remote-attachments/
|
||||
tmp-*.png
|
||||
tmp/
|
||||
.nezha/
|
||||
.omo/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
95
README.md
95
README.md
@@ -1,45 +1,50 @@
|
||||
# X-Financial
|
||||
|
||||
项目结构已按前后端拆开:
|
||||
|
||||
- `web/`:前端工程(当前 Vue + Vite 项目)
|
||||
- `server/`:后端工程目录
|
||||
- `docs/`:方案和阶段文档
|
||||
- `UI/`:界面参考稿
|
||||
- `document/`:业务文档
|
||||
|
||||
根目录统一环境变量:
|
||||
|
||||
- `.env`
|
||||
- `.env.example`
|
||||
|
||||
这里集中维护:
|
||||
|
||||
- 前端启动端口
|
||||
- 后端启动端口
|
||||
- PostgreSQL 连接参数
|
||||
- `DATABASE_URL`
|
||||
- `REDIS_URL`
|
||||
|
||||
从根目录统一启动:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
可选模式:
|
||||
|
||||
```bash
|
||||
./start.sh web
|
||||
./start.sh server
|
||||
./start.sh all
|
||||
```
|
||||
|
||||
根目录 `start.sh` 是统一编排入口;前端和后端的子启动脚本分别是 `web/web_start.sh` 与 `server/server_start.sh`。
|
||||
|
||||
手动进入前端目录:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run dev
|
||||
```
|
||||
# X-Financial
|
||||
|
||||
项目结构已按前后端拆开:
|
||||
|
||||
- `web/`:前端工程(当前 Vue + Vite 项目)
|
||||
- `server/`:后端工程目录
|
||||
- `docs/`:方案和阶段文档
|
||||
- `UI/`:界面参考稿
|
||||
- `document/`:业务文档
|
||||
|
||||
根目录统一环境变量:
|
||||
|
||||
- `.env`
|
||||
- `.env.example`
|
||||
|
||||
这里集中维护:
|
||||
|
||||
- 前端启动端口
|
||||
- 后端启动端口
|
||||
- PostgreSQL 连接参数
|
||||
- `DATABASE_URL`
|
||||
- `REDIS_URL`
|
||||
|
||||
从根目录统一启动:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
可选模式:
|
||||
|
||||
```bash
|
||||
./start.sh web
|
||||
./start.sh server
|
||||
./start.sh all
|
||||
```
|
||||
|
||||
根目录 `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
|
||||
cd web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
143
docker-compose.full.yml
Normal file
143
docker-compose.full.yml
Normal file
@@ -0,0 +1,143 @@
|
||||
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
|
||||
WEB_PORT: "${WEB_PORT:-5173}"
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: "${SERVER_PORT:-8000}"
|
||||
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:-5173}:${WEB_PORT:-5173}"
|
||||
- "${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:-5173}/ >/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:
|
||||
8
docker-compose.gpu.yml
Normal file
8
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
main:
|
||||
gpus: all
|
||||
shm_size: "8gb"
|
||||
environment:
|
||||
NVIDIA_VISIBLE_DEVICES: all
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
OCR_DEVICE: "${OCR_DEVICE:-gpu:0}"
|
||||
36
docker-compose.postgres.yml
Normal file
36
docker-compose.postgres.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
main:
|
||||
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
|
||||
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
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -1,38 +1,36 @@
|
||||
services:
|
||||
main:
|
||||
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_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||
QDRANT_URL: "http://qdrant:6333"
|
||||
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
|
||||
ports:
|
||||
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
|
||||
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
||||
- "2223:22"
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command:
|
||||
- /bin/sh
|
||||
- -lc
|
||||
- >
|
||||
services:
|
||||
main:
|
||||
image: x-financial-dev:latest
|
||||
container_name: x-financial-main
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
WEB_HOST: 0.0.0.0
|
||||
WEB_PORT: "${WEB_PORT:-5173}"
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: "${SERVER_PORT:-8000}"
|
||||
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
||||
X_FINANCIAL_PREFER_ENV_FILE: "true"
|
||||
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: "${QDRANT_URL:-}"
|
||||
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
|
||||
ports:
|
||||
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
|
||||
- "${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 fonts-noto-cjk fonts-noto-cjk-extra &&
|
||||
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">'
|
||||
@@ -48,63 +46,24 @@ services:
|
||||
> /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:-5173}/ >/dev/null || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
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:
|
||||
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:-5173}/ >/dev/null || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
networks:
|
||||
- financial-internal
|
||||
|
||||
networks:
|
||||
financial-internal:
|
||||
name: financial-internal
|
||||
|
||||
194
docker/README.md
194
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`.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://<your-linux-host>:5173
|
||||
```
|
||||
|
||||
## Container Layout
|
||||
|
||||
- `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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Persistence
|
||||
|
||||
The PostgreSQL data directory is stored in the named volume `postgres_data`.
|
||||
|
||||
## Notes
|
||||
|
||||
- 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.
|
||||
# Docker Compose
|
||||
|
||||
X-Financial 现在按运行依赖分成两层 Docker Compose:
|
||||
|
||||
- `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
|
||||
```
|
||||
|
||||
默认只会启动:
|
||||
|
||||
```text
|
||||
main
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
http://<your-linux-host>:5273
|
||||
```
|
||||
|
||||
这条路径不会主动拉起本地 PostgreSQL、Qdrant 或 ONLYOFFICE。
|
||||
数据库、ONLYOFFICE 和 Qdrant 地址都从 `.env` 或外部环境变量读取。
|
||||
|
||||
常见外部依赖变量:
|
||||
|
||||
```text
|
||||
DATABASE_URL
|
||||
POSTGRES_HOST
|
||||
POSTGRES_PORT
|
||||
ONLYOFFICE_ENABLED
|
||||
ONLYOFFICE_PUBLIC_URL
|
||||
ONLYOFFICE_BACKEND_URL
|
||||
QDRANT_URL
|
||||
```
|
||||
|
||||
## 完整启动:本地全栈
|
||||
|
||||
适合没有远端数据库和 ONLYOFFICE 的情况。
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.full.yml up -d
|
||||
```
|
||||
|
||||
会启动:
|
||||
|
||||
```text
|
||||
main
|
||||
postgres
|
||||
qdrant
|
||||
onlyoffice
|
||||
```
|
||||
|
||||
本地服务端口:
|
||||
|
||||
```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` 的清理命令。
|
||||
|
||||
572
docs/improvement-roadmap.md
Normal file
572
docs/improvement-roadmap.md
Normal file
@@ -0,0 +1,572 @@
|
||||
# X-Financial 改进路线图
|
||||
|
||||
> 本文档基于 2026-06-18 对代码库的算法层、业务层、工程层综合评估生成。
|
||||
> 每项改进都附有文件路径佐证,便于后续定位和追踪。
|
||||
> 维护规则:状态变更请在对应章节同步更新;新增改进项追加到对应优先级末尾。
|
||||
|
||||
## 状态约定
|
||||
|
||||
| 标记 | 含义 |
|
||||
|---|---|
|
||||
| ⏳ | 待启动 |
|
||||
| 🔄 | 进行中 |
|
||||
| ✅ | 已完成 |
|
||||
| ⏸️ | 暂缓(需说明原因) |
|
||||
| ❌ | 取消(需说明原因) |
|
||||
|
||||
## 优先级矩阵
|
||||
|
||||
| 优先级 | 编号 | 标题 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 🔴 P0 安全 | B2 | HTTP Header 权限漏洞 | ⏳ |
|
||||
| 🔴 P0 业务核心 | B1 | 审批流转交/加签/撤回/会签 | ⏳ |
|
||||
| 🔴 P0 共识 | B10 | 800 行硬约束破防 | ⏳ |
|
||||
| 🟠 P1 算法 | A1 | 风险评分权重自适应 | ⏳ |
|
||||
| 🟠 P1 算法 | A4 | LLM 票据分类 + 字段置信度 | ⏳ |
|
||||
| 🟠 P1 算法 | A7 | LLM 幻觉检测 | ⏳ |
|
||||
| 🟠 P1 业务 | B6 | 规则覆盖不均衡 | ⏳ |
|
||||
| 🟡 P2 业务 | B3 | 申请/报销拆表 | ⏳ |
|
||||
| 🟡 P2 业务 | B4 | 可配置审批矩阵 | ⏳ |
|
||||
| 🟡 P2 算法 | A2 | 异常检测自适应阈值 | ⏳ |
|
||||
| 🟢 P3 算法 | A3 | 多模型异常检测集成 | ⏳ |
|
||||
| 🟢 P3 算法 | A5 | 票据分类持续学习 | ⏳ |
|
||||
| 🟢 P3 算法 | A6 | Prompt 模板集中管理 | ⏳ |
|
||||
| 🟢 P3 算法 | A8 | 规则冗余建模 | ⏳ |
|
||||
| 🟢 P3 算法 | A9 | 行为画像 fairness 保护 | ⏳ |
|
||||
| 🟢 P3 业务 | B5 | 预算跨期/跨科边界 | ⏳ |
|
||||
| 🟢 P3 业务 | B7 | 审计日志防篡改 | ⏳ |
|
||||
| 🟢 P3 业务 | B8 | 审批 SLA 监控 | ⏳ |
|
||||
| 🟢 P3 业务 | B9 | 支付与凭证对接 | ⏳ |
|
||||
| 🟢 P3 算法 | A10 | 算法模块 800 行拆分 | ⏳ |
|
||||
|
||||
---
|
||||
|
||||
## 一、算法层面改进
|
||||
|
||||
### A1. 风险评分权重自适应调优 ⏳
|
||||
|
||||
**优先级**:🟠 P1
|
||||
|
||||
**证据**:
|
||||
- `server/src/app/algorithem/risk_graph/engine.py:457-465`
|
||||
- `server/src/app/services/risk_rule_scoring.py:16-23`
|
||||
|
||||
**当前实现**:
|
||||
```python
|
||||
risk_score = 0.35*S_rule + 0.25*S_anomaly + 0.20*S_graph + 0.15*S_policy + 0.05*S_history
|
||||
```
|
||||
五维权重和六因子权重均为硬编码常量,无法反映规则有效性差异。
|
||||
|
||||
**问题**:
|
||||
- 已有 `RiskObservationFeedback` 表收集人工反馈,但反馈数据**未反向更新权重**
|
||||
- 不同费用类型(差旅/招待/通信)的合理权重差异大,目前一刀切
|
||||
|
||||
**改进方向**:
|
||||
- 按费用类型分组的权重向量
|
||||
- 定期基于反馈数据做 logistic regression / Bayesian 更新
|
||||
- 权重变更需版本化、可回滚
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 权重从配置/数据库读取,不再硬编码
|
||||
- [ ] 反馈数据能触发权重自动调整
|
||||
- [ ] 不同费用类型可配置独立权重
|
||||
- [ ] 调整过程有日志和效果对比
|
||||
|
||||
---
|
||||
|
||||
### A2. 金额异常检测自适应阈值 ⏳
|
||||
|
||||
**优先级**:🟡 P2
|
||||
|
||||
**证据**:`server/src/app/algorithem/risk_graph/engine.py:221-261`
|
||||
|
||||
**当前实现**:固定分档阈值 `1.0x→0, 1.25x→30, 1.5x→55, 2.0x→75, 3.0x→95`
|
||||
|
||||
**问题**:
|
||||
- 通信费(小额高频)和差旅(大额低频)的"1.5x"含义完全不同
|
||||
- peer p75 在新部门/新费用类型时样本稀疏
|
||||
- 已识别 `peer_baseline_insufficient` 不确定性,但无冷启动方案
|
||||
|
||||
**改进方向**:
|
||||
- 改为自适应分位数(基于历史数据动态计算)
|
||||
- 按 `(费用类型 × 部门层级)` 分组维护基线
|
||||
- 冷启动:全局基线 + 小样本置信度折扣
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 阈值按业务维度分组,不再全局统一
|
||||
- [ ] 新部门/新费用类型有冷启动策略
|
||||
- [ ] 基线样本不足时有降级机制
|
||||
- [ ] 增加单元测试覆盖冷启动场景
|
||||
|
||||
---
|
||||
|
||||
### A3. 多模型异常检测集成策略 ⏳
|
||||
|
||||
**优先级**:🟢 P3
|
||||
|
||||
**证据**:`server/src/app/algorithem/risk_graph/anomaly_models.py`
|
||||
|
||||
**当前实现**:5 个模型独立输出(`robust_statistics / isolation_proxy / local_outlier / temporal_jump / periodic_deviation`),无集成。
|
||||
|
||||
**问题**:
|
||||
- 多模型同时报警时聚合规则未定义
|
||||
- 单模型 vs 多模型共识的严重度差异未体现
|
||||
- 模型间冲突无裁决机制
|
||||
|
||||
**改进方向**:
|
||||
- 引入 `AnomalyEnsembler` 集成层
|
||||
- 输出 `consensus_score` + `model_disagreement_flag`
|
||||
- 高风险图谱评分区分"单点异常"和"多维共识异常"
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 实现集成层并接入 engine.py
|
||||
- [ ] 集成结果包含共识度指标
|
||||
- [ ] 单元测试覆盖各种模型组合情况
|
||||
|
||||
---
|
||||
|
||||
### A4. LLM 票据分类与字段置信度 ⏳
|
||||
|
||||
**优先级**:🟠 P1
|
||||
|
||||
**证据**:
|
||||
- `server/src/app/services/document_intelligence.py:143-153`(`_classify_with_model` 当前 `return None`)
|
||||
- `server/src/app/services/document_intelligence.py:20-37`(字段抽取全正则)
|
||||
|
||||
**问题**:
|
||||
- 规则层单点支撑,非标准票据格式失效
|
||||
- 无字段级置信度评分,无法判断哪些抽取值需要人工复核
|
||||
- LLM 分类合并策略代码已存在但未启用
|
||||
|
||||
**改进方向**:
|
||||
1. 启用 LLM 分类层(合并逻辑可直接复用)
|
||||
2. 字段抽取增加置信度:`{field: {value, confidence, source}}`
|
||||
3. 低置信度字段(< 0.7)自动标记"需人工核对"
|
||||
|
||||
**验收标准**:
|
||||
- [ ] LLM 分类层启用并通过对比测试
|
||||
- [ ] 每个抽取字段附带置信度评分
|
||||
- [ ] 低置信度字段触发人工复核标记
|
||||
- [ ] 提供准确率回归测试集
|
||||
|
||||
---
|
||||
|
||||
### A5. 票据分类持续学习 ⏳
|
||||
|
||||
**优先级**:🟢 P3
|
||||
|
||||
**证据**:`server/src/app/services/document_intelligence_rules.py:120`(`score_bias` 硬编码)
|
||||
|
||||
**问题**:新票种(ETC 电子票、滴滴行程单新模板)需开发改代码才能识别。
|
||||
|
||||
**改进方向**:
|
||||
- 分类规则做成可配置 + 可学习
|
||||
- 管理员上传样本自动更新关键词权重
|
||||
- 基于历史已分类票据做 TF-IDF 训练
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 后台提供分类规则管理界面
|
||||
- [ ] 新票种可通过样本上传识别
|
||||
- [ ] 历史数据可训练关键词权重
|
||||
|
||||
---
|
||||
|
||||
### A6. Prompt 模板集中管理 ⏳
|
||||
|
||||
**优先级**:🟢 P3
|
||||
|
||||
**证据**:
|
||||
- `server/src/app/services/ontology_extraction.py`
|
||||
- `server/src/app/services/ontology_detection.py`
|
||||
- `server/src/app/services/risk_rule_generation.py`
|
||||
- `server/src/app/services/user_agent_response.py`
|
||||
- `server/src/app/services/user_agent_application.py`
|
||||
- `server/src/app/services/user_agent_review_core.py`
|
||||
- `server/src/app/services/knowledge_rag.py:214`(查询重写硬编码在方法内)
|
||||
|
||||
**问题**:
|
||||
- Prompt 散落在 12+ 文件,无版本化、无回滚、无 A/B 测试
|
||||
- 相同意图的 prompt 在不同 service 中重复
|
||||
|
||||
**改进方向**:
|
||||
- 建立 `prompts/` 集中目录 + `PromptRegistry`
|
||||
- 按 `(意图, 版本)` 管理
|
||||
- 支持灰度发布和效果对比
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 所有 prompt 迁移到集中目录
|
||||
- [ ] 支持版本化与回滚
|
||||
- [ ] 提供 A/B 测试接口
|
||||
|
||||
---
|
||||
|
||||
### A7. LLM 幻觉检测与事实校验 ⏳
|
||||
|
||||
**优先级**:🟠 P1
|
||||
|
||||
**证据**:当前系统缺少 LLM 输出的显式幻觉检测。本体解析有 `confidence` 门禁,但生成的解释文本、规则建议、对话回复无校验。
|
||||
|
||||
**问题**:
|
||||
- LLM 可能编造不存在的政策条款、错误金额阈值、虚构审批人
|
||||
- 风险图谱解释文本幻觉会误导审批人
|
||||
- 唯一兜底是 `data_quality_gate`,仅管输入数据质量
|
||||
|
||||
**改进方向**:
|
||||
- 关键输出(金额、政策条款、规则编号)做 grounded check:LLM 输出后用规则引擎反向校验
|
||||
- 对话回复中的具体数字、日期强制引用证据片段(RAG 引用)
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 关键数值字段有反向校验机制
|
||||
- [ ] 对话回复中的事实声明可追溯到证据
|
||||
- [ ] 校验失败时有明确降级策略
|
||||
|
||||
---
|
||||
|
||||
### A8. 规则冗余/相关性建模 ⏳
|
||||
|
||||
**优先级**:🟢 P3
|
||||
|
||||
**证据**:`server/src/app/services/risk_rule_scoring.py`(多规则命中简单求和/max)
|
||||
|
||||
**问题**:相关规则同时命中时分数被夸大。如 `preapproval_absent` 和 `date_outside_trip` 可能高度相关。
|
||||
|
||||
**改进方向**:引入规则相关矩阵,对相关规则命中分数做去冗余折扣。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 建立规则相关矩阵
|
||||
- [ ] 命中聚合时考虑冗余
|
||||
- [ ] 测试验证去冗余效果
|
||||
|
||||
---
|
||||
|
||||
### A9. 行为画像 fairness 保护 ⏳
|
||||
|
||||
**优先级**:🟢 P3
|
||||
|
||||
**证据**:`server/src/app/algorithem/employee_behavior_profile.py:345`(`evaluate_weighted_profile` / `calculate_review_priority_score`)
|
||||
|
||||
**问题**:行为画像影响审核优先级,若基于受保护属性(性别/年龄)产生系统性偏差,会构成隐性歧视。
|
||||
|
||||
**改进方向**:
|
||||
- 增加 fairness audit 接口(按人群分组统计风险分布)
|
||||
- 评分特征显式排除受保护属性
|
||||
- 定期输出偏差报告
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 评分特征清单明确排除受保护属性
|
||||
- [ ] 提供 fairness audit API
|
||||
- [ ] 定期偏差报告生成
|
||||
|
||||
---
|
||||
|
||||
### A10. 算法模块 800 行拆分 ⏳
|
||||
|
||||
**优先级**:🟢 P3(与 B10 同源,单独追踪算法模块进度)
|
||||
|
||||
**证据**:
|
||||
- `server/src/app/algorithem/employee_behavior_profile_tag_rules.py`: **812 行** 🔴
|
||||
- `server/src/app/algorithem/risk_graph/engine.py`: **794 行** 🟡 临界
|
||||
|
||||
**改进方向**:
|
||||
- `employee_behavior_profile_tag_rules.py` 按标签类别拆分(差旅类 / 招待类 / 办公类)
|
||||
- `engine.py` 的 5 个评分维度(rule/anomaly/graph/policy/history)拆为 5 个独立打分器
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 所有算法文件 ≤ 800 行
|
||||
- [ ] 拆分前后行为等价(单元测试通过)
|
||||
- [ ] 拆分后职责边界清晰
|
||||
|
||||
---
|
||||
|
||||
## 二、业务层面改进
|
||||
|
||||
### B1. 审批流转交/加签/撤回/会签 ⏳
|
||||
|
||||
**优先级**:🔴 P0
|
||||
|
||||
**证据**:
|
||||
- `server/src/app/services/expense_claim_workflow_constants.py`(仅 11 行固定阶段)
|
||||
- `server/src/app/services/expense_claim_approval_flow.py:28`(`approve_claim` 串行硬编码)
|
||||
- 转交/加签/撤回代码中**不存在**
|
||||
|
||||
**问题**:
|
||||
- 费控系统核心能力缺失
|
||||
- 现实中领导出差无法审批是常态
|
||||
- 无并行审批(会签),多人审批只能串行
|
||||
- 审批节点调整需改代码
|
||||
|
||||
**改进方向**:
|
||||
- 引入审批矩阵:`费用类型 × 金额区间 × 部门` → 审批节点列表
|
||||
- 支持节点动作:`{approve, reject, return, transfer, countersign, withdraw, add_approver}`
|
||||
- 短期优先实现"转交"和"撤回"两个最常用动作
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 支持转交(审批人转给他人)
|
||||
- [ ] 支持撤回(提交人在审批中撤回)
|
||||
- [ ] 支持加签(临时增加审批节点)
|
||||
- [ ] 支持会签(多节点并行)
|
||||
- [ ] 审批矩阵可后台配置
|
||||
- [ ] 关键操作有审计日志
|
||||
|
||||
---
|
||||
|
||||
### B2. HTTP Header 权限漏洞修复 ⏳
|
||||
|
||||
**优先级**:🔴 P0(安全)
|
||||
|
||||
**证据**:`server/src/app/api/deps.py:33-213`,通过 `X-Auth-Username / X-Auth-Role-Codes / X-Auth-Is-Admin` 等请求头识别身份。
|
||||
|
||||
**问题**:
|
||||
- **任何人只要在请求头加 `X-Auth-Is-Admin: true` 就能获得管理员权限**
|
||||
- 没有 token、没有签名、没有任何校验
|
||||
- 足以让所有费控规则形同虚设
|
||||
|
||||
**改进方向**:
|
||||
- 引入真正的身份认证(JWT 或 session cookie)
|
||||
- 角色信息从服务端 session/token 获取,**绝不信任客户端传来的角色声明**
|
||||
- 短期方案:前置网关(nginx)剥离这些头并注入真实身份
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 客户端无法通过伪造 Header 越权
|
||||
- [ ] 所有角色信息来自服务端校验
|
||||
- [ ] 现有 API 行为兼容(不破坏调用方)
|
||||
- [ ] 安全测试覆盖权限边界
|
||||
|
||||
---
|
||||
|
||||
### B3. 申请单与报销单拆表 ⏳
|
||||
|
||||
**优先级**:🟡 P2
|
||||
|
||||
**证据**:
|
||||
- `server/src/app/models/financial_record.py`:`ExpenseClaim` 通过 `expense_type` 后缀 + `claim_no` 前缀区分
|
||||
- `server/src/app/models/reimbursement.py`:`ReimbursementRequest` 几乎废弃(service 仅 54 行 CRUD)
|
||||
|
||||
**问题**:
|
||||
- 查询复杂度高,每个查询都要带 `expense_type IN (...)` 过滤
|
||||
- 字段冗余(申请单无发票字段但表里有)
|
||||
- 业务语义混乱("claim"分不清是申请还是报销)
|
||||
- 索引难优化
|
||||
|
||||
**改进方向**(需决策):
|
||||
- 方案 A(保守):保留单表,增加 `claim_kind` 字段(`application` / `reimbursement`)显式区分
|
||||
- 方案 B(彻底):拆分为 `ExpenseApplication` + `ExpenseReimbursement` 两张表,通过 `application_id` 外键关联
|
||||
- **涉及数据迁移,需用户确认方案**
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 方案决策完成
|
||||
- [ ] 数据迁移脚本可重入、可回滚
|
||||
- [ ] 迁移前后数据等价校验
|
||||
- [ ] 现有 API 行为兼容或平滑升级
|
||||
|
||||
---
|
||||
|
||||
### B4. 可配置审批矩阵 ⏳
|
||||
|
||||
**优先级**:🟡 P2
|
||||
|
||||
**证据**:`server/src/app/services/expense_claim_approval_routing.py`(`_APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = 90%` 等阈值硬编码)
|
||||
|
||||
**问题**:什么金额走什么审批、什么情况要预算管理者介入,全部硬编码。不同公司/部门差异大,无法运维配置。
|
||||
|
||||
**改进方向**:建立审批矩阵配置表:
|
||||
```
|
||||
approval_matrix(expense_type, amount_range, department_level, risk_level)
|
||||
→ [approver_roles, parallel_or_serial, sla_hours]
|
||||
```
|
||||
管理员后台维护,系统按矩阵动态生成审批流。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 审批矩阵可后台配置
|
||||
- [ ] 系统按矩阵动态生成审批流
|
||||
- [ ] 配置变更有版本和审计
|
||||
- [ ] 矩阵未命中时有合理默认值
|
||||
|
||||
---
|
||||
|
||||
### B5. 预算管控跨期/跨科边界 ⏳
|
||||
|
||||
**优先级**:🟢 P3
|
||||
|
||||
**证据**:
|
||||
- `server/src/app/services/budget.py`(780 行)
|
||||
- `server/src/app/services/expense_claim_budget_flow.py`(112 行)
|
||||
- 预算占用/释放/核销/转移已实现,但边界场景验证不足
|
||||
|
||||
**潜在漏洞**:
|
||||
- 跨财年结转:去年冻结的预算今年初未释放
|
||||
- 跨期占用:Q1 提交的申请 Q2 才审批,占用的是哪个季度?
|
||||
- 跨科目调剂:差旅预算不够能否临时挪用办公预算?
|
||||
- 无对应单元测试
|
||||
|
||||
**改进方向**:
|
||||
- 增加预算状态周期性对账任务(每日扫描 orphan reservation)
|
||||
- 跨期策略明确化(默认跟随申请提交期,可配置)
|
||||
- 补充跨期/跨科目单元测试
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 跨期/跨科目边界单元测试覆盖
|
||||
- [ ] 周期性对账任务上线
|
||||
- [ ] orphan reservation 自动清理
|
||||
|
||||
---
|
||||
|
||||
### B6. 规则覆盖不均衡补齐 ⏳
|
||||
|
||||
**优先级**:🟠 P1
|
||||
|
||||
**证据**:`server/rules/risk-rules/` 38 条规则分布:
|
||||
- 差旅(travel):13 条
|
||||
- 预算(budget):13 条
|
||||
- 申请(application):5 条
|
||||
- 报销(reimbursement):7 条
|
||||
- 标准(standard):5 条
|
||||
|
||||
**问题**:
|
||||
- **招待费、市场推广、培训费、福利费、软件服务费几乎没有专门规则**
|
||||
- 缺少供应商关联方交易、连号发票重复报销、跨年度重复报销检测
|
||||
- 这些恰是真实费控场景最易出问题的领域
|
||||
|
||||
**改进方向**:
|
||||
1. 招待费规则(参与人数缺失、人均超标、同城招待、节假日招待)
|
||||
2. 供应商风险规则(同一供应商高频、关联方、工商信息异常)
|
||||
3. 重复报销检测(发票号哈希去重、跨期扫描)
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 招待费规则集(≥5 条)
|
||||
- [ ] 供应商风险规则集(≥3 条)
|
||||
- [ ] 重复报销检测规则(≥2 条)
|
||||
- [ ] 每条新规则有对应单元测试
|
||||
|
||||
---
|
||||
|
||||
### B7. 审计日志防篡改 ⏳
|
||||
|
||||
**优先级**:🟢 P3
|
||||
|
||||
**证据**:
|
||||
- `server/src/app/models/audit_log.py`
|
||||
- `server/src/app/services/audit.py`(72 行)
|
||||
- before/after JSON 快照完整,但**无 hash chain 或数字签名**
|
||||
|
||||
**问题**:数据库管理员(或有 DB 写权限的人)可静默篡改审计日志。
|
||||
|
||||
**改进方向**:
|
||||
- 每条日志附加 `prev_hash + current_hash = sha256(prev_hash + payload)`
|
||||
- 定期锚定到外部存证(区块链 / 公证处 / WORM 存储)
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 审计日志实现 hash chain
|
||||
- [ ] 篡改可被检测
|
||||
- [ ] 外部存证机制(至少文档化)
|
||||
|
||||
---
|
||||
|
||||
### B8. 审批 SLA 与时效监控 ⏳
|
||||
|
||||
**优先级**:🟢 P3
|
||||
|
||||
**证据**:审批节点无超时提醒代码。
|
||||
|
||||
**问题**:单据卡在某领导处一周无人管,系统无感知。
|
||||
|
||||
**改进方向**:
|
||||
- 每个审批节点配置 SLA(如 24h/48h)
|
||||
- 后台定时任务扫描超时单据
|
||||
- 自动催办 / 升级到上级 / 转交
|
||||
|
||||
**验收标准**:
|
||||
- [ ] SLA 可配置
|
||||
- [ ] 超时自动催办
|
||||
- [ ] 超时升级机制
|
||||
- [ ] SLA 报表可查
|
||||
|
||||
---
|
||||
|
||||
### B9. 支付与凭证对接 ⏳
|
||||
|
||||
**优先级**:🟢 P3(业务延伸方向)
|
||||
|
||||
**证据**:状态机到 `paid` 就结束,无银企直连、无会计凭证生成。
|
||||
|
||||
**问题**:报销审批通过后仍需财务人工付款、手工录凭证,未形成完整闭环。
|
||||
|
||||
**改进方向**:
|
||||
- 银企直连(用友 / 金蝶 / 远光 API)
|
||||
- 自动生成会计凭证(借:管理费用-差旅,贷:银行存款/应付职工薪酬)
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 至少接入一个财务系统
|
||||
- [ ] 凭证自动生成
|
||||
- [ ] 支付状态回传
|
||||
|
||||
---
|
||||
|
||||
### B10. 800 行硬约束拆分(业务模块) ⏳
|
||||
|
||||
**优先级**:🔴 P0
|
||||
|
||||
**证据**:services/ 下 ≥ 800 行的文件,共 **20 个**:
|
||||
|
||||
| 文件 | 行数 | 超标幅度 |
|
||||
|---|---|---|
|
||||
| `services/user_agent_application.py` | 1451 | +81% |
|
||||
| `services/risk_rule_template_executor.py` | 1164 | +45% |
|
||||
| `services/expense_claim_draft_flow.py` | 1064 | +33% |
|
||||
| `services/expense_claims.py` | 1042 | +30% |
|
||||
| `services/receipt_folder.py` | 1034 | +29% |
|
||||
| `services/steward_planner.py` | 935 | +17% |
|
||||
| `api/v1/endpoints/agent_assets.py` | 925 | +16% |
|
||||
| `services/orchestrator_execution.py` | 900 | +12.5% |
|
||||
| `services/finance_dashboard.py` | 884 | +10.5% |
|
||||
| `services/knowledge_rag.py` | 877 | +9.6% |
|
||||
| `services/settings.py` | 873 | +9.1% |
|
||||
| `services/agent_assets.py` | 856 | +7% |
|
||||
| `services/employee.py` | 850 | +6.25% |
|
||||
| `services/employee_behavior_profile_service.py` | 823 | +2.9% |
|
||||
| `services/risk_rule_generation.py` | 821 | +2.6% |
|
||||
| `services/agent_foundation_asset_topup.py` | 809 | +1.1% |
|
||||
| `services/ontology_extraction.py` | 808 | +1% |
|
||||
| `services/demo_company_simulation_seed.py` | 805 | +0.6% |
|
||||
| `services/knowledge.py` | 800 | 临界 |
|
||||
| 另约 20 个文件在 700-800 行区间 | | 🟡 |
|
||||
|
||||
**前端超大文件**:
|
||||
|
||||
| 文件 | 行数 |
|
||||
|---|---|
|
||||
| `web/src/views/scripts/TravelReimbursementCreateView.js` | 4066 🔴🔴 |
|
||||
| `web/src/views/scripts/TravelRequestDetailView.js` | 2861 🔴🔴 |
|
||||
| `web/src/views/scripts/useTravelReimbursementSubmitComposer.js` | 2173 🔴🔴 |
|
||||
| `web/src/composables/useRequests.js` | 1799 🔴 |
|
||||
| `web/src/views/scripts/travelReimbursementReviewModel.js` | 1662 🔴 |
|
||||
| 多个 `.vue` 文件 | 800-1130 🔴 |
|
||||
|
||||
**改进方向**:
|
||||
- 按 AGENTS.md 既定的拆分原则(编排 / 状态 / 持久化 / 权限 / 文件存储 / OCR / 规则审核 / 响应构建 / 序列化)逐个拆
|
||||
- 优先 Top 5:`user_agent_application` / `risk_rule_template_executor` / `expense_claim_draft_flow` / `expense_claims` / `receipt_folder`
|
||||
- 每次拆分配套定向测试
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 所有类/文件 ≤ 800 行
|
||||
- [ ] 拆分前后行为等价(测试通过)
|
||||
- [ ] 拆分后职责边界清晰
|
||||
- [ ] CI 中加入行数检查(防止回潮)
|
||||
|
||||
---
|
||||
|
||||
## 三、推进原则
|
||||
|
||||
1. **P0 优先**:B2(安全)、B1(核心能力)、B10(共识)必须先行。
|
||||
2. **算法优化在 P0 落地后做**:再准的算法也会被权限漏洞和流程缺失抵消。
|
||||
3. **小步快跑**:每项改进拆成可独立验证的子任务,配套测试。
|
||||
4. **不破坏既有协议**:对外 API 尽量稳定,内部实现先拆。
|
||||
5. **800 行约束**:所有改动前后检查受影响类行数,CI 加入行数门禁。
|
||||
|
||||
---
|
||||
|
||||
## 四、变更日志
|
||||
|
||||
| 日期 | 变更 | 操作人 |
|
||||
|---|---|---|
|
||||
| 2026-06-18 | 路线图初始版本,基于代码库全量评估生成 | Sisyphus |
|
||||
@@ -0,0 +1,177 @@
|
||||
# Steward Application Reimbursement State 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:** Build a persistent ontology-bound steward state for travel application and travel reimbursement flows.
|
||||
|
||||
**Architecture:** Keep the existing steward planning UI and assistant delegation flow. Add a backend state layer that stores `steward_state` in `AgentConversation.state_json`, merges LLM/rule output as patches, and rejects fields outside the ontology registry before downstream services consume them.
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy JSON state, Pydantic schemas, pytest in Docker `x-financial-main:/app`, Vue/Vite frontend.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend State Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/app/schemas/steward.py`
|
||||
- Create: `server/src/app/services/steward_flow_state.py`
|
||||
- Test: `server/tests/test_steward_flow_state.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```python
|
||||
def test_state_merge_keeps_application_and_reimbursement_flows():
|
||||
service = StewardFlowStateService()
|
||||
state = service.merge_state(
|
||||
{},
|
||||
StewardFlowStatePatch(
|
||||
active_flow="travel_application",
|
||||
flow_id="travel_application",
|
||||
intent="travel_application_create",
|
||||
fields={"expense_type": "travel", "location": "上海"},
|
||||
),
|
||||
)
|
||||
state = service.merge_state(
|
||||
state,
|
||||
StewardFlowStatePatch(
|
||||
active_flow="travel_reimbursement",
|
||||
flow_id="travel_reimbursement",
|
||||
intent="travel_reimbursement_draft",
|
||||
fields={"amount": "708", "invoice_no": "NO-1"},
|
||||
),
|
||||
)
|
||||
|
||||
assert state["flows"]["travel_application"]["fields"]["location"] == "上海"
|
||||
assert state["flows"]["travel_reimbursement"]["fields"]["amount"] == "708"
|
||||
```
|
||||
|
||||
```python
|
||||
def test_state_merge_filters_non_ontology_fields():
|
||||
service = StewardFlowStateService()
|
||||
state = service.merge_state(
|
||||
{},
|
||||
StewardFlowStatePatch(
|
||||
active_flow="travel_application",
|
||||
flow_id="travel_application",
|
||||
intent="travel_application_create",
|
||||
fields={"location": "上海", "invented_field": "x"},
|
||||
),
|
||||
)
|
||||
|
||||
assert state["flows"]["travel_application"]["fields"] == {"location": "上海"}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run red tests**
|
||||
|
||||
Run:
|
||||
|
||||
```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_steward_flow_state.py
|
||||
```
|
||||
|
||||
Expected: fail because `steward_flow_state.py` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement minimal state service**
|
||||
|
||||
Create `StewardFlowStatePatch`, `StewardFlowStateService.merge_state`, ontology field filtering, and event append logic.
|
||||
|
||||
- [ ] **Step 4: Run green tests**
|
||||
|
||||
Run the same pytest command and expect pass.
|
||||
|
||||
### Task 2: Steward Plan Persistence
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/app/schemas/steward.py`
|
||||
- Modify: `server/src/app/api/v1/endpoints/steward.py`
|
||||
- Modify: `server/src/app/services/agent_conversations.py`
|
||||
- Test: `server/tests/test_steward_planner.py`
|
||||
|
||||
- [ ] **Step 1: Write failing API/service test**
|
||||
|
||||
Add a test proving `/steward/plans` response contains `conversation_id` and `steward_state` when `context_json.session_type = steward`, and the state contains two flows when the input contains one application and one reimbursement task.
|
||||
|
||||
- [ ] **Step 2: Run red test**
|
||||
|
||||
Run:
|
||||
|
||||
```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_steward_planner.py
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement conversation state persistence**
|
||||
|
||||
Add `conversation_id` and `steward_state` fields to response schemas, persist state through `AgentConversationService`, and merge planner tasks into `steward_state`.
|
||||
|
||||
- [ ] **Step 4: Run green test**
|
||||
|
||||
Run the same pytest command and expect pass.
|
||||
|
||||
### Task 3: Runtime Decision Reads Persistent State
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/app/services/steward_runtime_decision_agent.py`
|
||||
- Test: `server/tests/test_steward_runtime_decision_agent.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
Add a test proving runtime decision uses `context_json.conversation_state.steward_state` when `runtime_state` is empty.
|
||||
|
||||
- [ ] **Step 2: Implement minimal fallback hydration**
|
||||
|
||||
Normalize runtime state by merging request runtime state with persisted steward state.
|
||||
|
||||
- [ ] **Step 3: Run green test**
|
||||
|
||||
Run:
|
||||
|
||||
```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_steward_runtime_decision_agent.py
|
||||
```
|
||||
|
||||
### Task 4: Frontend State Carry
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/views/scripts/stewardPlanModel.js`
|
||||
- Modify: `web/src/views/scripts/TravelReimbursementCreateView.js`
|
||||
- Modify: `web/src/views/scripts/useTravelReimbursementSessionState.js`
|
||||
|
||||
- [ ] **Step 1: Preserve steward state from backend responses**
|
||||
|
||||
Normalize `conversation_id` and `steward_state` from plan/runtime responses into the local session model.
|
||||
|
||||
- [ ] **Step 2: Send steward state in later requests**
|
||||
|
||||
Include the current `steward_state` under `context_json` for plan and runtime decision calls.
|
||||
|
||||
- [ ] **Step 3: Build frontend**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker exec -w /app/web x-financial-main npm run build
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
### Task 5: Final Verification
|
||||
|
||||
- [ ] **Step 1: Run backend steward 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_steward_flow_state.py server/tests/test_steward_planner.py server/tests/test_steward_runtime_decision_agent.py server/tests/test_steward_slot_decision_agent.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
```bash
|
||||
docker exec -w /app/web x-financial-main npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Report workspace status**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
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.
|
||||
410
docs/superpowers/plans/2026-06-23-code-deduplication-refactor.md
Normal file
410
docs/superpowers/plans/2026-06-23-code-deduplication-refactor.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# X-Financial Duplicate Code Refactor Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans for multi-task execution. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Reduce duplicated business logic, renderer helpers, protocol constants, and test fixtures without changing existing behavior.
|
||||
|
||||
**Architecture:** Start with low-risk pure helpers, then move toward business contract consolidation. Each round must add or preserve regression tests before production code changes, and backend validation must run inside `x-financial-main`.
|
||||
|
||||
**Tech Stack:** Vue 3, Vite, Node test runner, FastAPI, SQLAlchemy, pytest, Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This document records the duplicate-code audit from 2026-06-23 and defines a staged cleanup path. The first implementation slice is intentionally small: extract shared frontend conversation rendering helpers used by `markdown.js` and `aiConversationHtmlRenderer.js`.
|
||||
|
||||
The following areas are in scope:
|
||||
|
||||
- Frontend AI markdown / HTML trusted block normalization.
|
||||
- Frontend reimbursement review panel model duplication.
|
||||
- Workbench AI composer / attachment strip duplication.
|
||||
- Backend application gate, fact extraction, amount/date/location parsing.
|
||||
- Backend platform risk context helper duplication.
|
||||
- Cross-layer status, expense type, document type, and risk-level taxonomy drift.
|
||||
- Test helper duplication for DB sessions, FastAPI client overrides, and OCR fakes.
|
||||
|
||||
The following areas are out of scope for the first implementation slice:
|
||||
|
||||
- Changing application submission behavior.
|
||||
- Changing reimbursement association decision flow.
|
||||
- Changing API response shapes.
|
||||
- Editing unrelated notification top bar changes already present in the worktree.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0: Frontend Trusted HTML And Conversation Text Helpers
|
||||
|
||||
`web/src/utils/markdown.js` and `web/src/utils/aiConversationHtmlRenderer.js` both implement:
|
||||
|
||||
- `ALLOWED_COLON_HEADING_TITLES`
|
||||
- `BUSINESS_FIELD_LABELS`
|
||||
- `TRUSTED_HTML_ALLOWED_TAGS`
|
||||
- `TRUSTED_HTML_ALLOWED_ATTRS`
|
||||
- `splitColonHeadingLine`
|
||||
- `normalizeBusinessFieldLine`
|
||||
- `hasOnlyTrustedHtmlTags`
|
||||
- `sanitizeTrustedHtmlBlock`
|
||||
- `extractTrustedHtmlBlocks`
|
||||
- `restoreTrustedHtmlBlocks`
|
||||
|
||||
Plan:
|
||||
|
||||
- [x] Create `web/src/utils/conversationTrustedHtml.js`.
|
||||
- [x] Move trusted HTML sanitizing and business-line normalization into the helper.
|
||||
- [x] Keep renderer-specific output differences in each renderer.
|
||||
- [x] Verify both markdown and AI conversation renderers still preserve valid trusted document cards and reject unsafe trusted HTML.
|
||||
|
||||
Expected benefit:
|
||||
|
||||
- One XSS whitelist.
|
||||
- One business field normalization rule.
|
||||
- Less drift between AI workbench and reimbursement assistant rendering.
|
||||
|
||||
### P0: Reimbursement Review Panel Model Duplication
|
||||
|
||||
`web/src/views/scripts/travelReimbursementCreateReviewModel.js` and `web/src/views/scripts/travelReimbursementReviewPanelModel.js` duplicate review scope, fact cards, risk item mapping, risk conversation text, and message cleanup.
|
||||
|
||||
Plan:
|
||||
|
||||
- [x] Choose `travelReimbursementReviewPanelModel.js` as the shared model.
|
||||
- [x] Convert create-view imports to the shared model, or make the create-view module a thin compatibility re-export.
|
||||
- [x] Add behavior tests for exported review helpers before deleting duplicate code.
|
||||
|
||||
Expected benefit:
|
||||
|
||||
- One risk item mapping.
|
||||
- One review fact card model.
|
||||
- Lower risk when changing reimbursement review copy or drawer behavior.
|
||||
|
||||
### P0: Workbench AI Composer And File Strip Template Duplication
|
||||
|
||||
`web/src/components/business/PersonalWorkbenchAiMode.template.html` keeps two near-identical composer forms and two near-identical selected-file strips for the welcome and inline conversation states. `web/src/components/business/workbench-ai/WorkbenchAiComposer.vue` and `web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue` already exist, but the main template still duplicates the markup.
|
||||
|
||||
Plan:
|
||||
|
||||
- [x] Reuse `WorkbenchAiComposer.vue` for both welcome and inline composer surfaces.
|
||||
- [x] Reuse `WorkbenchAiFileStrip.vue` for both welcome and inline selected-file strips.
|
||||
- [x] Keep the parent runtime API stable by passing a proxied runtime object into shared components.
|
||||
- [x] Preserve OCR state display in the shared file-strip component.
|
||||
- [x] Keep input focus behavior by exposing an explicit assistant input ref setter.
|
||||
|
||||
Expected benefit:
|
||||
|
||||
- One composer markup surface.
|
||||
- One selected-file/OCR badge markup surface.
|
||||
- Lower maintenance cost when upload, date picker, lock state, or send-button behavior changes.
|
||||
|
||||
### P1: Application Gate And Fact Extraction
|
||||
|
||||
Backend application flow repeats checks across `user_agent_application.py`, `steward_planner.py`, `orchestrator.py`, `ontology_detection.py`, and `ontology_extraction.py`.
|
||||
|
||||
Plan:
|
||||
|
||||
- [ ] Extract application intent / context gate helpers.
|
||||
- [ ] Extract application fact resolver for date, location, reason, amount, transport, and expense type.
|
||||
- [ ] Route UserAgent, StewardPlanner, and Orchestrator through the same helpers.
|
||||
- [ ] Preserve existing application vs reimbursement stage boundaries.
|
||||
|
||||
Expected benefit:
|
||||
|
||||
- Fewer mismatches between button actions and text-input actions.
|
||||
- Less chance of direct submit re-entering slow `/orchestrator/run` paths.
|
||||
- More consistent missing-field prompts.
|
||||
|
||||
### P1: Backend Parsing And Risk Context Utilities
|
||||
|
||||
Several backend modules repeat city extraction, document field lookup, item-id dedupe, Decimal conversion, endpoint normalization, and JSON error parsing.
|
||||
|
||||
Plan:
|
||||
|
||||
- [ ] Extract platform risk context helpers for item-id and document field utilities.
|
||||
- [ ] Reuse existing amount utilities before adding new regex parsing.
|
||||
- [ ] Share model connectivity URL/header/error helpers between RAG runtime and connectivity checks.
|
||||
- [ ] Cache sorted travel-policy city names per policy snapshot.
|
||||
|
||||
Expected benefit:
|
||||
|
||||
- Less CPU churn in repeated risk/OCR loops.
|
||||
- Fewer provider connectivity behavior differences.
|
||||
- Easier review of platform-risk regressions.
|
||||
|
||||
### P1: Cross-Layer Taxonomy And Protocol Constants
|
||||
|
||||
Status labels, expense types, document types, risk levels, API paths, and snake_case/camelCase mappings are repeated across backend schemas, frontend services, and tests.
|
||||
|
||||
Plan:
|
||||
|
||||
- [ ] Establish read-only contract baseline from OpenAPI export.
|
||||
- [ ] Export status / approval-stage registry to frontend constants.
|
||||
- [ ] Consolidate expense type, document type, and risk-level taxonomy.
|
||||
- [ ] Move high-churn API path and payload builders into shared frontend test helpers.
|
||||
|
||||
Expected benefit:
|
||||
|
||||
- Fewer display inconsistencies.
|
||||
- Easier API evolution.
|
||||
- Less brittle source-string tests.
|
||||
|
||||
### P2: Test Fixture Duplication
|
||||
|
||||
`server/tests` repeatedly defines `build_session`, `build_session_factory`, `override_db`, `build_client`, and OCR fake functions.
|
||||
|
||||
Plan:
|
||||
|
||||
- [ ] Add backend test fixtures in `server/tests/conftest.py`.
|
||||
- [ ] Move OCR fake builders into a small test helper.
|
||||
- [ ] Migrate tests in batches, one behavior area at a time.
|
||||
|
||||
Expected benefit:
|
||||
|
||||
- Less boilerplate in large test files.
|
||||
- Easier targeted regression coverage before service refactors.
|
||||
|
||||
## First Slice Execution Plan
|
||||
|
||||
### Task 1: Lock Shared Renderer Helper Behavior
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `web/tests/conversation-trusted-html.test.mjs`
|
||||
- Create: `web/src/utils/conversationTrustedHtml.js`
|
||||
- Modify: `web/src/utils/markdown.js`
|
||||
- Modify: `web/src/utils/aiConversationHtmlRenderer.js`
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Add a failing Node test that imports `conversationTrustedHtml.js`.
|
||||
- [x] Assert valid trusted document-card HTML is preserved through placeholder extraction and restore.
|
||||
- [x] Assert unsafe tags, event handlers, and non-document hrefs are rejected.
|
||||
- [x] Assert colon headings and business field lines normalize outside fenced code blocks.
|
||||
- [x] Run the new test and confirm it fails because the helper does not exist.
|
||||
- [x] Implement the helper with pure functions only.
|
||||
- [x] Refactor both renderers to use the helper.
|
||||
- [x] Run targeted renderer tests.
|
||||
- [x] Run `npm --prefix web run build`.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
node --test web/tests/conversation-trusted-html.test.mjs
|
||||
node --test web/tests/ai-conversation-html-renderer.test.mjs web/tests/travel-reimbursement-review-drawer-switch.test.mjs
|
||||
npm --prefix web run build
|
||||
```
|
||||
|
||||
## Third Slice Execution Plan
|
||||
|
||||
### Task 3: Reuse Workbench AI Composer Components
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `web/tests/workbench-ai-composer-components.test.mjs`
|
||||
- Modify: `web/src/components/business/PersonalWorkbenchAiMode.vue`
|
||||
- Modify: `web/src/components/business/PersonalWorkbenchAiMode.template.html`
|
||||
- Modify: `web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue`
|
||||
- Modify: `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js`
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Add a failing Node test that expects the main template to use `WorkbenchAiComposer` and `WorkbenchAiFileStrip`.
|
||||
- [x] Assert the shared file strip preserves OCR state badges.
|
||||
- [x] Assert the runtime exposes an input ref setter for the shared composer.
|
||||
- [x] Run the new test and confirm it fails on the duplicated template.
|
||||
- [x] Pass a proxied runtime object from `PersonalWorkbenchAiMode.vue` into shared components.
|
||||
- [x] Replace duplicate composer and file-strip markup in the external template.
|
||||
- [x] Add OCR badge markup to `WorkbenchAiFileStrip.vue`.
|
||||
- [x] Run targeted workbench AI tests.
|
||||
- [x] Run `npm --prefix web run build`.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
node --test web/tests/workbench-ai-composer-components.test.mjs
|
||||
node --test web/tests/workbench-ai-composer-components.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-mode-expense-scene-action.test.mjs
|
||||
npm --prefix web run build
|
||||
```
|
||||
|
||||
## Remaining Execution Plan
|
||||
|
||||
The remaining work is intentionally split into bounded slices. Each slice extracts shared code without changing user-facing flow, API response shape, or submission semantics.
|
||||
|
||||
### Task 4: Frontend Application Gate Helpers
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js`
|
||||
- Create: `web/tests/workbench-ai-application-gate-model.test.mjs`
|
||||
- Modify: `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js`
|
||||
- Modify: `web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js`
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Add a failing Node test for reimbursement creation intent, submit/save text action resolution, and orphan preview detection.
|
||||
- [x] Move pure gate predicates into `workbenchAiApplicationGateModel.js`.
|
||||
- [x] Replace inline copies in personal workbench and application-preview flow.
|
||||
- [x] Run targeted workbench AI application tests.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
node --test web/tests/workbench-ai-application-gate-model.test.mjs
|
||||
node --test web/tests/workbench-ai-application-gate-model.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-action-router.test.mjs
|
||||
```
|
||||
|
||||
### Task 5: Backend Application Fact Resolver
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `server/src/app/services/application_fact_resolver.py`
|
||||
- Create: `server/tests/test_application_fact_resolver.py`
|
||||
- Modify: `server/src/app/services/steward_planner.py`
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Add failing pytest coverage for time, location, reason, transport, and task-type inference.
|
||||
- [x] Extract pure resolver helpers that preserve existing planner output.
|
||||
- [x] Route `StewardPlannerService` extraction wrappers through the resolver.
|
||||
- [x] Run targeted planner/fact tests inside the active app container.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```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_application_fact_resolver.py server/tests/test_steward_planner.py
|
||||
```
|
||||
|
||||
### Task 6: Backend Platform Risk Context Utilities
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `server/src/app/services/expense_claim_platform_context_tools.py`
|
||||
- Create: `server/tests/test_expense_claim_platform_context_tools.py`
|
||||
- Modify: `server/src/app/services/expense_claim_platform_route_risk.py`
|
||||
- Modify: `server/src/app/services/expense_claim_platform_risk.py`
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Add failing pytest coverage for context city extraction, item-id dedupe, and text-value dedupe helpers.
|
||||
- [x] Add pure helper functions in `expense_claim_platform_context_tools.py`.
|
||||
- [x] Route route-risk and platform-risk consumers through the shared helpers.
|
||||
- [x] Run targeted platform-risk tests inside the active app container.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```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_expense_claim_platform_context_tools.py server/tests/test_expense_claim_platform_risk_stage.py
|
||||
```
|
||||
|
||||
### Task 7: Frontend Protocol Constants And Test Helpers
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `web/src/constants/documentProtocol.js`
|
||||
- Create: `web/tests/helpers/sourceSurface.mjs`
|
||||
- Create: `web/tests/document-protocol-constants.test.mjs`
|
||||
- Modify: `web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js`
|
||||
- Migrate one high-churn source-surface test to the helper.
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Add failing Node tests for status labels, document type constants, and reusable source-surface loading.
|
||||
- [x] Move duplicated status labels into `documentProtocol.js`.
|
||||
- [x] Reuse constants in application-preview model and request/document-center model code.
|
||||
- [x] Migrate one source-surface test helper to reduce brittle boilerplate.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
node --test web/tests/document-protocol-constants.test.mjs web/tests/workbench-ai-mode-switch.test.mjs
|
||||
```
|
||||
|
||||
### Task 8: Backend Test Fixture Consolidation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `server/src/app/test_helpers/db.py`
|
||||
- Create: `server/src/app/test_helpers/__init__.py`
|
||||
- Create: `server/tests/test_db_test_helpers.py`
|
||||
- Modify: `server/tests/test_expense_claim_platform_risk_stage.py`
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Identify an existing small test file with duplicated session/client/OCR helpers.
|
||||
- [x] Add shared DB helper while preserving its current behavior.
|
||||
- [x] Migrate one test file only.
|
||||
- [x] Run the migrated test plus adjacent coverage inside the active app container.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```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_db_test_helpers.py server/tests/test_expense_claim_platform_risk_stage.py
|
||||
```
|
||||
|
||||
### Task 9: Steward Planner Module Split
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `server/src/app/services/steward_planner_shared.py`
|
||||
- Create: `server/src/app/services/steward_planner_fallback.py`
|
||||
- Create: `server/src/app/services/steward_planner_extraction.py`
|
||||
- Modify: `server/src/app/services/steward_planner.py`
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Move shared constants and `PlannedTaskDraft` into a shared planner module.
|
||||
- [x] Move off-topic, pending-flow, and rule-fallback planning into `steward_planner_fallback.py`.
|
||||
- [x] Move task extraction, ontology normalization, attachment grouping, and summary helpers into `steward_planner_extraction.py`.
|
||||
- [x] Keep `steward_planner.py` as the service orchestration entrypoint.
|
||||
- [x] Run planner/fact resolver tests inside the active app container.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```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_application_fact_resolver.py server/tests/test_steward_planner.py
|
||||
```
|
||||
|
||||
### Task 10: App Shell Dynamic Business Chunk Loading
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `web/src/views/scripts/appShellAsyncViews.js`
|
||||
- Create: `web/src/components/shared/AppViewLoadingState.vue`
|
||||
- Create: `web/src/components/shared/AppModalLoadingState.vue`
|
||||
- Modify: `web/src/views/AppShellRouteView.vue`
|
||||
- Modify: `web/src/components/layout/SidebarRail.vue`
|
||||
- Modify: `web/src/components/layout/AiSidebarRail.vue`
|
||||
- Modify: `web/tests/app-shell-route-loading.test.mjs`
|
||||
- Modify: `web/tests/ai-sidebar-rail-mode.test.mjs`
|
||||
|
||||
Steps:
|
||||
|
||||
- [x] Keep top-level shell routes eager so login/setup/app layout does not blank during route navigation.
|
||||
- [x] Move heavy business views behind `defineAsyncComponent` loaders.
|
||||
- [x] Add an in-workarea loading state for slow business chunks.
|
||||
- [x] Add a modal loading state for the smart reimbursement assistant chunk.
|
||||
- [x] Preload likely next views on sidebar hover/focus and during browser idle time.
|
||||
- [x] Preserve existing Vite manual vendor chunks, including `vendor-echarts`.
|
||||
- [x] Run targeted frontend tests and production build.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
node --test web/tests/app-shell-route-loading.test.mjs web/tests/ai-sidebar-rail-mode.test.mjs web/tests/sidebar-document-unread-dot.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-reimbursement-association-gate.test.mjs web/tests/travel-reimbursement-review-drawer-switch.test.mjs web/tests/documents-center-status-filter.test.mjs
|
||||
npm --prefix web run build
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- App shell entry chunk after split: `index-vWyUfHfm.js` 223.75 kB, gzip 74.11 kB.
|
||||
- Large `vendor-echarts` chunk remains isolated at 598.67 kB, gzip 204.84 kB; it is no longer part of the app-shell entry chunk.
|
||||
- Build still reports the existing Rollup `#__PURE__` annotation warnings from Element Plus / VueUse.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not touch unrelated dirty files.
|
||||
- Do not change renderer output intentionally in this slice.
|
||||
- Do not move backend logic until frontend helper extraction is green.
|
||||
- Backend tests must run through Docker:
|
||||
|
||||
```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 <path>
|
||||
```
|
||||
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 |
@@ -1,5 +1,11 @@
|
||||
# 财务规则表补齐开发记录
|
||||
|
||||
## 2026-06-05 口径调整
|
||||
|
||||
用户明确要求业务招待费超过 500 元、大额办公用品以及金额超过 2000 元的费用申请审批要求进入财务规则中心。因此新增《公司费用申请审批规则》作为申请前置和审批阈值的财务规则依据;风险规则负责引用该财务规则并执行命中判断。
|
||||
|
||||
本次调整不恢复旧的单项《业务招待费报销规则》或《办公用品费报销规则》,而是使用统一规则表维护申请审批阈值,避免规则中心再次出现多个口径型规则表。
|
||||
|
||||
## 目标
|
||||
|
||||
财务规则中心只维护真正具备制度标准、且需要按职级/职务或明确人均标准执行的规则表。没有实际金额分档的费用类型,不在财务规则中心单独生成 Excel 表;其额度控制进入预算中心,申请前置和材料完整性进入风险规则。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 风险规则补齐开发记录
|
||||
|
||||
## 2026-06-05 口径调整
|
||||
|
||||
业务招待费超过 500 元、办公用品超过 2000 元、通用费用超过 2000 元的申请前置要求,制度依据统一改为财务规则《公司费用申请审批规则》。风险规则继续承担执行判断,但 `finance_rule_code` 统一指向 `expense.preapproval.policy`。
|
||||
|
||||
## 目标
|
||||
|
||||
补齐预算、申请前置、报销偏差、费用标准、材料完整性类风险规则,让后续 demo 数据可以形成“预算-申请-报销-风控”的闭环。
|
||||
|
||||
424
document/development/小财管家/CONCEPT.md
Normal file
424
document/development/小财管家/CONCEPT.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# 小财管家
|
||||
|
||||
## 功能一句话
|
||||
|
||||
小财管家是首页统一财务任务入口,负责把用户的自然语言和附件拆解为多个可确认、可追踪、可分派的申请与报销任务,再调用现有申请助手和报销助手完成执行闭环。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前个人工作台已经提供首页输入框,并能通过本体解析把一句话路由到申请、报销、预算或知识等单一会话。这个能力适合单意图,但用户真实表达经常是多任务组合,例如同时包含出差申请、昨日交通费报销、历史出差费用报销以及多张附件。
|
||||
|
||||
现有问题:
|
||||
|
||||
- 首页输入框当前会收敛为一个 `sessionType`,无法保留多任务计划。
|
||||
- 申请助手和报销助手已经具备单任务核对能力,但缺少上层任务拆解、归集和跨助手分派。
|
||||
- 附件上传后主要进入当前会话,缺少面向多任务的自动归集建议。
|
||||
- 财务动作需要确认后才能入库、绑定或提交,不能让大模型直接执行高风险动作。
|
||||
- 新增字段必须尊重本体字段,不能因为小财管家新增一套业务字段。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
- 首页输入框定位为“小财管家”,作为用户默认财务任务入口。
|
||||
- 用户提交自然语言和附件后,先展示小财管家的任务识别与附件归集过程。
|
||||
- 支持把一句话拆成多个任务,第一版只验证费用申请和费用报销。
|
||||
- 支持多附件按费用场景、时间、地点、任务线索形成归集建议。
|
||||
- 遇到创建申请单、创建报销草稿、附件绑定、提交审批等动作时必须等待用户确认。
|
||||
- 保留现有申请助手和报销助手能力,小财管家只做上层编排和分派。
|
||||
- 外层意图识别必须优先使用大模型 function calling 输出结构化任务计划,规则逻辑只作为模型不可用或结构不合法时的兜底。
|
||||
- 前端展示“意图识别智能体”过程气泡,并用流式状态逐步呈现,不暴露模型内部推理链。
|
||||
- 所有业务字段先进入本体字段归一化,再进入下游助手、草稿、风险规则和持久化。
|
||||
|
||||
### 非目标
|
||||
|
||||
- 第一版不做万能智能体,不覆盖预算、审批、知识问答等全部场景。
|
||||
- 第一版不引入 LangChain 或 LangGraph,先复用项目内运行时模型配置和 OpenAI-compatible function calling 契约。
|
||||
- 第一版不自动提交审批,不绕过用户确认。
|
||||
- 第一版不新增业务语义字段;只新增任务编排态字段。
|
||||
- 第一版不重写申请助手、报销助手和现有 Orchestrator。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
### 目标用户
|
||||
|
||||
- 普通员工:在首页一次性描述申请、报销和附件处理诉求。
|
||||
- 财务人员:查看任务拆解、附件归集和用户确认链路是否可追溯。
|
||||
- 审批/管理角色:后续可扩展为审批待办和预算提醒编排,但不进入第一版。
|
||||
|
||||
### 核心场景
|
||||
|
||||
用户在首页输入:
|
||||
|
||||
```text
|
||||
我想要申请7月2日去北京出差,辅助北京供电局的税务审核任务,并且我要报销昨天的交通费,还需要报销6月3日出差去上海的费用
|
||||
```
|
||||
|
||||
系统处理:
|
||||
|
||||
1. 小财管家识别到三条候选任务。
|
||||
2. 将“昨天”按客户端日期解析为明确日期,例如 2026-06-03。
|
||||
3. 将“7月2日去北京出差”归为费用申请任务。
|
||||
4. 将“昨天的交通费”和“6月3日去上海出差费用”归为费用报销任务。
|
||||
5. 如果用户同时上传附件,系统先识别附件场景,再建议归集到对应任务。
|
||||
6. 需要创建申请单或报销草稿时,向用户展示核对摘要和确认动作。
|
||||
|
||||
## 功能能力
|
||||
|
||||
### 1. 任务识别与拆分
|
||||
|
||||
任务识别主链路是“小财管家意图识别智能体”:
|
||||
|
||||
1. 后端读取系统设置中的主模型/备模型运行时配置。
|
||||
2. 将用户话术、客户端日期、附件元信息、上下文和 canonical ontology field 列表传入模型。
|
||||
3. 通过强制 function calling 调用 `submit_steward_intent_plan`。
|
||||
4. 模型只能返回结构化参数:`thinking_events`、`tasks`、`attachment_groups`。
|
||||
5. 服务端再次校验:任务类型只能是 `expense_application` / `reimbursement`,业务字段只能是 canonical ontology fields,附件名必须来自本次上传。
|
||||
6. 如果模型未配置、调用失败、未返回工具调用或结构不合法,才切换到规则兜底,并在过程摘要中标记兜底原因。
|
||||
|
||||
输入:
|
||||
|
||||
- 用户自然语言 `message`
|
||||
- 附件元信息 `attachments`
|
||||
- 当前用户、部门、角色、客户端时间
|
||||
- 已有会话上下文,可选
|
||||
|
||||
输出:
|
||||
|
||||
- `plan_id`:本次小财管家计划 ID
|
||||
- `tasks`:多个任务条目
|
||||
- `thinking_events`:面向用户展示的过程摘要
|
||||
- `confirmation_groups`:需要用户确认的动作集合
|
||||
- `attachment_groups`:附件归集建议
|
||||
|
||||
任务条目包含:
|
||||
|
||||
- `task_id`:编排态任务 ID
|
||||
- `task_type`:`expense_application` 或 `reimbursement`
|
||||
- `assigned_agent`:`application_assistant` 或 `reimbursement_assistant`
|
||||
- `title`:任务标题
|
||||
- `summary`:任务摘要
|
||||
- `status`:`planned`、`needs_confirmation`、`ready_to_delegate`、`delegated`、`completed`、`blocked`
|
||||
- `confidence`:识别置信度
|
||||
- `ontology_fields`:归一化后的本体字段
|
||||
- `missing_fields`:缺失字段
|
||||
- `confirmation_required`:是否需要确认后执行
|
||||
|
||||
### 2. 附件归集
|
||||
|
||||
附件归集基于以下信号:
|
||||
|
||||
- 附件类型:发票、火车票、机票、酒店票、付款截图、招待票据等。
|
||||
- 费用场景:差旅、交通、招待、住宿、其他。
|
||||
- 日期:票据日期是否匹配任务时间。
|
||||
- 地点:票据地点是否匹配任务地点。
|
||||
- 金额:是否能参与报销草稿。
|
||||
- 置信度:低置信度必须提示用户核对。
|
||||
|
||||
输出示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "ag_travel_001",
|
||||
"target_task_id": "task_reim_002",
|
||||
"scene": "travel",
|
||||
"scene_label": "差旅费用",
|
||||
"attachment_names": ["上海高铁票.jpg", "上海酒店发票.pdf", "出租车票.png"],
|
||||
"excluded_attachment_names": ["客户招待发票.jpg"],
|
||||
"confidence": 0.86,
|
||||
"confirmation_required": true
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 用户确认
|
||||
|
||||
必须确认的动作:
|
||||
|
||||
- 创建费用申请单。
|
||||
- 创建报销草稿。
|
||||
- 将附件归集并绑定到某一任务。
|
||||
- 将附件关联到已有报销草稿。
|
||||
- 提交审批。
|
||||
- 修改已有草稿字段。
|
||||
|
||||
小财管家的确认动作采用“下一步优先”策略:
|
||||
|
||||
- 同一个计划里同时存在申请和报销时,前端只展示当前下一步主动作,不一次性摊开全部确认按钮。
|
||||
- 下一步优先级为:费用申请单创建 > 报销单填写 > 附件归集确认。
|
||||
- 小财管家先思考和分析,说明下一步将要做的行为;用户输入“确定”或点击确认后,才进入行动。
|
||||
- 行动完成后重新检查剩余任务队列,继续进入“思考 -> 分析 -> 等待确认 -> 行动”的循环。
|
||||
- 申请任务完成后,再把剩余报销任务作为后续任务引导到报销助手。
|
||||
- 附件归集不作为第一屏主动作抢占申请流程;当进入报销任务时,相关附件随报销上下文带入。
|
||||
|
||||
小财管家必须维护运行时任务上下文,而不是把每次用户输入都当成新的独立意图。上下文至少包含:
|
||||
|
||||
- 当前任务:正在处理的申请或报销任务。
|
||||
- 剩余任务:已拆解但尚未处理的任务队列。
|
||||
- 已完成任务:已经形成申请单、报销草稿、附件归集或提交动作的任务。
|
||||
- 等待动作:当前正在等待用户补字段、确认核对表、确认提交审批或继续下一项。
|
||||
- 最近结构化结果:当前申请核对表、报销核对结果、附件归集建议等。
|
||||
|
||||
用户输入“确认”“无误”“可以提交”等文本时,小财管家必须先匹配当前等待动作;如果当前等待的是申请单提交确认,就提交当前申请单;如果当前等待的是继续下一项,就进入剩余任务队列中的下一项;如果当前核对表仍有缺字段,则提示补字段。只有没有可匹配上下文时,才重新进入任务规划。
|
||||
|
||||
上述匹配不应主要依赖前端关键词规则。第一版应新增“小财管家运行时决策智能体”,由后端 function calling 接收 `runtime_state` 和用户当前输入,返回结构化 `next_action`:
|
||||
|
||||
- `submit_current_application`:确认当前申请核对表并提交至审批。
|
||||
- `continue_next_task`:当前任务已完成,继续剩余任务队列中的下一项。
|
||||
- `fill_current_slot`:用户补充了当前等待字段。
|
||||
- `ask_user`:当前信息不足,需要继续追问。
|
||||
- `plan_new_tasks`:当前没有可匹配上下文,重新进入任务规划。
|
||||
- `cancel_current_action` / `no_op`:取消或不执行当前动作。
|
||||
|
||||
前端只执行模型返回的结构化动作,并做安全校验:例如申请核对表必须 `readyToSubmit` 才能提交,已提交消息必须标记避免重复提交,缺字段时必须追问。本地规则只允许作为模型失败后的保守兜底,不作为主判断来源。
|
||||
|
||||
可以自动执行的动作:
|
||||
|
||||
- 任务拆解。
|
||||
- 本体字段归一化。
|
||||
- 附件分类。
|
||||
- 缺失字段检查。
|
||||
- 风险和规则预审。
|
||||
- 生成确认摘要。
|
||||
|
||||
### 4. 流式过程摘要
|
||||
|
||||
前端展示的是“意图识别智能体”的过程摘要,不是模型内部推理链;过程摘要必须独立于最终回复正文展示。过程摘要必须围绕业务理解展开,例如用户说了什么、被拆成哪些申请/报销任务、已识别哪些业务要素、还缺少哪些关键条件、为什么需要向用户追问。不能只展示“接收确认、协调能力、准备输出”等系统执行日志。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
正在识别用户输入中的财务事项...
|
||||
已识别到 3 个候选任务。
|
||||
正在按时间、地点和费用场景核对附件...
|
||||
发现 3 张附件疑似属于差旅费用,1 张附件需要单独处理。
|
||||
等待你确认后,我再创建申请单或报销草稿。
|
||||
```
|
||||
|
||||
第一版通过 `POST /steward/plans/stream` 返回 `application/x-ndjson` 流式事件:
|
||||
|
||||
- `thinking`:逐条追加到系统回复气泡上方的独立“意图识别智能体”折叠气泡。
|
||||
- `plan`:返回完整任务计划后,再渲染最终正文、任务卡片、附件归集和确认动作。
|
||||
|
||||
流式接口必须在模型 function calling 完成前先返回首个 `thinking` 事件,告知用户“意图识别智能体接管”。后续模型返回后再追加结构化拆解、字段映射、附件归集等过程摘要。
|
||||
|
||||
前端收到 `thinking` 事件后,也必须以 typewriter 方式逐字展示过程摘要,不能把一条完整思考事件一次性塞进折叠气泡。多条 `thinking` 事件应排队顺序输出,上一条内容打完后再输出下一条。
|
||||
|
||||
前端流式超时必须区分“首包等待”和“流式空闲等待”:首包应快速返回,收到首包后不能再用固定总时长中断仍在思考的模型调用,只能在长时间没有任何新事件时判定空闲超时。
|
||||
|
||||
流式过程中正文区域不输出任务结论;计划完成后意图识别气泡默认折叠,正文只保留用户需要确认和执行的信息。
|
||||
|
||||
计划完成后的最终正文也必须流式输出。前端不能把完整正文一次性替换到消息气泡里,而应进入 `typing` 状态按字符逐步追加正文;正文输出完成后,再把状态改为“等待用户确认”并展示确认按钮。
|
||||
|
||||
用户确认当前步骤后,小财管家隐式委派给申请能力或报销能力时,也必须保持同一套流式体验:先在系统气泡上方的小财管家思考折叠气泡中逐字展示当前业务任务、已识别信息、待补充条件和下一步动作;拿到申请核对表或报销核对结果后,再逐字输出正文。结构化表格、核对卡片、确认按钮可以在正文输出完成后一次性展示,但正文不能一次性替换进消息气泡。
|
||||
|
||||
小财管家委派期间不得打开右侧单助手执行流程面板,也不得把“申请助手 / 报销助手”的执行步骤显示成独立助手思考框。用户可见身份保持“小财管家”,具体调用哪个能力只作为小财管家自己的过程摘要,不切换为“财务助手”或单独助手会话。
|
||||
|
||||
### 5. 用户可见结果展示
|
||||
|
||||
小财管家的第一屏最终正文必须采用适中信息量的分段结构:让用户看懂系统理解了哪些财务事项、先后顺序是什么、每一步会交给哪个助手做什么;但不要把任务摘要、置信度、字段缺口和附件判断提前摊开。
|
||||
|
||||
第一屏推荐结构:
|
||||
|
||||
1. `我会这样推进`:说明识别到几个财务事项,以及会逐步处理。
|
||||
2. 顺序列表:说明先做什么、后做什么,每步附一句负责助手和动作边界。
|
||||
3. 确认提示:请用户回复“确定”后开始第一步,并说明具体缺口会在对应步骤里再判断。
|
||||
|
||||
最终正文必须使用 Markdown 块结构渲染,至少包含标题、段落和顺序列表;标题与段落之间必须保留空行,并通过 `steward-plan-markdown` 专属样式拉开块间距。不能只依赖普通换行拼接文本,因为普通换行在对话气泡里会显得拥挤。
|
||||
|
||||
第一屏不展示任务详情卡片里的“还需要补充”,也不展示字段缺口说明。用户确认开始后,进入当前步骤的申请助手或报销助手,再由具体助手基于当前任务判断需要补充什么。
|
||||
|
||||
后续步骤如果需要展示“还需要补充”,必须是结构化列表,每个待补充项独立成行,包含字段业务名称和填写说明;不得把多个待补充项拼接成一行连续文本。
|
||||
|
||||
当后续步骤发现关键条件缺失时,小财管家不能只展示“模型复核不稳定”或“下方表格待补充”。它必须把缺口转成下一轮对话问题,并优先给出可直接选择的业务选项。例如差旅申请缺少 `transport_mode` 时,用户界面展示为“请问你打算怎么出行?火车、飞机或轮船”,不得先展示申请核对表,也不得默认补成火车;用户选择后再生成申请核对表、写回出行方式、重新测算费用,并继续判断是否可以提交申请。这是“思考 -> 行动 -> 再思考 -> 再行动”循环的一部分。
|
||||
|
||||
用户补齐关键字段也不是终态动作。以“出行方式”为例,用户选择火车后,小财管家必须先进入下一轮业务思考,基于已识别的时间、地点、事由和出行方式模拟查询交通票据或票价口径,完成系统预估金额测算,再流式输出正文并展示申请核对表;不能在用户点击选项后直接把旧核对表补字段后闪现出来。
|
||||
|
||||
费用申请核对表阶段不得把系统档案字段或非阻塞归档字段当作用户待补充项。`employee_no`、`employee_name`、`department_name` 应从当前登录用户档案和组织上下文读取;`attachments` 在差旅申请阶段不阻塞核对表生成,可在后续报销、归档或审批材料补充阶段处理;`amount` 在申请阶段由系统规则估算。字段决策模型即使返回这些字段为缺失,服务端也必须过滤,不能向用户展示“附件/凭证和员工编号为合规必需字段”这类错误追问。
|
||||
|
||||
任务卡片和正文不得直接暴露本体字段名,例如 `transport_mode`、`amount`、`attachments`。本体字段只允许作为内部结构化数据进入后端、助手委派和持久化链路;用户界面必须翻译为业务中文,并提供可理解的填写说明:
|
||||
|
||||
- `transport_mode` 展示为“出行方式”,说明可填写高铁、飞机、自驾、出租车等。
|
||||
- `amount` 在申请任务中展示为“预计金额”,在报销任务中展示为“报销金额”。
|
||||
- `attachments` 展示为“附件/凭证”,说明可上传发票、行程单、付款截图或其他证明材料。
|
||||
- `merchant_name` 展示为“商户/开票方”。
|
||||
- `customer_name` 展示为“客户或项目对象”。
|
||||
|
||||
## 本体字段约束
|
||||
|
||||
业务字段必须使用本体 canonical field:
|
||||
|
||||
- `expense_type`
|
||||
- `time_range`
|
||||
- `location`
|
||||
- `reason`
|
||||
- `amount`
|
||||
- `transport_mode`
|
||||
- `attachments`
|
||||
- `customer_name`
|
||||
- `merchant_name`
|
||||
- `department_name`
|
||||
- `employee_name`
|
||||
- `employee_no`
|
||||
|
||||
兼容字段只能作为输入别名,例如:
|
||||
|
||||
- `occurred_date` -> `time_range`
|
||||
- `business_time` -> `time_range`
|
||||
- `reason_value` -> `reason`
|
||||
- `transport_type` -> `transport_mode`
|
||||
- `application_transport_mode` -> `transport_mode`
|
||||
|
||||
小财管家的编排态字段不进入业务语义本体:
|
||||
|
||||
- `plan_id`
|
||||
- `task_id`
|
||||
- `planning_source`
|
||||
- `model_call_traces`
|
||||
- `task_status`
|
||||
- `assigned_agent`
|
||||
- `confirmation_status`
|
||||
- `attachment_group_id`
|
||||
- `thinking_event_id`
|
||||
|
||||
这些字段只用于编排、展示和审计,不参与费用规则判断。
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 后端
|
||||
|
||||
新增小财管家规划服务:
|
||||
|
||||
- `schemas/steward.py`:定义请求、任务计划、附件归集、确认动作等契约。
|
||||
- `services/runtime_chat.py`:新增 `complete_with_tool_call`,复用主/备模型配置发送 `tools` 与 `tool_choice`。
|
||||
- `services/steward_intent_agent.py`:负责构造 `submit_steward_intent_plan` function schema 与模型调用。
|
||||
- `services/steward_model_plan_builder.py`:负责把模型工具参数转换为服务端可校验计划。
|
||||
- `services/steward_planner.py`:负责“大模型 function calling 优先、规则兜底”的编排和本体字段归一化。
|
||||
- `api/v1/endpoints/steward.py`:提供 `POST /steward/plans` 和 `POST /steward/plans/stream`。
|
||||
|
||||
后端第一版不直接落库业务单据,只返回计划和确认动作。确认后的执行仍走现有申请助手、报销助手和 Orchestrator。
|
||||
|
||||
### 前端
|
||||
|
||||
新增或改造能力:
|
||||
|
||||
- 首页输入框标题和提示文案改为“小财管家”。
|
||||
- 工作台打开时默认使用 `sessionType=steward`。
|
||||
- 小财管家模式下隐藏“智能体切换”工具条。
|
||||
- 小财管家模式下不展示欢迎界面。
|
||||
- 小财管家模式下使用专属底部输入框,仅保留附件、自然语言输入和发送动作。
|
||||
- 小财管家模式下先流式渲染独立过程摘要,再渲染任务计划正文。
|
||||
- 用户确认当前下一步后,再切换/分派到申请助手或报销助手执行;多任务按顺序推进,不把所有任务动作一次性展示给用户。
|
||||
|
||||
### 执行流
|
||||
|
||||
```text
|
||||
首页输入
|
||||
↓
|
||||
小财管家计划接口
|
||||
↓
|
||||
意图识别智能体 function calling
|
||||
↓
|
||||
思考过程流式输出 + 任务分析 + 下一步动作说明
|
||||
↓
|
||||
等待用户输入“确定”或点击确认
|
||||
↓
|
||||
小财管家隐式调用申请助手创建申请单核对结果
|
||||
↓
|
||||
申请动作完成后重新思考剩余队列
|
||||
↓
|
||||
继续等待确认并隐式调用报销助手填写报销单
|
||||
↓
|
||||
执行结果汇总
|
||||
```
|
||||
|
||||
## 算法与公式
|
||||
|
||||
第一版主路径不以关键词规则定义“意图”,而是使用大模型 function calling 生成结构化计划。
|
||||
模型输出后由服务端做确定性校验、字段归一化和确认动作生成。
|
||||
|
||||
规则置信度评分仅用于模型不可用或模型返回结构不可用时的兜底路径。
|
||||
|
||||
任务拆解之后还需要第二层“任务字段决策智能体”。这一步不能由前端关键词或固定 required 字段直接决定,而要把当前任务类型、用户原话、上游任务拆解结果、canonical ontology fields、已抽取字段、缺失字段、附件和申请/报销上下文交给模型,通过 function calling 返回下一步动作:
|
||||
|
||||
- `ask_user`:当前信息不足,必须先把缺口转成业务问题和可选项。
|
||||
- `render_preview`:当前信息足够生成可核对结果,但提交、入库、绑定附件前仍需用户确认。
|
||||
|
||||
字段决策规则只能作为模型不可用或结构化结果非法时的兜底,兜底结果必须标记为 `rule_fallback`,不能伪装成智能体判断。字段名必须来自 ontology registry;UI 只展示中文业务名称,不展示 canonical 字段名。
|
||||
|
||||
任务置信度:
|
||||
|
||||
$$
|
||||
confidence = \min(1, 0.35s_i + 0.25s_t + 0.2s_l + 0.2s_a)
|
||||
$$
|
||||
|
||||
变量说明:
|
||||
|
||||
- `s_i`:意图关键词得分,命中申请/报销核心动词。
|
||||
- `s_t`:时间得分,识别到明确日期、相对日期或时间范围。
|
||||
- `s_l`:地点得分,识别到城市、客户或业务对象。
|
||||
- `s_a`:附件/费用场景得分,识别到票据、交通、住宿、招待等费用线索。
|
||||
|
||||
附件归集置信度:
|
||||
|
||||
$$
|
||||
group\_score = 0.4m_s + 0.3m_t + 0.2m_l + 0.1m_n
|
||||
$$
|
||||
|
||||
变量说明:
|
||||
|
||||
- `m_s`:附件场景与任务场景匹配度。
|
||||
- `m_t`:附件日期与任务日期匹配度。
|
||||
- `m_l`:附件地点与任务地点匹配度。
|
||||
- `m_n`:附件名称和任务关键词匹配度。
|
||||
|
||||
## 测试方案
|
||||
|
||||
### 后端单元测试
|
||||
|
||||
- function calling 路径能把模型工具参数转换为 `planning_source=llm_function_call` 的任务计划。
|
||||
- 模型返回 `occurred_date`、`transport_type`、`reason_value` 等别名时,服务端仍只输出 canonical 字段。
|
||||
- 一句话中同时包含申请和报销时,返回多个任务。
|
||||
- “昨天”能根据 `client_now_iso` 解析为明确日期。
|
||||
- `occurred_date`、`transport_type`、`reason_value` 等兼容字段不会作为业务 canonical 字段输出。
|
||||
- 多附件能生成差旅归集建议和排除项。
|
||||
- 创建/绑定/提交类动作必须带 `confirmation_required=true`。
|
||||
|
||||
### 前端测试
|
||||
|
||||
- 首页输入复杂话术后打开小财管家模式。
|
||||
- 小财管家模式标题显示“小财管家”,不展示智能体切换。
|
||||
- 过程摘要按步骤渐进展示。
|
||||
- 任务计划卡片展示申请任务和报销任务。
|
||||
- 附件归集建议展示包含项、排除项和确认按钮。
|
||||
|
||||
### 容器验证
|
||||
|
||||
后端测试必须在 `x-financial-main` 容器内执行:
|
||||
|
||||
```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_steward_planner.py
|
||||
```
|
||||
|
||||
前端构建优先使用宿主机 `npm.cmd` 或项目既有脚本,并设置合理超时。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 输入包含 3 个任务的示例话术时,至少识别出 1 个申请任务和 2 个报销任务。
|
||||
- 输入“明天出差北京3天,支撑国网仿生产部署,并且报销昨天业务招待费”时,必须识别出 1 个申请任务和 1 个报销任务。
|
||||
- 模型可用时,小财管家计划响应包含 `planning_source=llm_function_call`。
|
||||
- 小财管家计划响应中业务字段只出现 canonical ontology fields。
|
||||
- 附件场景混合时,能区分差旅相关附件和非差旅附件。
|
||||
- 前端弹窗标题为“小财管家”,并隐藏智能体切换。
|
||||
- 前端确认区只展示当前下一步主动作;存在申请任务时,第一步必须是“先创建申请单”。
|
||||
- 意图识别折叠气泡不得宽于正文气泡,且流式首包必须先于最终计划到达。
|
||||
- 用户未确认前,不创建申请单、不创建报销草稿、不绑定附件、不提交审批。
|
||||
- 后端定向测试通过。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 模型供应商对 tools/function calling 的兼容度可能不同;第一版保留规则兜底和主备模型 failover。
|
||||
- 规则兜底无法覆盖所有自然语言,需要保留人工确认和低置信度提示。
|
||||
- 附件真实 OCR 归集依赖现有票据识别质量;第一版先使用附件名称和已有 OCR 摘要做轻量归集。
|
||||
- NDJSON 流式输出展示的是过程摘要,不是模型内部推理链。
|
||||
- 多任务之间可能共享日期、地点、申请单上下文,需要后续完善任务图依赖。
|
||||
- 如果未来接入 LangGraph,应基于当前计划契约迁移,而不是推翻现有申请/报销助手。
|
||||
66
document/development/小财管家/TODO.md
Normal file
66
document/development/小财管家/TODO.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 小财管家 TODO
|
||||
|
||||
## 阶段一:调研与契约
|
||||
|
||||
- [x] 盘点首页输入框、工作台弹窗、会话路由和本体字段注册表。[CONCEPT: 背景与问题] 证据:已确认 `PersonalWorkbench.vue`、`useAppShell.js`、`TravelReimbursementCreateView.vue`、`ontology_field_registry.py`。
|
||||
- [x] 定义第一版只覆盖申请助手和报销助手,不引入 LangChain,但外层意图识别使用大模型 function calling。[CONCEPT: 目标与非目标] 证据:`CONCEPT.md` 已明确 MVP 边界和 function calling 主链路。
|
||||
- [x] 明确小财管家业务字段必须走 ontology canonical fields,编排字段不得进入业务本体。[CONCEPT: 本体字段约束] 证据:`CONCEPT.md` 已列出 canonical 字段与编排态字段。
|
||||
|
||||
## 阶段二:后端规划服务
|
||||
|
||||
- [x] 新增 `schemas/steward.py`,定义计划请求、任务、附件归集、确认动作和响应模型。[CONCEPT: 后端] 证据:`StewardPlanRequest`、`StewardTask`、`StewardAttachmentGroup`、`StewardConfirmationAction` 已新增。
|
||||
- [x] 扩展 `services/runtime_chat.py`,支持 OpenAI-compatible / Azure OpenAI 的 `tools` 与 `tool_choice` function calling。[CONCEPT: 后端] 证据:新增 `complete_with_tool_call`、`RuntimeChatToolCall` 和工具调用解析。
|
||||
- [x] 新增 `services/steward_intent_agent.py`,定义 `submit_steward_intent_plan` function schema 并调用系统主/备模型。[CONCEPT: 任务识别与拆分] 证据:模型调用入口已从 `StewardPlannerService` 注入。
|
||||
- [x] 新增 `services/steward_model_plan_builder.py`,将模型工具参数转换为服务端可校验计划。[CONCEPT: 后端] 证据:模型返回后仍会校验任务类型、canonical 字段和附件名。
|
||||
- [x] 改造 `services/steward_planner.py`,实现大模型 function calling 优先、规则规划兜底。[CONCEPT: 任务识别与拆分] 证据:`planning_source` 区分 `llm_function_call` 与 `rule_fallback`。
|
||||
- [x] 新增 `api/v1/endpoints/steward.py`,提供 `POST /steward/plans`。[CONCEPT: 后端] 证据:容器内接口 smoke 返回 `task_count=3`。
|
||||
- [x] 新增 `POST /steward/plans/stream`,以 NDJSON 返回 `thinking` 和最终 `plan` 事件。[CONCEPT: 流式过程摘要] 证据:真实接口 smoke 返回事件序列 `thinking,thinking,thinking,thinking,plan`。
|
||||
- [x] 调整 `POST /steward/plans/stream`,确保模型 function calling 完成前先返回首个 `thinking` 事件。[CONCEPT: 流式过程摘要] 证据:live smoke 首个事件为 `thinking/stream_start`。
|
||||
- [x] 在 `api/v1/router.py` 注册小财管家接口。[CONCEPT: 后端] 证据:运行中 `/api/v1/steward/plans` 返回 200。
|
||||
- [x] 保证所有输出到 `ontology_fields` 的业务字段只使用 canonical ontology fields。[CONCEPT: 本体字段约束] 证据:测试断言 `occurred_date`、`transport_type`、`reason_value` 不进入输出字段。
|
||||
- [x] 强化模型提示词和规则兜底,确保“未来出差/去某地几天/部署支撑”即使未出现“申请”也识别为费用申请。[CONCEPT: 任务识别与拆分] 证据:live smoke 将“明天出差北京3天...”拆为 `expense_application,reimbursement`。
|
||||
|
||||
## 阶段三:前端入口和弹窗
|
||||
|
||||
- [x] 将首页输入区主文案调整为“小财管家”,提示语体现可处理多任务。[CONCEPT: 前端] 证据:`PersonalWorkbench.vue` 标题和 placeholder 已更新。
|
||||
- [x] 增加 `steward` 会话类型,首页复杂输入默认进入小财管家模式。[CONCEPT: 前端] 证据:`SESSION_TYPE_STEWARD` 与首页 `sessionType` 已接入。
|
||||
- [x] 小财管家模式下隐藏“智能体切换”工具条。[CONCEPT: 前端] 证据:`shortcuts` 在 `isStewardSession` 下返回空数组。
|
||||
- [x] 小财管家模式下标题显示“小财管家”,副标题说明“统一财务任务编排入口”。[CONCEPT: 前端] 证据:`assistantHeaderTitle` 和 `assistantHeaderDescription` 已按 steward 分支处理。
|
||||
- [x] 小财管家模式下不展示欢迎界面。[CONCEPT: 前端] 证据:`useTravelReimbursementSessionState.js` 对 steward 空会话返回空消息,并过滤旧欢迎消息快照。
|
||||
- [x] 小财管家模式下使用专属底部输入框,不展示日期选择、差旅计算器和业务时间标签。[CONCEPT: 前端] 证据:`TravelReimbursementCreateView.vue` 按 `isStewardSession` 渲染 `steward-composer-row`。
|
||||
- [x] 新增前端小财管家计划服务,调用 `POST /steward/plans`。[CONCEPT: 后端] 证据:`web/src/services/steward.js` 已新增 `fetchStewardPlan`。
|
||||
- [x] 新增小财管家视图模型,生成过程摘要、任务计划卡片和附件归集卡片。[CONCEPT: 流式过程摘要] 证据:`stewardPlanModel.js` 和 `TravelReimbursementMessageItem.vue` 已接入 `stewardPlan`。
|
||||
- [x] 支持后端 `thinking` 事件真流式呈现为折叠式意图识别气泡。[CONCEPT: 流式过程摘要] 证据:`useStewardPlanFlow.js` 通过 `fetchStewardPlanStream` 接收 thinking 事件,并用 `typeStewardThinkingEvent` 将每条过程摘要逐字输出到折叠气泡。
|
||||
- [x] 支持小财管家最终正文逐字流式输出,正文完成前不展示确认按钮。[CONCEPT: 流式过程摘要] 证据:`useStewardPlanFlow.js` 新增 `typeStewardPlanText`,计划完成后进入 `typing` 状态逐字追加正文,完成后再注入 `suggestedActions`。
|
||||
- [x] 意图识别过程放在系统回复气泡上方,作为不同颜色的独立折叠气泡,完成后默认折叠。[CONCEPT: 流式过程摘要] 证据:`TravelReimbursementMessageItem.vue` 将 `steward-intent-bubble` 放在 `message-bubble` 上方,`steward-plan-block` 只渲染任务和附件结果。
|
||||
- [x] 统一小财管家思考折叠气泡与正文气泡宽度,避免思考气泡长于正文框。[CONCEPT: 流式过程摘要] 证据:`has-steward-plan` 消息栈统一为 760px,思考气泡和正文气泡同宽。
|
||||
- [x] 优化小财管家最终正文和任务卡片层次,用户可见内容不直接展示本体字段名。[CONCEPT: 用户可见结果展示] 证据:`stewardPlanModel.js` 第一屏使用 Markdown 标题、段落和顺序列表说明“先做什么、后做什么、交给哪个助手做什么”,但不展示置信度和字段缺口;`useStewardPlanFlow.js` 将第一屏标记为 `initialSummaryOnly`,`TravelReimbursementMessageItem.vue` 不再渲染第一屏任务详情卡片;后续步骤如需展示待补充项,再按独立列表行展示业务名称和填写说明。
|
||||
|
||||
## 阶段四:确认与分派
|
||||
|
||||
- [x] 为每个创建/绑定/提交类动作生成确认按钮,不确认不执行。[CONCEPT: 用户确认] 证据:接口返回 `confirmation_count=4`,前端转为 suggested actions。
|
||||
- [x] 将小财管家确认区改为“只展示当前下一步主动作”,存在申请任务时优先进入申请助手。[CONCEPT: 用户确认] 证据:`buildStewardSuggestedActions` 只返回下一步动作,优先 `confirm_create_application`。
|
||||
- [x] 支持用户输入“确定”触发小财管家的下一步动作,而不是重新生成计划。[CONCEPT: 用户确认] 证据:`useStewardPlanFlow` 会查找待确认的小财管家动作并执行。
|
||||
- [x] 支持小财管家隐藏委派申请/报销能力,执行后保留小财管家会话并继续引导剩余任务。[CONCEPT: 执行流] 证据:`sessionTypeOverride` 和 `stewardContinuation` 已接入前端提交流程。
|
||||
- [x] 支持小财管家确认后的隐式委派继续流式输出,正文完成后再展示申请核对表、报销核对卡片和确认按钮。[CONCEPT: 流式过程摘要] 证据:`useTravelReimbursementSubmitComposer.js` 新增 `typeStewardDelegatedMessage`,申请预览与 orchestrator 结果均先流式思考、再逐字正文、最后挂载结构化 payload;`npm.cmd --prefix web run build` 成功。
|
||||
- [x] 小财管家委派申请/报销能力期间不打开右侧单助手执行流程面板,用户可见身份保持“小财管家”。[CONCEPT: 流式过程摘要] 证据:`stewardDelegated` 分支跳过 flow step 与 review panel scope,并在最终消息设置 `assistantName: '小财管家'`;`stewardPlanModel.js` 助手标签兜底不再显示“财务助手”。
|
||||
- [x] 小财管家在后续步骤发现关键缺口时,主动转成可回答的问题和选项,而不是只展示待补充表格。[CONCEPT: 用户可见结果展示] 证据:`useTravelReimbursementSubmitComposer.js` 在申请核对缺少“出行方式”时只输出主动追问和火车/飞机/轮船选项,不提前挂载 `applicationPreview`;`stewardPlanModel.js` 的内部 `carry_text` 不再把“高铁、飞机”等示例写进缺字段提示,避免本地抽取误当成用户已选择;`TravelReimbursementCreateView.js` 在用户选择后不再直接补旧表格,而是重新进入小财管家的委派流;`web/tests/expense-application-fast-preview.test.mjs` 覆盖该回归。
|
||||
- [x] 用户补齐出行方式后,小财管家必须先思考、模拟查询票据和测算金额,再展示申请核对表。[CONCEPT: 用户可见结果展示] 证据:`stewardFieldCompletionModel.js` 将补齐字段后的当前任务、本体字段和旧预览重组为续跑输入;`TravelReimbursementCreateView.js` 的 `continueStewardApplicationFieldCompletion` 调用 `submitComposerInternal` 触发流式思考、申请复核和费用测算,不再调用 `commitApplicationPreviewEditor` 直接闪现表格。
|
||||
- [x] 防止残留预算上下文抢占小财管家的申请续跑链路。[CONCEPT: 执行流] 证据:`budgetAssistantReportModel.js` 不再因存在 `initialBudgetContext` 就无条件进入预算编制报告;`useTravelReimbursementSubmitComposer.js` 对 `stewardDelegated` 显式跳过预算编制分支;`expense-application-fast-preview.test.mjs` 覆盖“申请续跑 + 残留预算上下文”不得进入预算编制。
|
||||
- [x] 支持用户直接输入“确认/无误/可以提交”命中当前申请核对表提交动作,而不是重新规划。[CONCEPT: 用户确认] 证据:`TravelReimbursementCreateView.js` 通过 `handleStewardRuntimeDecision` 优先请求运行时决策智能体;模型返回 `submit_current_application` 后复用 `confirmApplicationSubmit`;本地 `handleApplicationSubmitConfirmationText` 仅作为模型不可用时的兜底;提交成功后标记 `applicationSubmitConfirmed`,避免后续重复提交;测试 `text confirmation submits pending application preview before replanning steward task` 覆盖该优先级。
|
||||
- [x] 增加小财管家运行时决策智能体,把“确认、继续下一项、补字段、重新规划”的上下文判断迁到后端 function calling。[CONCEPT: 用户确认] 证据:`POST /steward/runtime-decisions` 调用 `StewardRuntimeDecisionAgent`,通过 `submit_steward_runtime_decision` 返回 `submit_current_application`、`continue_next_task`、`fill_current_slot`、`plan_new_tasks` 等动作;前端 `handleStewardRuntimeDecision` 先提交 `runtime_state`,再执行模型返回的结构化动作,本地规则仅兜底。
|
||||
- [x] 增加第二层任务字段决策智能体,动态判断当前任务应追问用户还是展示核对结果。[CONCEPT: 算法与公式] 证据:`POST /steward/slot-decisions` 调用 `StewardSlotDecisionAgent`,通过 `submit_steward_slot_decision` function calling 输出 `ask_user` / `render_preview`、canonical 缺失字段、问题和选项;前端 `useTravelReimbursementSubmitComposer.js` 在小财管家委派申请时消费该决策。
|
||||
- [x] 防止字段决策模型把申请阶段非阻塞字段误判为用户必填项。[CONCEPT: 用户可见结果展示] 证据:`StewardSlotDecisionAgent` 过滤 `amount`、`attachments`、`employee_no`、`department_name`、`employee_name`,模型误返 `ask_user` 且过滤后无缺口时改为 `render_preview`;前端 `APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS` 同步过滤兜底缺口和选项;测试覆盖附件/员工编号误判。
|
||||
- [x] 小财管家思考气泡必须体现业务意图和关键缺口,不能退化为系统执行日志。[CONCEPT: 流式过程摘要] 证据:`steward_planner.py` 将差旅申请缺少“出行方式”纳入计划缺口并追加业务缺口思考事件;`useTravelReimbursementSubmitComposer.js` 和 `TravelReimbursementCreateView.js` 的确认后思考改为读取任务摘要、已识别信息和待补充项。
|
||||
- [x] 确认申请任务后,将任务摘要分派到现有申请助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=application` 和 `auto_submit=true`。
|
||||
- [x] 确认报销任务后,将任务摘要和附件带入现有报销助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=expense`、`carry_files=true` 和 `auto_submit=true`。
|
||||
- [x] 附件归集建议确认后,将选中附件作为报销助手上下文继续处理。[CONCEPT: 附件归集] 证据:附件归集确认动作携带归集附件名称和排除附件名称。
|
||||
|
||||
## 阶段五:测试与验证
|
||||
|
||||
- [x] 新增 `server/tests/test_steward_planner.py`,覆盖多任务拆解、相对日期、附件归集、确认动作和流式事件。[CONCEPT: 测试方案] 证据:新增 4 个后端定向测试。
|
||||
- [x] 补充 function calling 定向测试,覆盖模型工具参数、canonical 字段清洗、附件归集和规则兜底。[CONCEPT: 后端单元测试] 证据:`test_steward_planner.py` 新增 fake function calling 路径,`test_runtime_chat_service.py` 新增 tools payload 用例。
|
||||
- [x] 后端测试在 Docker `x-financial-main:/app` 内执行,超时控制在 60s 内。[CONCEPT: 容器验证] 证据:`pytest -q server/tests/test_steward_planner.py server/tests/test_runtime_chat_service.py` 结果 `13 passed`。
|
||||
- [ ] 新增或更新前端定向测试,覆盖小财管家标题、隐藏智能体切换和计划展示。[CONCEPT: 前端测试]
|
||||
- [x] 运行前端构建或定向测试,确认 UI 编译通过。[CONCEPT: 测试方案] 证据:`npm.cmd run build` 成功。
|
||||
- [x] 通过接口或页面可见结果证明用户最终看到小财管家计划和确认点。[CONCEPT: 指标与验收] 证据:容器接口返回 3 个任务、3 份归集附件、1 份排除附件和 4 个确认动作。
|
||||
273
document/development/小财管家本体JSON流程/CONCEPT.md
Normal file
273
document/development/小财管家本体JSON流程/CONCEPT.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# 小财管家本体 JSON 流程
|
||||
|
||||
## 功能一句话
|
||||
|
||||
用大模型作为小财管家的主意图识别器,将用户连续对话转换为受本体字段约束的业务 JSON,并在申请和报销意图不确定时先进入用户确认,而不是用固定规则直接判定。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前小财管家已经具备任务规划、部分运行时状态和申请/报销委派能力,但仍有两个关键缺口:
|
||||
|
||||
- 意图识别仍带有较强规则假设。例如“2月20-23日去上海出差辅助国网仿生产环境部署”这类话术,在没有“申请”或“报销”动词时,系统不能仅凭规则直接判定为申请。
|
||||
- 跨轮对话需要一个贯穿流程的结构化 JSON。该 JSON 必须只承载本体 canonical field,不能由前端、规则或大模型临时发明业务字段。
|
||||
|
||||
因此,本轮目标不是重写整个小财管家,而是在现有 `steward` 体系上补齐“LLM 主识别 + 本体 JSON 模板 + 待确认流程 + 上下文记忆”的闭环。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
- 用大模型 function calling 作为主路径识别用户意图。
|
||||
- 模型输出必须落到统一业务 JSON 模板,字段来源必须来自本体字段注册表。
|
||||
- 支持 `travel_application` 和 `travel_reimbursement` 两个业务流程。
|
||||
- 当用户话术无法确定是申请还是报销时,返回 `pending_flow_confirmation`,由前端展示两个明确选项。
|
||||
- 跨轮对话持续携带并合并 `steward_state`,直到用户完成、取消或切换业务。
|
||||
- 规则只做兜底,且响应必须标记 `rule_fallback`,不能伪装成模型判断。
|
||||
- 用户可见回复使用 Markdown 块结构,重点信息加粗,避免密集换行。
|
||||
|
||||
### 非目标
|
||||
|
||||
- 本轮不引入 LangChain 或 LangGraph。
|
||||
- 本轮不迁移申请助手、报销助手和 Orchestrator 的既有核心逻辑。
|
||||
- 本轮不让大模型直接创建申请单、保存草稿、绑定附件或提交审批。
|
||||
- 本轮不新增脱离本体字段体系的新业务字段。
|
||||
- 本轮不改造所有财务场景,只先覆盖出差申请和差旅/费用报销。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 普通员工:在首页或小财管家对话框中说“2月20-23日去上海出差辅助国网仿生产环境部署”。
|
||||
- 小财管家:先判断该话术包含出差、时间、地点和事由,但缺少“申请还是报销”的明确动作。
|
||||
- 用户:点击“补办出差申请”或“发起费用报销”。
|
||||
- 系统:将用户选择写入同一个业务 JSON,并继续用对应流程追问缺字段、生成核对结果或委派现有助手。
|
||||
|
||||
示例预期:
|
||||
|
||||
```markdown
|
||||
我识别到你描述的是一次 **上海出差事项**,时间为 **2月20日至2月23日**,事由是 **辅助国网仿生产环境部署**。
|
||||
|
||||
但当前还不能确定你要做哪一件事:
|
||||
|
||||
1. **补办出差申请**
|
||||
2. **发起费用报销**
|
||||
|
||||
请先选择一个方向,我会继续整理对应材料。
|
||||
```
|
||||
|
||||
## 功能能力
|
||||
|
||||
### 输入
|
||||
|
||||
- 用户自然语言 `message`。
|
||||
- 当前时间 `client_now_iso`,用于解析相对日期。
|
||||
- 附件元信息和 OCR 摘要。
|
||||
- 当前 `conversation_id`。
|
||||
- 已持久化 `steward_state`。
|
||||
- ontology canonical fields 列表。
|
||||
|
||||
### 输出
|
||||
|
||||
- `steward_state`:贯穿对话的业务 JSON。
|
||||
- `intent_result`:本轮模型或兜底规则的识别结果。
|
||||
- `candidate_flows`:存在歧义时的候选流程。
|
||||
- `next_action`:下一步动作,例如追问、确认流程、渲染申请预览、渲染报销预审。
|
||||
- `markdown_reply`:面向用户的 Markdown 回复。
|
||||
|
||||
### 状态边界
|
||||
|
||||
业务 JSON 必须区分业务字段和编排字段:
|
||||
|
||||
- 业务字段只允许出现在 `flows.<flow_id>.fields`。
|
||||
- 业务字段 key 必须是 canonical ontology field。
|
||||
- 编排字段只能出现在 `active_flow`、`pending_flow_confirmation`、`events`、`status` 等结构里。
|
||||
- 规则或模型返回的别名字段必须先归一化,例如 `occurred_date -> time_range`、`transport_type -> transport_mode`、`reason_value -> reason`。
|
||||
|
||||
### 安全边界
|
||||
|
||||
- 保存草稿、创建申请单、提交审批、删除或绑定附件必须等待用户确认。
|
||||
- LLM 只能产出结构化建议,不直接执行副作用操作。
|
||||
- 如果模型返回非法字段、非法流程或非法动作,服务端丢弃非法部分并进入保守兜底。
|
||||
|
||||
## 业务 JSON 模板
|
||||
|
||||
目标模板如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "steward.flow_state.v2",
|
||||
"active_flow": "",
|
||||
"pending_flow_confirmation": {
|
||||
"status": "none",
|
||||
"source_message": "",
|
||||
"reason": "",
|
||||
"candidate_flows": []
|
||||
},
|
||||
"flows": {
|
||||
"travel_application": {
|
||||
"flow_id": "travel_application",
|
||||
"intent": "travel_application_create",
|
||||
"status": "idle",
|
||||
"fields": {},
|
||||
"missing_fields": [],
|
||||
"confidence": 0,
|
||||
"evidence": []
|
||||
},
|
||||
"travel_reimbursement": {
|
||||
"flow_id": "travel_reimbursement",
|
||||
"intent": "travel_reimbursement_draft",
|
||||
"status": "idle",
|
||||
"fields": {},
|
||||
"missing_fields": [],
|
||||
"linked_application_claim_id": "",
|
||||
"attachments": [],
|
||||
"confidence": 0,
|
||||
"evidence": []
|
||||
}
|
||||
},
|
||||
"events": []
|
||||
}
|
||||
```
|
||||
|
||||
候选流程结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"flow_id": "travel_application",
|
||||
"label": "补办出差申请",
|
||||
"confidence": 0.52,
|
||||
"reason": "用户描述了出差时间、地点和事由,但没有明确要求报销或提交申请。"
|
||||
}
|
||||
```
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 后端
|
||||
|
||||
新增或扩展以下职责:
|
||||
|
||||
- `schemas/steward.py`:增加 v2 JSON 状态、候选流程、待确认流程和意图识别响应模型。
|
||||
- `services/steward_intent_agent.py`:扩展 function schema,允许模型返回 `pending_flow_confirmation` 和 `candidate_flows`。
|
||||
- `services/steward_model_plan_builder.py`:校验模型输出,只保留合法 flow、合法 action 和 canonical ontology fields。
|
||||
- `services/steward_flow_state.py`:支持 v1 到 v2 状态兼容、字段 patch 合并、候选流程落态和事件追踪。
|
||||
- `services/steward_runtime_decision_agent.py`:识别用户点击或输入的流程选择,并把选择写回 `active_flow`。
|
||||
- `api/v1/endpoints/steward.py`:在 `/steward/plans`、`/steward/plans/stream`、`/steward/runtime-decisions` 中统一返回最新 `steward_state`。
|
||||
|
||||
### 前端
|
||||
|
||||
- `stewardPlanModel.js`:将 `pending_flow_confirmation` 转为可点击操作。
|
||||
- `TravelReimbursementCreateView.js`:用户点击候选流程后,优先走 runtime decision,不重新把原句当新任务规划。
|
||||
- `useStewardPlanFlow.js`:渲染 Markdown 回复和候选流程操作。
|
||||
- `useTravelReimbursementSessionState.js`:持续保存并传回 `conversation_id` 和 `steward_state`。
|
||||
|
||||
### 数据与持久化
|
||||
|
||||
- 复用 `AgentConversation.state_json` 持久化 `steward_state`。
|
||||
- 不新增数据库表。
|
||||
- 不改变申请单、报销单现有表结构。
|
||||
|
||||
### 接口契约
|
||||
|
||||
`POST /steward/plans` 和流式计划接口返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"planning_source": "llm_function_call",
|
||||
"conversation_id": "conv_xxx",
|
||||
"steward_state": {},
|
||||
"next_action": "confirm_flow",
|
||||
"candidate_flows": [],
|
||||
"summary": "Markdown 文本"
|
||||
}
|
||||
```
|
||||
|
||||
运行时确认接口返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision_source": "llm_function_call",
|
||||
"next_action": "continue_selected_flow",
|
||||
"steward_state": {},
|
||||
"response_text": "Markdown 文本"
|
||||
}
|
||||
```
|
||||
|
||||
## 算法与公式
|
||||
|
||||
主路径不使用关键词打分决定最终意图,而是由 LLM function calling 返回结构化候选结果。
|
||||
|
||||
规则兜底仅在模型不可用、超时或结构非法时使用。兜底置信度用于决定是否直接进入候选确认:
|
||||
|
||||
$$
|
||||
confidence(flow) = 0.35t + 0.25l + 0.25v + 0.15a
|
||||
$$
|
||||
|
||||
变量定义:
|
||||
|
||||
- `t`:时间线索得分,出现明确日期、日期区间或相对日期时取 1,否则取 0。
|
||||
- `l`:地点线索得分,出现城市、客户地点或项目地点时取 1,否则取 0。
|
||||
- `v`:动作线索得分,出现申请、报销、提交、保存草稿等动作词时取 1,否则取 0。
|
||||
- `a`:附件线索得分,存在票据、发票、行程单、OCR 金额等附件证据时取 1,否则取 0。
|
||||
|
||||
当最高候选流程与第二候选流程差值小于阈值时进入确认:
|
||||
|
||||
$$
|
||||
\Delta = confidence(flow_1) - confidence(flow_2) < 0.20
|
||||
$$
|
||||
|
||||
该公式只用于兜底路径,不能覆盖模型主判断。
|
||||
|
||||
## 测试方案
|
||||
|
||||
### 后端单元测试
|
||||
|
||||
- `test_steward_intent_agent.py`:覆盖 function schema 包含 `candidate_flows`、`pending_flow_confirmation`。
|
||||
- `test_steward_model_plan_builder.py`:覆盖非法字段过滤、别名归一、非法 flow 丢弃。
|
||||
- `test_steward_flow_state.py`:覆盖 v2 状态合并、候选流程落态、用户选择后 active flow 切换。
|
||||
- `test_steward_runtime_decision_agent.py`:覆盖用户选择“补办出差申请 / 发起费用报销”。
|
||||
|
||||
### 接口测试
|
||||
|
||||
- `/steward/plans` 输入“2月20-23日去上海出差辅助国网仿生产环境部署”,返回 `next_action=confirm_flow`。
|
||||
- `/steward/runtime-decisions` 选择“补办出差申请”后,`active_flow=travel_application`。
|
||||
- `/steward/runtime-decisions` 选择“发起费用报销”后,`active_flow=travel_reimbursement`。
|
||||
|
||||
### 前端测试
|
||||
|
||||
- 候选流程按钮只在 `pending_flow_confirmation.status=pending` 时展示。
|
||||
- 用户点击候选流程后不重复触发新计划。
|
||||
- Markdown 回复中标题、段落、列表和重点加粗能正确渲染。
|
||||
|
||||
### 容器验证
|
||||
|
||||
后端测试必须在 Docker 容器内执行:
|
||||
|
||||
```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_steward_intent_agent.py server/tests/test_steward_model_plan_builder.py server/tests/test_steward_flow_state.py server/tests/test_steward_runtime_decision_agent.py
|
||||
```
|
||||
|
||||
前端构建必须在容器内执行:
|
||||
|
||||
```bash
|
||||
docker exec -w /app/web x-financial-main npm run build
|
||||
```
|
||||
|
||||
单次测试命令最长等待 60 秒,避免任务卡死。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 对“2月20-23日去上海出差辅助国网仿生产环境部署”,系统不再直接判定为申请,而是返回两个候选流程并要求用户确认。
|
||||
- 用户选择“补办出差申请”后,同一 `conversation_id` 的 `steward_state.active_flow=travel_application`。
|
||||
- 用户选择“发起费用报销”后,同一 `conversation_id` 的 `steward_state.active_flow=travel_reimbursement`。
|
||||
- `flows.*.fields` 中不出现非本体字段。
|
||||
- 模型返回别名字段时,服务端输出仍为 canonical ontology field。
|
||||
- 模型不可用时,规则兜底结果明确标记 `rule_fallback`。
|
||||
- 用户未确认前,不创建申请单、不保存报销草稿、不提交审批、不绑定附件。
|
||||
- 前端候选流程按钮点击后不产生重复消息、不重复规划、不丢失上下文。
|
||||
- 后端定向测试和前端构建在 `x-financial-main:/app` 通过。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 模型供应商对 function calling 的兼容程度不同,需要保留严格的服务端结构校验。
|
||||
- 旧版 `steward_state.v1` 已有数据需要兼容升级到 v2。
|
||||
- 用户输入可能同时包含“补申请”和“报销”,这种情况不应进入歧义确认,而应拆成两个任务。
|
||||
- 过去日期不等于报销,未来日期也不绝对等于申请;最终应由 LLM 主识别,并用候选确认处理低确定性场景。
|
||||
- 后续如果要支持更多流程,例如审批、制度问答或预算查询,需要先扩展本体业务契约,再扩展本 JSON 模板。
|
||||
75
document/development/小财管家本体JSON流程/TODO.md
Normal file
75
document/development/小财管家本体JSON流程/TODO.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 小财管家本体 JSON 流程 TODO
|
||||
|
||||
> 开发时必须先更新本 TODO,再按小步执行。只有真实完成并通过对应验证后,才能把 `[ ]` 改成 `[x]` 并补充证据。
|
||||
|
||||
## 阶段一:调研与契约确认
|
||||
|
||||
- [x] 盘点 `schemas/steward.py`、`steward_intent_agent.py`、`steward_model_plan_builder.py`、`steward_flow_state.py` 的当前状态模型。[CONCEPT: 方案设计] 证据:已在实现前读取并确认现有 `steward_state`、planner、runtime decision 入口。
|
||||
- [x] 盘点 `ontology_field_registry.py` 中申请和报销可使用的 canonical ontology fields。[CONCEPT: 业务 JSON 模板] 证据:实现复用 `BUSINESS_CANONICAL_FIELDS` 与 `normalize_ontology_form_values`。
|
||||
- [x] 确认 `AgentConversation.state_json` 中已有 `steward_state.v1` 数据的兼容方式。[CONCEPT: 数据与持久化] 证据:`StewardFlowStateService._normalize_state` 兼容旧 state 并升级默认版本为 `steward.flow_state.v2`。
|
||||
- [x] 复核前端 `stewardPlanModel.js`、`useStewardPlanFlow.js`、`TravelReimbursementCreateView.js` 中候选动作和状态携带入口。[CONCEPT: 前端] 证据:前端子智能体只读检查确认建议动作入口可复用。
|
||||
|
||||
## 阶段二:后端 Schema 与 JSON 模板
|
||||
|
||||
- [x] 在 `schemas/steward.py` 增加 `StewardCandidateFlow`、`StewardPendingFlowConfirmation`、v2 `steward_state` 相关模型。[CONCEPT: 业务 JSON 模板] 证据:新增模型与 `StewardPlanResponse.pending_flow_confirmation`。
|
||||
- [x] 在 `StewardPlanResponse` 和 runtime response 中补充 `next_action`、`candidate_flows` 或等价结构,保持旧字段兼容。[CONCEPT: 接口契约] 证据:`StewardPlanResponse.next_action/candidate_flows` 与 `continue_selected_flow` 已接入。
|
||||
- [x] 编写 schema 单元测试,验证候选流程只允许 `travel_application` 和 `travel_reimbursement`。[CONCEPT: 安全边界] 证据:`test_steward_intent_agent.py` 覆盖 function schema 枚举。
|
||||
|
||||
## 阶段三:LLM 意图识别主路径
|
||||
|
||||
- [x] 扩展 `steward_intent_agent.py` 的 function schema,要求模型输出 `pending_flow_confirmation` 和 `candidate_flows`。[CONCEPT: 后端] 证据:`test_steward_intent_agent.py` 通过。
|
||||
- [x] 更新系统提示词:不能把无明确动作的出差描述直接判定为申请;应结合语义、上下文和候选置信度决定是否确认。[CONCEPT: 背景与问题] 证据:`steward_intent_agent.py` system prompt 已要求低确定性返回 pending flow。
|
||||
- [x] 增加 fake LLM 测试:输入“2月20-23日去上海出差辅助国网仿生产环境部署”时,模型路径返回 `confirm_flow`。[CONCEPT: 指标与验收] 证据:`test_steward_planner_returns_pending_flow_confirmation_from_llm`。
|
||||
- [ ] 增加模型非法输出测试:非法字段、非法 flow、空候选项必须被服务端过滤或降级。[CONCEPT: 安全边界]
|
||||
|
||||
## 阶段四:状态合并与上下文记忆
|
||||
|
||||
- [x] 扩展 `steward_flow_state.py`,支持 `steward.flow_state.v1` 到 `steward.flow_state.v2` 的兼容升级。[CONCEPT: 风险与开放问题] 证据:`_normalize_state` 默认 v2 并保留 v1 核心结构。
|
||||
- [x] 支持将 `pending_flow_confirmation` 写入 state,并记录 source message、候选 flow 和确认原因。[CONCEPT: 业务 JSON 模板] 证据:`test_state_merge_plan_keeps_pending_flow_confirmation`。
|
||||
- [x] 支持用户选择候选 flow 后切换 `active_flow`,并把已识别字段合并到对应流程。[CONCEPT: 功能能力] 证据:`StewardFlowStateService.confirm_flow` 与 runtime 测试覆盖。
|
||||
- [x] 增加状态测试:多轮合并后 `flows.*.fields` 不出现非本体字段。[CONCEPT: 指标与验收] 证据:既有 `test_state_merge_filters_non_ontology_fields` 继续通过。
|
||||
- [ ] 增加状态测试:同一 `conversation_id` 下选择申请或报销不会丢失前一轮字段和证据。[CONCEPT: 数据与持久化]
|
||||
|
||||
## 阶段五:运行时决策
|
||||
|
||||
- [x] 扩展 `steward_runtime_decision_agent.py`,识别用户点击或输入“补办出差申请”“发起费用报销”。[CONCEPT: 后端] 证据:`_build_selected_flow_decision` 前置处理候选 flow。
|
||||
- [x] Runtime decision 输入为空时,从 `context_json.conversation_state.steward_state` 恢复状态。[CONCEPT: 输入] 证据:既有 `test_steward_runtime_decision_fallback_reads_persisted_steward_state` 继续通过。
|
||||
- [x] 用户选择申请后返回 `continue_selected_flow`,并设置 `active_flow=travel_application`。[CONCEPT: 指标与验收] 证据:`test_steward_runtime_decision_fallback_confirms_selected_flow`。
|
||||
- [x] 用户选择报销后返回 `continue_selected_flow`,并设置 `active_flow=travel_reimbursement`。[CONCEPT: 指标与验收] 证据:`test_steward_runtime_decision_fallback_confirms_reimbursement_flow`。
|
||||
- [x] 增加 runtime 测试,覆盖点击按钮和用户直接输入两种方式。[CONCEPT: 测试方案] 证据:runtime 单测覆盖申请/报销选择,接口 smoke 覆盖用户选择。
|
||||
|
||||
## 阶段六:前端候选流程展示
|
||||
|
||||
- [x] 在 `stewardPlanModel.js` 中把 `pending_flow_confirmation` 转成两个可点击建议动作。[CONCEPT: 前端] 证据:`steward-plan-model-pending-flow.test.mjs`。
|
||||
- [x] 在 `useStewardPlanFlow.js` 中渲染 Markdown 回复,确保标题、列表和重点加粗间距正常。[CONCEPT: 用户与场景] 证据:`buildStewardPlanMessageText` 对 `confirm_flow` 生成 Markdown 标题、列表和加粗内容。
|
||||
- [x] 在 `TravelReimbursementCreateView.js` 中处理候选流程点击:优先调用 runtime decision,不重新规划原始输入。[CONCEPT: 前端] 证据:`steward_confirm_flow` 分支调用 `handleStewardRuntimeDecision`。
|
||||
- [x] 在 `useTravelReimbursementSessionState.js` 中确认 `conversation_id` 和 `steward_state` 后续请求持续携带。[CONCEPT: 输入] 证据:现有 session state 与 `buildStewardPlanRequest` 已持续携带,无需新增改动。
|
||||
- [x] 增加或补充前端定向测试,覆盖候选按钮展示、点击后状态更新和不重复规划。[CONCEPT: 前端测试] 证据:新增 `steward-plan-model-pending-flow.test.mjs` 覆盖候选按钮,接口 smoke 覆盖选择后状态更新。
|
||||
|
||||
## 阶段七:接口与回归验证
|
||||
|
||||
- [x] 在容器中运行后端定向测试,单次命令超时控制在 60 秒内。[CONCEPT: 容器验证] 证据:`24 passed in 25.14s`。
|
||||
|
||||
```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_steward_intent_agent.py server/tests/test_steward_model_plan_builder.py server/tests/test_steward_flow_state.py server/tests/test_steward_runtime_decision_agent.py
|
||||
```
|
||||
|
||||
- [x] 在容器中运行已有小财管家回归测试,确认旧的申请/报销拆分不退化。[CONCEPT: 测试方案] 证据:`test_steward_planner.py`、`test_steward_slot_decision_agent.py` 包含在后端定向测试中并通过。
|
||||
|
||||
```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_steward_planner.py server/tests/test_steward_slot_decision_agent.py
|
||||
```
|
||||
|
||||
- [x] 在容器中运行前端构建。[CONCEPT: 容器验证] 证据:`docker exec -w /app/web x-financial-main npm run build` 成功。
|
||||
|
||||
```bash
|
||||
docker exec -w /app/web x-financial-main npm run build
|
||||
```
|
||||
|
||||
- [x] 手工验证小财管家输入“2月20-23日去上海出差辅助国网仿生产环境部署”,页面展示两个候选流程,未确认前不创建申请单或报销草稿。[CONCEPT: 指标与验收] 证据:接口 smoke 返回 `next_action=confirm_flow`、候选 `travel_application/travel_reimbursement`、`state_pending=pending`。
|
||||
|
||||
## 阶段八:文档同步
|
||||
|
||||
- [x] 实现过程中如调整 JSON 字段或接口契约,先更新 `CONCEPT.md`,再修改代码。[CONCEPT: 方案设计] 证据:已先新增 `CONCEPT.md` 与 `TODO.md`。
|
||||
- [x] 每完成一个阶段,在本 TODO 中勾选并补充证据,例如测试命令、文件名或接口返回要点。[CONCEPT: 测试方案] 证据:本文件已补充阶段证据。
|
||||
- [ ] 最终汇报工作区状态,不自动 commit/push,除非用户明确要求。[CONCEPT: 风险与开放问题]
|
||||
88
document/development/申请单关联归档状态/CONCEPT.md
Normal file
88
document/development/申请单关联归档状态/CONCEPT.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 申请单关联归档状态概念文档
|
||||
|
||||
## 功能一句话
|
||||
|
||||
申请单审批完成后先进入关联单据状态,只有关联的报销单完成付款归档后,申请单才同步归档。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前费用申请单审批完成后,部分列表和进度展示会把申请单视为归档;但业务上申请单只是完成了事前审批,还需要等待后续报销单关联、报销审批、付款完成后,申请单生命周期才真正闭环。
|
||||
|
||||
这会导致用户看到报销单仍在处理、申请单却已归档,或者报销单已完成但申请单还停留在进行中的割裂状态。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 申请单审批完成不直接进入归档中心。
|
||||
2. 申请单进度在归档前增加“关联单据状态”节点。
|
||||
3. 已有关联报销单但未付款完成时,该节点显示“关联中”。
|
||||
4. 没有关联报销单时,该节点显示“未关联”。
|
||||
5. 关联报销单付款完成后,申请单同步进入“申请归档”。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不新增数据库表。
|
||||
2. 不改变报销单本身的审批、付款权限。
|
||||
3. 不改变申请单审批通过自动生成报销草稿的现有能力。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
涉及角色:
|
||||
|
||||
- 申请人:查看申请单是否已经关联后续报销单。
|
||||
- 审批人:审批申请单后不再误以为该申请已经归档。
|
||||
- 财务人员:付款完成报销单时,同步闭环关联申请单。
|
||||
|
||||
关键场景:
|
||||
|
||||
1. 申请单审批通过,但未生成或未关联报销单:显示“关联单据状态 / 未关联”。
|
||||
2. 申请单审批通过,并已生成报销草稿或报销单仍在流程中:显示“关联单据状态 / 关联中”。
|
||||
3. 关联报销单已付款:报销单进入已付款,申请单进入“申请归档”。
|
||||
|
||||
## 方案设计
|
||||
|
||||
后端:
|
||||
|
||||
- 申请单 `approved + 审批完成` 不再被归档查询命中。
|
||||
- 申请单只有 `approved + 申请归档` 才属于归档。
|
||||
- 报销单付款完成时,从 `application_handoff` 或 `application_link` 风险事件中读取关联申请单。
|
||||
- 找到关联申请单后,追加同步归档事件,并将申请单阶段置为“申请归档”。
|
||||
|
||||
前端:
|
||||
|
||||
- 申请单进度增加“关联单据状态”和“已归档”节点。
|
||||
- 审批完成但未归档的申请单,当前节点停留在“关联单据状态”。
|
||||
- 根据申请单自身的 `generated_draft_claim_no` 或报销单侧关联事件显示“关联中 / 未关联”。
|
||||
- 只有“申请归档”阶段才展示归档完成。
|
||||
|
||||
## 算法与公式
|
||||
|
||||
当前功能不涉及显式数学公式。
|
||||
|
||||
关联状态判断:
|
||||
|
||||
```text
|
||||
has_linked_reimbursement = exists(application.generated_draft_claim_no)
|
||||
or exists(reimbursement.risk_flags.application_claim_id/no == application.id/no)
|
||||
|
||||
application_archived = application.status in {approved, completed}
|
||||
and application.approval_stage == "申请归档"
|
||||
```
|
||||
|
||||
## 测试方案
|
||||
|
||||
1. 后端状态测试:审批完成申请单不归档,申请归档才归档。
|
||||
2. 后端付款测试:关联报销单付款后,申请单同步进入“申请归档”。
|
||||
3. 前端进度测试:审批完成申请单显示“关联单据状态”和“已归档”。
|
||||
4. 前端归档判断测试:`审批完成` 申请单不算归档,`申请归档` 才算归档。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 单据中心普通视图仍能看到审批完成但未归档的申请单。
|
||||
2. 归档中心不会提前出现仅审批完成的申请单。
|
||||
3. 申请单进度在审批完成后能看到“关联单据状态”。
|
||||
4. 报销单付款完成后,关联申请单同步显示为归档。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 旧数据中可能存在已经把申请单审批完成当作归档的数据,本次按新业务规则修正展示与查询口径。
|
||||
- 如果历史申请单缺少关联报销事件,只能展示“未关联”,不做自动猜测。
|
||||
8
document/development/申请单关联归档状态/TODO.md
Normal file
8
document/development/申请单关联归档状态/TODO.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 申请单关联归档状态开发 TODO
|
||||
|
||||
- [x] 梳理申请单审批完成、报销单关联、报销单付款、归档查询的现有链路。[CONCEPT: 背景与问题] 证据:已确认 `expense_claim_status_registry.py`、`expense_claim_access_policy.py`、`expense_claim_approval_flow.py`、`useRequests.js` 的当前行为。
|
||||
- [x] 调整后端归档查询口径:申请单 `审批完成` 不再视为归档,仅 `申请归档` 才归档。[CONCEPT: 方案设计] 证据:`ExpenseClaimAccessPolicy.build_archived_claim_condition()` 仅将 `APPLICATION_ARCHIVE_STAGE` 视为申请归档。
|
||||
- [x] 调整报销单付款完成逻辑:根据关联事件同步推进申请单到 `申请归档`。[CONCEPT: 方案设计] 证据:`mark_claim_paid()` 调用 `_archive_linked_applications_after_reimbursement_paid()`,新增付款同步测试通过。
|
||||
- [x] 调整前端申请单进度:增加 `关联单据状态` 与 `已归档` 节点,并显示 `关联中/未关联`。[CONCEPT: 方案设计] 证据:`useRequests.js` 新增申请单进度节点和关联状态计算。
|
||||
- [x] 补充前后端回归测试,覆盖未关联、关联中、已归档三类申请单状态。[CONCEPT: 测试方案] 证据:`requestProgressSteps.test.mjs`、`document-center-archived-scope.test.mjs`、`expense-claim-archive.test.mjs`、`test_expense_claim_service.py` 已覆盖。
|
||||
- [x] 在容器或前端定向测试中完成验证,并记录命令结果。[CONCEPT: 验收标准] 证据:前端 Node 定向测试、容器内 py_compile、状态/路由/归档/付款同步 pytest、`npm.cmd --prefix web run build` 均通过。
|
||||
131
document/development/费用申请审批财务规则/CONCEPT.md
Normal file
131
document/development/费用申请审批财务规则/CONCEPT.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 费用申请审批财务规则概念文档
|
||||
|
||||
## 功能一句话
|
||||
|
||||
在财务规则中心新增《公司费用申请审批规则》,统一维护业务招待、办公用品和通用大额费用的事前申请与审批阈值,并让报销风险规则引用该规则执行。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
现有系统已经有“业务招待无申请”“办公采购无申请”“大额费用无申请”等风险规则,但制度依据主要以风险规则 JSON 的口径字段存在,财务规则中心缺少一张可被制度管理员查看、编辑和追溯的规则表。
|
||||
|
||||
用户明确要求:
|
||||
|
||||
- 业务招待费超过 500 元需要申请。
|
||||
- 大额办公用品需要申请。
|
||||
- 金额超过 2000 元的费用都需要走审批。
|
||||
- 这些要求最好形成财务规则,而不是散落在代码或前端提示中。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:
|
||||
|
||||
- 新增一张财务规则资产《公司费用申请审批规则》。
|
||||
- 规则资产以 Excel 形式进入 `finance-rules` 规则库,并在规则中心按“财务规则”展示。
|
||||
- 风险规则引用统一的 `finance_rule_code`,不再使用零散口径 code。
|
||||
- 报销阶段按结构化金额规则判断,而不是只靠关键词命中。
|
||||
- 关联有效申请单后不触发“缺少申请”风险。
|
||||
|
||||
非目标:
|
||||
|
||||
- 本轮不新增数据库字段。
|
||||
- 本轮不新增非本体业务字段。
|
||||
- 本轮不改造完整审批流节点,只补充申请前置与风险执行依据。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 报销人:上传或录入业务招待、办公用品、大额费用报销时,系统自动识别是否缺少事前申请。
|
||||
- 直属领导和财务审核人:审核单据时能看到风险来自财务规则。
|
||||
- 财务制度管理员:能在规则中心看到并维护《公司费用申请审批规则》。
|
||||
|
||||
## 功能能力
|
||||
|
||||
### 财务规则表
|
||||
|
||||
规则表包含以下行:
|
||||
|
||||
- 业务招待费:单次费用金额大于 500 元时,必须先提交费用申请单。
|
||||
- 办公用品费:单次或批量采购金额大于 2000 元时,必须先提交办公采购或费用申请单。
|
||||
- 通用大额费用:任意费用金额大于 2000 元时,必须进入审批流程。
|
||||
|
||||
### 风险规则执行
|
||||
|
||||
- `meal` 与 `entertainment` 都视为业务招待费。
|
||||
- `office` 视为办公用品费。
|
||||
- `all` 视为通用大额费用。
|
||||
- 报销阶段没有关联有效申请单时,超过阈值命中高风险。
|
||||
- 已有关联申请单时,不命中缺少申请风险。
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 后端
|
||||
|
||||
- 在 `agent_asset_spreadsheet.py` 中新增费用申请审批规则 code 与文件名常量。
|
||||
- 在财务规则同步中新增该资产的 metadata、Excel 工作簿生成和版本快照。
|
||||
- 在初始化和补齐逻辑中创建该财务规则资产,确保老库和新库都能看到。
|
||||
- 将三条风险规则改为 `composite_rule_v1`,使用金额阈值和申请单存在性执行。
|
||||
- 在 `risk_rule_template_executor.py` 中补齐 `application.*` 字段解析,桥接现有 `application_link` / `application_handoff` / `application_detail` 风险上下文。
|
||||
|
||||
### 前端
|
||||
|
||||
本轮不新增前端页面。规则中心已有财务规则和 JSON 风险规则展示能力,后端资产同步后前端可直接展示。
|
||||
|
||||
### 数据与本体
|
||||
|
||||
本轮只使用现有本体字段:
|
||||
|
||||
- `expense_type`
|
||||
- `amount`
|
||||
- `reason`
|
||||
- `application_claim_id`
|
||||
- `application_claim_no`
|
||||
- `application_detail`
|
||||
|
||||
不新增非本体字段。
|
||||
|
||||
## 算法与公式
|
||||
|
||||
业务招待费规则:
|
||||
|
||||
$$
|
||||
hit = expenseType \in \{meal, entertainment\} \land amount > 500 \land \neg hasApplication
|
||||
$$
|
||||
|
||||
办公用品规则:
|
||||
|
||||
$$
|
||||
hit = expenseType = office \land amount > 2000 \land \neg hasApplication
|
||||
$$
|
||||
|
||||
通用大额规则:
|
||||
|
||||
$$
|
||||
hit = amount > 2000 \land \neg hasApplication
|
||||
$$
|
||||
|
||||
其中:
|
||||
|
||||
- `amount` 来自 `claim.amount`。
|
||||
- `hasApplication` 来自 `application.id`、`application.claim_no` 或等价申请单上下文。
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 单元测试:验证 `application.*` 字段能从已有申请关联上下文解析。
|
||||
- 规则执行测试:超过 500 元业务招待费且无申请命中风险。
|
||||
- 规则执行测试:超过 2000 元办公用品费且无申请命中风险。
|
||||
- 规则执行测试:超过 2000 元通用费用且无申请命中风险。
|
||||
- 规则执行测试:已关联申请单的超额费用不命中缺少申请风险。
|
||||
- 资产测试:规则中心种子数据包含《公司费用申请审批规则》,且 `config_json.tag` 为“财务规则”。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 财务规则中心能看到新增规则资产。
|
||||
- 新增资产 `finance_rule_code` 统一为 `expense.preapproval.policy`。
|
||||
- 三条风险规则均引用该财务规则 code。
|
||||
- 容器内后端定向测试通过。
|
||||
- 不新增非本体业务字段。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- “大额办公用品”的金额阈值按用户同句“大额/超过 2000 都需要审批”落为 2000 元。
|
||||
- 当前申请单上下文主要存在 `risk_flags_json` 的申请关联 flag 中,本轮先补执行器解析,不新增外键字段。
|
||||
- 后续如果要支持不同部门或不同职级阈值,可以在同一张财务规则表中扩展分档行。
|
||||
23
document/development/费用申请审批财务规则/TODO.md
Normal file
23
document/development/费用申请审批财务规则/TODO.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 费用申请审批财务规则 TODO
|
||||
|
||||
## 调研与契约
|
||||
|
||||
- [x] 盘点现有财务规则资产、风险规则 JSON 与规则同步链路。[CONCEPT: 背景与问题] 证据:确认现有 `finance-rules` 仅差旅和通信两张核心规则表,前置申请规则当前在 `risk-rules` 中。
|
||||
- [x] 明确本轮不新增非本体业务字段。[CONCEPT: 数据与本体] 证据:规则只使用 `expense_type`、`amount`、`reason` 和申请单上下文。
|
||||
|
||||
## 后端实现
|
||||
|
||||
- [x] 新增《公司费用申请审批规则》财务规则资产常量与 Excel 工作簿内容。[CONCEPT: 财务规则表] 证据:`COMPANY_PREAPPROVAL_RULE_CODE`、`COMPANY_PREAPPROVAL_RULE_FILENAME` 和 `_ensure_company_preapproval_rule_spreadsheet_seed()` 已实现。
|
||||
- [x] 初始化种子和老库补齐逻辑都能创建该财务规则资产。[CONCEPT: 方案设计] 证据:`agent_foundation_asset_seed.py` 和 `agent_foundation_asset_topup.py` 均接入该资产。
|
||||
- [x] 将大额费用、业务招待、办公用品三条前置申请风险规则改为结构化金额判断。[CONCEPT: 风险规则执行] 证据:三条 `risk.application.*without_preapproval.json` 已改为 `composite_rule_v1`。
|
||||
- [x] 补齐 `application.*` 字段解析,支持从现有关联申请上下文判断是否已有申请。[CONCEPT: 后端] 证据:`risk_rule_template_executor.py` 新增 `_resolve_application_values()`。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
- [x] 新增执行器测试:申请单上下文存在时 `application.id` 可解析。[CONCEPT: 测试方案] 证据:`test_application_context_values_are_available_to_composite_rules` 通过。
|
||||
- [x] 新增风险规则执行测试:业务招待费超过 500 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 meal。
|
||||
- [x] 新增风险规则执行测试:办公用品超过 2000 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 office。
|
||||
- [x] 新增风险规则执行测试:通用费用超过 2000 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 software。
|
||||
- [x] 新增资产同步测试:财务规则中心包含新增规则资产。[CONCEPT: 指标与验收] 证据:`test_finance_rules_use_risk_rule_scenario_categories` 断言新增财务规则资产和规则文档。
|
||||
- [x] Docker `x-financial-main` 容器内定向测试通过。[CONCEPT: 指标与验收] 证据:新增与相邻回归共 15 个后端测试通过。
|
||||
- [x] 重启后端并验证运行时健康状态。[CONCEPT: 指标与验收] 证据:`x-financial-main` 已重启并进入 healthy;真实库可查到 `rule.expense.company_preapproval_requirement`。
|
||||
86
document/development/附件上传风险前置复核/CONCEPT.md
Normal file
86
document/development/附件上传风险前置复核/CONCEPT.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 附件上传风险前置复核
|
||||
|
||||
## 功能一句话
|
||||
|
||||
报销附件上传并完成 OCR 识别后立即执行完整风险复核,提交审批时只做轻量最终校验、预算占用和流程流转。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前报销单提交阶段会同步执行较重的风险检查,包括附件风险汇总、差旅规则、场景规则、规则中心风险、历史行为统计和风险观测写入。用户在点击提交后会等待较长时间,容易误认为页面卡住。
|
||||
|
||||
风险的主要依据来自已上传票据、OCR 识别结果、费用明细、关联申请单和员工历史行为。这些数据在附件上传完成后已经基本具备,因此完整风险复核应前移到上传完成阶段。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:
|
||||
|
||||
- 附件上传成功后自动刷新费用明细、附件风险、差旅/场景/规则中心风险和 AI 预审标识。
|
||||
- 风险复核结果写回 `claim.risk_flags_json`,并持久化规则中心风险观测。
|
||||
- 提交阶段不再重复跑完整 `_run_ai_submission_review()`。
|
||||
- 提交阶段只保留草稿完整性校验、预算占用、未处理阻断风险判断、状态流转、审计日志和助手会话清理。
|
||||
|
||||
非目标:
|
||||
|
||||
- 不新增业务字段。
|
||||
- 不改变现有风险规则语义。
|
||||
- 不把提交改成真正的后端异步任务队列。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 报销申请人:上传票据后立即看到风险建议和需补充说明,不必等到提交时才发现问题。
|
||||
- 直属领导和财务人员:收到单据时可看到提交前已生成的风险提示和用户处理结果。
|
||||
- 系统管理员:风险观测仍可进入后台统计。
|
||||
|
||||
## 功能能力
|
||||
|
||||
上传完成后:
|
||||
|
||||
- 根据 OCR 结果回填费用明细类型、日期、金额、事由等已有字段。
|
||||
- 刷新附件级 `attachment_analysis` 风险。
|
||||
- 执行报销级风险复核,并生成 `ai_pre_review` 状态。
|
||||
- 对规则中心命中的风险写入 `risk_observations`。
|
||||
|
||||
提交审批时:
|
||||
|
||||
- 如果存在高风险且用户未处理,继续阻止提交或要求说明/按职级测算。
|
||||
- 如果风险已处理,只做预算和流程流转。
|
||||
- 不再重复生成一套提交阶段风险。
|
||||
|
||||
## 方案设计
|
||||
|
||||
后端:
|
||||
|
||||
- 在 `ExpenseClaimService.upload_claim_item_attachment()` 中,OCR、附件分析和 `_sync_claim_from_items()` 完成后,调用上传后风险复核 helper。
|
||||
- 新增 helper 复用现有 `_run_ai_submission_review()` 与 `_replace_ai_pre_review_flag()`,但保持单据状态为草稿。
|
||||
- 提交阶段读取既有风险结果,只做最终阻断风险判断,不重复调用 `_run_ai_submission_review()`。
|
||||
|
||||
前端:
|
||||
|
||||
- 继续使用当前附件识别中的状态条。
|
||||
- 上传完成后通过接口返回的 `claim_risk_flags` 更新 AI 建议区和风险标识。
|
||||
- 提交时只显示轻量后台提交流程提示。
|
||||
|
||||
## 算法与公式
|
||||
|
||||
当前功能不涉及新的显式数学公式。风险评分和风险等级沿用现有规则中心、附件分析、差旅政策和风险观测逻辑。
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 后端单元测试:附件上传后写入 `ai_pre_review` 和 `submission_review` 风险。
|
||||
- 后端单元测试:提交阶段不再调用完整 `_run_ai_submission_review()`。
|
||||
- 后端单元测试:上传后规则中心风险可写入 `risk_observations`。
|
||||
- 前端静态回归:提交确认仍为后台提交,不恢复阻塞弹窗。
|
||||
- 构建验证:`npm.cmd --prefix web run build`。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 上传附件后,接口响应的 `claim_risk_flags` 包含最新复核结果。
|
||||
- 提交接口耗时不再包含完整风险复核耗时。
|
||||
- 提交后审批人仍能看到已前置生成的风险提示。
|
||||
- 后端和前端相关回归测试通过。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 如果用户上传后又修改费用明细,现有 `update_claim_item()` 需要继续刷新附件风险和报销级风险。
|
||||
- 如果用户没有上传附件直接提交,提交阶段仍需要保留兜底风险复核或阻断提示。
|
||||
- 未来可进一步把上传后复核做成真正后台任务,但本次先保持同步接口返回最新风险结果。
|
||||
28
document/development/附件上传风险前置复核/TODO.md
Normal file
28
document/development/附件上传风险前置复核/TODO.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 附件上传风险前置复核 TODO
|
||||
|
||||
## 调研与契约
|
||||
|
||||
- [x] 盘点附件上传、预审、提交链路,确认完整风险复核当前在提交阶段重复执行。[CONCEPT: 背景与问题]
|
||||
- [x] 明确上传后复核 helper 的输入输出契约,不新增业务字段。[CONCEPT: 方案设计] 证据:新增 `_refresh_claim_pre_review_flags()` 复用现有风险字段。
|
||||
|
||||
## 后端实现
|
||||
|
||||
- [x] 在附件上传完成后触发报销级风险复核,并保持单据状态为草稿。[CONCEPT: 功能能力] 证据:`upload_claim_item_attachment()` 调用 `_refresh_claim_pre_review_flags()`。
|
||||
- [x] 上传后风险复核写回 `ai_pre_review` 和 `submission_review` 风险结果。[CONCEPT: 功能能力] 证据:`test_upload_attachment_refreshes_claim_pre_review` 通过。
|
||||
- [x] 规则中心风险在上传后写入 `risk_observations`,避免提交阶段集中写入。[CONCEPT: 方案设计] 证据:上传后复核复用 `_run_ai_submission_review()`,平台风险仍调用 `RiskObservationService.upsert_platform_risk_flags()`。
|
||||
- [x] 提交阶段改为读取既有风险结果,只做最终校验、预算占用和流转。[CONCEPT: 目标与非目标] 证据:`submit_claim()` 仅在缺少 `ai_pre_review` 时兜底复核。
|
||||
- [x] 保留“无附件直接提交”的兜底检查,避免绕过风险复核。[CONCEPT: 风险与开放问题] 证据:`test_submit_claim_runs_ai_review_and_routes_to_direct_manager` 通过。
|
||||
|
||||
## 前端实现
|
||||
|
||||
- [x] 确认上传完成后 UI 使用接口返回的 `claim_risk_flags` 刷新 AI 建议与行风险标识。[CONCEPT: 前端] 证据:`travel-request-detail-risk-advice.test.mjs` 通过。
|
||||
- [x] 确认提交阶段不恢复阻塞弹窗,只显示轻量后台提交提示。[CONCEPT: 前端] 证据:`travel-request-detail-submit-confirm.test.mjs` 通过。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
- [x] 后端测试:附件上传后自动生成预审风险结果。[CONCEPT: 测试方案] 证据:`test_upload_attachment_refreshes_claim_pre_review` 通过。
|
||||
- [x] 后端测试:提交阶段不重复调用完整风险复核。[CONCEPT: 测试方案] 证据:`test_submit_claim_reuses_upload_pre_review_without_rerunning_review` 通过。
|
||||
- [x] 后端测试:风险观测仍被持久化。[CONCEPT: 测试方案] 证据:`test_risk_observation_storage_ready_is_cached_per_bind` 通过。
|
||||
- [x] 前端回归测试通过。[CONCEPT: 测试方案] 证据:54 个详情页风险/提交测试通过。
|
||||
- [x] `npm.cmd --prefix web run build` 通过。[CONCEPT: 测试方案] 证据:前端生产构建通过,仅保留既有 Rollup 注释与 chunk size 警告。
|
||||
- [x] Docker `x-financial-main` 容器内后端定向测试通过。[CONCEPT: 测试方案] 证据:核心上传前置复核、提交复用预审、申请/报销风险回归测试通过。
|
||||
1177
mobile/design/android-expense-assistant-screens.html
Normal file
1177
mobile/design/android-expense-assistant-screens.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mobile/design/android-expense-assistant-screens.png
Normal file
BIN
mobile/design/android-expense-assistant-screens.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
1338
mobile/design/android-expense-assistant-v2.html
Normal file
1338
mobile/design/android-expense-assistant-v2.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mobile/design/android-expense-assistant-v2.png
Normal file
BIN
mobile/design/android-expense-assistant-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 734 KiB |
724
mobile/design/android-expense-splash-assets.html
Normal file
724
mobile/design/android-expense-splash-assets.html
Normal file
@@ -0,0 +1,724 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>X-Financial Expense 启动页素材系统</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #edf2f0;
|
||||
--ink: #0f172a;
|
||||
--muted: #64748b;
|
||||
--surface: #ffffff;
|
||||
--line: #d8e1e8;
|
||||
--primary: #006b5e;
|
||||
--primary-2: #0f766e;
|
||||
--primary-dark: #061416;
|
||||
--mint: #e1f6f1;
|
||||
--gold: #f5a524;
|
||||
--gold-soft: #fff2cf;
|
||||
--blue: #2563eb;
|
||||
--blue-soft: #e7efff;
|
||||
--paper: #f8fafc;
|
||||
--shadow: 0 24px 70px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(15, 23, 42, 0.045) 1px, transparent 1px),
|
||||
linear-gradient(rgba(15, 23, 42, 0.045) 1px, transparent 1px),
|
||||
var(--bg);
|
||||
background-size: 28px 28px;
|
||||
font-family: "IBM Plex Sans", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 34px 30px 70px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 22px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.86);
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
min-height: 260px;
|
||||
padding: 30px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
width: fit-content;
|
||||
min-height: 28px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 107, 94, 0.18);
|
||||
color: var(--primary);
|
||||
background: var(--mint);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 760px;
|
||||
margin: 14px 0 10px;
|
||||
font-size: 36px;
|
||||
line-height: 1.12;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hero p,
|
||||
.section p {
|
||||
max-width: 860px;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.62;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.checkline {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.checkline strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.checkline span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--primary);
|
||||
background: var(--mint);
|
||||
}
|
||||
|
||||
.icon.gold { color: #8a5a00; background: var(--gold-soft); }
|
||||
.icon.blue { color: var(--blue); background: var(--blue-soft); }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 420px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.phone {
|
||||
width: 316px;
|
||||
height: 684px;
|
||||
margin: 0 auto;
|
||||
padding: 9px;
|
||||
border-radius: 34px;
|
||||
background: #101827;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.screen {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 27px;
|
||||
color: #fff;
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.sys { display: flex; gap: 5px; align-items: center; }
|
||||
.sig, .wifi, .bat { display: inline-block; border: 1.7px solid currentColor; }
|
||||
.sig { width: 12px; height: 10px; border-top: 0; border-left: 0; transform: skew(-8deg); }
|
||||
.wifi { width: 13px; height: 8px; border-bottom: 0; border-left-color: transparent; border-right-color: transparent; border-radius: 14px 14px 0 0; }
|
||||
.bat { width: 18px; height: 10px; border-radius: 2px; background: linear-gradient(90deg, currentColor 68%, transparent 68%); }
|
||||
|
||||
.splash {
|
||||
position: relative;
|
||||
height: calc(100% - 30px);
|
||||
padding: 48px 16px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.splash::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -34px -18px auto;
|
||||
height: 320px;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(circle at 28% 18%, rgba(245, 165, 36, 0.18), transparent 26%),
|
||||
linear-gradient(135deg, rgba(255,255,255,0.12), transparent 34%),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px),
|
||||
linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px);
|
||||
background-size: auto, auto, 24px 24px, 24px 24px;
|
||||
}
|
||||
|
||||
.topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: rgba(255,255,255,0.68);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.lockup {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 42px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(145deg, #0b3b36, #00806f);
|
||||
box-shadow: 0 24px 58px rgba(0,0,0,0.36), 0 0 0 1px rgba(255,255,255,0.16) inset;
|
||||
}
|
||||
|
||||
.brand-mark::before,
|
||||
.brand-mark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
transform: rotate(28deg);
|
||||
}
|
||||
|
||||
.brand-mark::before { left: 20px; top: 15px; }
|
||||
.brand-mark::after { right: 20px; bottom: 15px; opacity: 0.72; }
|
||||
|
||||
.splash-name {
|
||||
font-size: 25px;
|
||||
line-height: 1.05;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.splash-tag {
|
||||
max-width: 230px;
|
||||
margin-top: 8px;
|
||||
color: rgba(255,255,255,0.68);
|
||||
font-size: 12px;
|
||||
line-height: 1.48;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.scene {
|
||||
position: relative;
|
||||
height: 252px;
|
||||
margin-top: 34px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.13);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px),
|
||||
linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px),
|
||||
linear-gradient(145deg, #062425, #0a5f56 58%, #103a36);
|
||||
background-size: 22px 22px, 22px 22px, auto;
|
||||
box-shadow: 0 22px 50px rgba(0,0,0,0.34);
|
||||
}
|
||||
|
||||
.device {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
top: 28px;
|
||||
width: 128px;
|
||||
height: 188px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
background: rgba(3, 21, 24, 0.72);
|
||||
box-shadow: 0 18px 36px rgba(0,0,0,0.28);
|
||||
}
|
||||
|
||||
.device span {
|
||||
display: block;
|
||||
height: 8px;
|
||||
margin: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.device span:first-child {
|
||||
width: 72%;
|
||||
margin-top: 26px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
}
|
||||
|
||||
.amount {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 18px;
|
||||
padding: 11px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
font-size: 19px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.amount small {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: rgba(255,255,255,0.52);
|
||||
font-size: 9px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.asset-card {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 42px;
|
||||
width: 156px;
|
||||
padding: 13px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.94);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 18px 42px rgba(0,0,0,0.28);
|
||||
}
|
||||
|
||||
.asset-card.secondary {
|
||||
top: 132px;
|
||||
right: 34px;
|
||||
width: 174px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
color: #8a5a00;
|
||||
background: var(--gold-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.asset-card h3 {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.asset-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.float-pill {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
bottom: 18px;
|
||||
min-height: 32px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #d7fff7;
|
||||
background: rgba(0, 91, 79, 0.75);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
font-size: 11px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.splash-footer {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 118px;
|
||||
height: 4px;
|
||||
margin: 0 auto;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 66%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #dffcf4, var(--gold));
|
||||
}
|
||||
|
||||
.small-copy {
|
||||
color: rgba(255,255,255,0.68);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.asset {
|
||||
min-height: 138px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset.dark {
|
||||
color: #fff;
|
||||
background: var(--primary-dark);
|
||||
border-color: rgba(255,255,255,0.16);
|
||||
}
|
||||
|
||||
.asset-title {
|
||||
font-size: 13px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.asset.dark .asset-meta { color: rgba(255,255,255,0.62); }
|
||||
|
||||
.app-icons {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
position: relative;
|
||||
border-radius: 22%;
|
||||
background:
|
||||
linear-gradient(145deg, #0b3b36, #00806f);
|
||||
box-shadow: 0 16px 38px rgba(0, 91, 79, 0.26);
|
||||
}
|
||||
|
||||
.app-icon::before,
|
||||
.app-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
transform: rotate(28deg);
|
||||
}
|
||||
|
||||
.app-icon::before { left: 27%; top: 20%; width: 22%; height: 56%; }
|
||||
.app-icon::after { right: 27%; bottom: 20%; width: 22%; height: 56%; opacity: 0.72; }
|
||||
.app-icon.lg { width: 96px; height: 96px; }
|
||||
.app-icon.md { width: 72px; height: 72px; }
|
||||
.app-icon.sm { width: 48px; height: 48px; }
|
||||
|
||||
.spec-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.spec {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spec strong {
|
||||
min-width: 0;
|
||||
font-weight: 950;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.spec span {
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.motion {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.frame {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.frame strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.frame span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.mini-scene {
|
||||
height: 72px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px),
|
||||
linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px),
|
||||
linear-gradient(145deg, #062425, #0a5f56);
|
||||
background-size: 18px 18px, 18px 18px, auto;
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
min-height: 76px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(15,23,42,0.1);
|
||||
display: grid;
|
||||
align-content: end;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.swatch.light { color: var(--ink); }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.hero,
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.phone { margin-left: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.wrap { padding: 22px 12px 40px; }
|
||||
h1 { font-size: 28px; }
|
||||
.asset-grid,
|
||||
.motion,
|
||||
.color-row { grid-template-columns: 1fr; }
|
||||
.spec { grid-template-columns: 1fr; }
|
||||
.phone { width: min(316px, 100%); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<div class="panel hero-main">
|
||||
<span class="kicker">Splash asset system</span>
|
||||
<h1>X-Financial Expense 启动页素材系统</h1>
|
||||
<p>这页专门给 Android 开机页使用,不直接复用业务页面卡片。素材分成品牌锁定、深色背景、票据场景、启动动效和 Android 落地规格,后续开发 Compose 或原生启动页时按这里取值。</p>
|
||||
</div>
|
||||
<aside class="panel hero-side">
|
||||
<div class="checkline"><div class="icon">01</div><div><strong>启动页先看品牌</strong><span>Logo、产品名和安全工作台定位占主导,不放登录表单。</span></div></div>
|
||||
<div class="checkline"><div class="icon gold">02</div><div><strong>业务只做隐喻</strong><span>票据、审批、金额作为启动素材出现,不承载真实操作。</span></div></div>
|
||||
<div class="checkline"><div class="icon blue">03</div><div><strong>可直接落 Android</strong><span>提供颜色、图标尺寸、动效节奏和 SplashScreen API 参数。</span></div></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel section">
|
||||
<h2>启动页预览</h2>
|
||||
<p>用于 9:16 Android 手机首屏,登录态检查完成后跳转登录页或首页。</p>
|
||||
<div style="height:16px"></div>
|
||||
<div class="phone">
|
||||
<div class="screen">
|
||||
<div class="status"><span>9:41</span><span class="sys"><i class="sig"></i><i class="wifi"></i><i class="bat"></i></span></div>
|
||||
<div class="splash">
|
||||
<div class="topline"><span>SECURE EXPENSE</span><span>ANDROID</span></div>
|
||||
<div class="lockup">
|
||||
<div class="brand-mark"></div>
|
||||
<div>
|
||||
<div class="splash-name">X-Financial<br/>Expense</div>
|
||||
<div class="splash-tag">出差申请、票据采集、报销提交、移动审批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scene">
|
||||
<div class="device">
|
||||
<span></span><span></span><span></span>
|
||||
<div class="amount"><small>本月待处理</small>¥ 3,280</div>
|
||||
</div>
|
||||
<div class="asset-card">
|
||||
<span class="chip">AI 识别</span>
|
||||
<h3>票据已归类</h3>
|
||||
<p>5 张票据 · 2 项待补</p>
|
||||
</div>
|
||||
<div class="asset-card secondary">
|
||||
<h3>审批流已同步</h3>
|
||||
<p>财务复核中 · 待提醒</p>
|
||||
</div>
|
||||
<div class="float-pill">拍照上传</div>
|
||||
</div>
|
||||
<div class="splash-footer">
|
||||
<div class="progress"></div>
|
||||
<div class="small-copy">正在加载安全工作台</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<section class="panel section">
|
||||
<h2>品牌与 App 图标</h2>
|
||||
<p>App 图标保留双斜票据形态,避免直接使用文字。启动页 Logo 使用 74dp 等比缩放,图标安全区不低于 12dp。</p>
|
||||
<div class="app-icons">
|
||||
<div class="app-icon lg"></div>
|
||||
<div class="app-icon md"></div>
|
||||
<div class="app-icon sm"></div>
|
||||
</div>
|
||||
<div class="asset-grid">
|
||||
<div class="asset"><div class="brand-mark" style="width:58px;height:58px;border-radius:17px"></div><div><div class="asset-title">启动 Logo</div><div class="asset-meta">74dp / 58dp / 44dp 三档,深色背景使用。</div></div></div>
|
||||
<div class="asset dark"><div class="app-icon md"></div><div><div class="asset-title">Launcher Icon</div><div class="asset-meta">前景图形居中,保留 Android 自适应图标裁切空间。</div></div></div>
|
||||
<div class="asset"><div class="chip">AI 识别</div><div><div class="asset-title">业务状态 Chip</div><div class="asset-meta">仅作为视觉线索,不做主操作入口。</div></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel section">
|
||||
<h2>颜色与背景</h2>
|
||||
<p>启动页走深色金融安全感。绿色做品牌,金色只用于识别和进度强调,蓝色用于信息状态。</p>
|
||||
<div class="color-row">
|
||||
<div class="swatch" style="background:#061416">#061416<br/>启动底色</div>
|
||||
<div class="swatch" style="background:#006b5e">#006B5E<br/>品牌主色</div>
|
||||
<div class="swatch" style="background:#0f766e">#0F766E<br/>场景渐变</div>
|
||||
<div class="swatch" style="background:#f5a524;color:#251500">#F5A524<br/>识别强调</div>
|
||||
<div class="swatch light" style="background:#f8fafc">#F8FAFC<br/>票据纸面</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel section">
|
||||
<h2>素材拆分</h2>
|
||||
<p>开发落地时建议拆成 5 个可复用素材层,Compose 中可分别写成 Composable。</p>
|
||||
<div class="asset-grid">
|
||||
<div class="asset dark"><div class="mini-scene"></div><div><div class="asset-title">背景层</div><div class="asset-meta">深色底、细网格、顶部柔光,静态即可。</div></div></div>
|
||||
<div class="asset"><div class="asset-card" style="position:static;width:auto;box-shadow:none;border:1px solid var(--line)"><span class="chip">AI 识别</span><h3>票据已归类</h3><p>5 张票据 · 2 项待补</p></div><div><div class="asset-title">票据层</div><div class="asset-meta">表达拍照上传和 OCR,不显示真实敏感票据信息。</div></div></div>
|
||||
<div class="asset"><div class="float-pill" style="position:static;color:#006b5e;background:var(--mint);border:0;width:max-content">拍照上传</div><div><div class="asset-title">状态层</div><div class="asset-meta">启动时轻提示,900ms 后淡出。</div></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel section">
|
||||
<h2>启动动效节奏</h2>
|
||||
<p>控制在 900ms 左右,不阻塞用户进入登录或首页。系统检测慢时只延长进度条,不重复播放动画。</p>
|
||||
<div class="motion">
|
||||
<div class="frame"><div class="mini-scene"></div><strong>0ms</strong><span>深色底和网格先出现,避免白屏。</span></div>
|
||||
<div class="frame"><div class="mini-scene"></div><strong>160ms</strong><span>Logo scale 0.92 到 1,透明度进入。</span></div>
|
||||
<div class="frame"><div class="mini-scene"></div><strong>320ms</strong><span>产品名和定位文案淡入。</span></div>
|
||||
<div class="frame"><div class="mini-scene"></div><strong>520ms</strong><span>票据场景上移 8dp,进度条启动。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel section">
|
||||
<h2>Android 落地规格</h2>
|
||||
<p>先走 Android 原生启动,再进入 Compose 首页。冷启动用系统 SplashScreen,业务素材用于第一个 Activity 的过渡页。</p>
|
||||
<div class="spec-list">
|
||||
<div class="spec"><strong>windowSplashScreenBackground</strong><span>#061416。系统启动页避免白屏,和业务过渡页底色一致。</span></div>
|
||||
<div class="spec"><strong>windowSplashScreenAnimatedIcon</strong><span>只放 App 图标前景,不放产品文字,防止小屏裁切。</span></div>
|
||||
<div class="spec"><strong>过渡页时长</strong><span>登录态检查完成即跳转;正常 600-900ms,异常最长 1800ms 后进入登录页。</span></div>
|
||||
<div class="spec"><strong>Compose 拆分</strong><span>SplashBackground、BrandLockup、ExpenseScene、LoadingRail 四个组件。</span></div>
|
||||
<div class="spec"><strong>无障碍</strong><span>启动页只设置应用名称 contentDescription,业务装饰图不参与朗读。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
BIN
mobile/design/android-expense-splash-assets.png
Normal file
BIN
mobile/design/android-expense-splash-assets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 423 KiB |
BIN
mobile/design/yicai-splash-screen.png
Normal file
BIN
mobile/design/yicai-splash-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 462 KiB |
39
remove_bg.py
Normal file
39
remove_bg.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import sys
|
||||
from PIL import Image
|
||||
|
||||
def remove_white_bg(input_path, output_path, threshold=235):
|
||||
img = Image.open(input_path).convert("RGBA")
|
||||
data = img.getdata()
|
||||
new_data = []
|
||||
|
||||
for item in data:
|
||||
r, g, b, a = item
|
||||
avg = (r + g + b) / 3.0
|
||||
|
||||
# If it's very close to white, make it transparent
|
||||
if avg > threshold and min(r,g,b) > threshold - 10:
|
||||
# Feathering the alpha channel
|
||||
# 255 = fully transparent
|
||||
# threshold = fully opaque
|
||||
factor = (avg - threshold) / (255 - threshold)
|
||||
alpha = int(255 * (1 - factor))
|
||||
|
||||
# Clamp alpha
|
||||
alpha = max(0, min(255, alpha))
|
||||
|
||||
# We keep the pixel white to avoid dark fringes, but lower its opacity
|
||||
new_data.append((255, 255, 255, alpha))
|
||||
else:
|
||||
new_data.append(item)
|
||||
|
||||
img.putdata(new_data)
|
||||
|
||||
# Optional: crop the image to its bounding box
|
||||
bbox = img.getbbox()
|
||||
if bbox:
|
||||
img = img.crop(bbox)
|
||||
|
||||
img.save(output_path, "PNG")
|
||||
|
||||
if __name__ == "__main__":
|
||||
remove_white_bg(sys.argv[1], sys.argv[2])
|
||||
45
remove_bg_fast.ps1
Normal file
45
remove_bg_fast.ps1
Normal file
@@ -0,0 +1,45 @@
|
||||
$code = @"
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public class ImageProcessor {
|
||||
public static void RemoveWhiteBg(string inputFile, string outputFile, int threshold) {
|
||||
Bitmap bmp = new Bitmap(inputFile);
|
||||
Rectangle rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
|
||||
BitmapData bmpData = bmp.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
|
||||
|
||||
int bytes = Math.Abs(bmpData.Stride) * bmp.Height;
|
||||
byte[] rgbValues = new byte[bytes];
|
||||
Marshal.Copy(bmpData.Scan0, rgbValues, 0, bytes);
|
||||
|
||||
for (int i = 0; i < rgbValues.Length; i += 4) {
|
||||
byte b = rgbValues[i];
|
||||
byte g = rgbValues[i + 1];
|
||||
byte r = rgbValues[i + 2];
|
||||
byte a = rgbValues[i + 3];
|
||||
|
||||
float avg = (r + g + b) / 3f;
|
||||
if (avg > threshold && r > threshold - 10 && g > threshold - 10 && b > threshold - 10) {
|
||||
float factor = (avg - threshold) / (255f - threshold);
|
||||
int alpha = (int)(255 * (1 - factor));
|
||||
if (alpha < 0) alpha = 0;
|
||||
if (alpha > 255) alpha = 255;
|
||||
rgbValues[i] = 255;
|
||||
rgbValues[i + 1] = 255;
|
||||
rgbValues[i + 2] = 255;
|
||||
rgbValues[i + 3] = (byte)alpha;
|
||||
}
|
||||
}
|
||||
|
||||
Marshal.Copy(rgbValues, 0, bmpData.Scan0, bytes);
|
||||
bmp.UnlockBits(bmpData);
|
||||
bmp.Save(outputFile, ImageFormat.Png);
|
||||
bmp.Dispose();
|
||||
}
|
||||
}
|
||||
"@
|
||||
Add-Type -TypeDefinition $code -ReferencedAssemblies System.Drawing
|
||||
[ImageProcessor]::RemoveWhiteBg("d:\Code\Project\X-Financial\web\src\assets\images\raw_documents.png", "d:\Code\Project\X-Financial\web\src\assets\images\hero-financial-decor.png", 235)
|
||||
Write-Host "Done"
|
||||
BIN
server/rules/finance-rules/交通工具等级标准.xlsx
Normal file
BIN
server/rules/finance-rules/交通工具等级标准.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/交通费用预估表.xlsx
Normal file
BIN
server/rules/finance-rules/交通费用预估表.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/公司费用申请审批规则.xlsx
Normal file
BIN
server/rules/finance-rules/公司费用申请审批规则.xlsx
Normal file
Binary file not shown.
Binary file not shown.
BIN
server/rules/finance-rules/出差补助标准.xlsx
Normal file
BIN
server/rules/finance-rules/出差补助标准.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/地区淡旺季映射表.xlsx
Normal file
BIN
server/rules/finance-rules/地区淡旺季映射表.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/差旅住宿费标准.xlsx
Normal file
BIN
server/rules/finance-rules/差旅住宿费标准.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/差旅职级映射表.xlsx
Normal file
BIN
server/rules/finance-rules/差旅职级映射表.xlsx
Normal file
Binary file not shown.
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.large_expense_without_preapproval",
|
||||
"name": "大额费用未事前申请",
|
||||
"description": "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。",
|
||||
"name": "通用大额费用无前置申请",
|
||||
"description": "非业务招待、非办公用品的通用费用超过 2000 元且缺少关联费用申请。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"template_key": "composite_rule_v1",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
@@ -58,13 +60,19 @@
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"label": "明细事由",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"label": "申请单ID",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.claim_no",
|
||||
"label": "申请单号",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
@@ -95,7 +103,8 @@
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "preapproval_required_amount_threshold",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
@@ -103,31 +112,99 @@
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.claim_no",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_preapproval_threshold",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": [
|
||||
"claim.amount"
|
||||
],
|
||||
"threshold": 2000,
|
||||
"compare": "gt"
|
||||
},
|
||||
{
|
||||
"id": "application_present",
|
||||
"operator": "exists_any",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "not_specific_preapproval_type",
|
||||
"operator": "not_contains_any",
|
||||
"fields": [
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"meal",
|
||||
"entertainment",
|
||||
"office",
|
||||
"业务招待",
|
||||
"招待",
|
||||
"办公用品",
|
||||
"办公"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"大额费用",
|
||||
"未申请",
|
||||
"先申请后报销"
|
||||
],
|
||||
"condition_summary": "金额达到大额阈值且缺少已通过申请单时触发。",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"amount_exceeds_preapproval_threshold",
|
||||
{
|
||||
"not": "application_present"
|
||||
},
|
||||
"not_specific_preapproval_type"
|
||||
]
|
||||
},
|
||||
"formula": "amount > threshold AND NOT hasApplication",
|
||||
"condition_summary": "非业务招待、非办公用品的通用费用超过 2000 元且未关联费用申请时触发。",
|
||||
"message_template": "通用大额费用超过 2000 元但未找到关联费用申请,请补充前置申请或审批说明。",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"threshold_amount": 2000,
|
||||
"rule_ir": {
|
||||
"facts": [
|
||||
{
|
||||
"id": "A",
|
||||
"label": "报销金额",
|
||||
"fields": [
|
||||
"claim.amount"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "B",
|
||||
"label": "关联申请",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"hit_logic": "A > threshold AND NOT EXISTS(B)"
|
||||
},
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -141,25 +218,43 @@
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"owner": "财务制度管理组",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.805274+00:00",
|
||||
"source_ref": "公司费用申请审批规则",
|
||||
"created_at": "2026-06-05T00:00:00+08:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high",
|
||||
"rule_title": "大额费用未事前申请",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"rule_title": "通用大额费用无前置申请",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high"
|
||||
"risk_level": "high",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.meal_high_value_without_preapproval",
|
||||
"name": "大额业务招待未申请",
|
||||
"description": "业务招待金额或人均金额超过制度阈值但未事前审批。",
|
||||
"name": "业务招待高金额无前置申请",
|
||||
"description": "业务招待费超过 500 元且缺少关联费用申请或审批记录。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"template_key": "composite_rule_v1",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
"meal",
|
||||
"entertainment"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
@@ -24,7 +27,8 @@
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
"meal",
|
||||
"entertainment"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
@@ -58,13 +62,19 @@
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"label": "明细事由",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"label": "申请单ID",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.claim_no",
|
||||
"label": "申请单号",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
@@ -91,17 +101,12 @@
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "material.attendee_list_uploaded",
|
||||
"label": "参与人清单已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "preapproval_required_amount_threshold",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
@@ -109,32 +114,83 @@
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.claim_no",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name",
|
||||
"material.attendee_list_uploaded"
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_preapproval_threshold",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": [
|
||||
"claim.amount"
|
||||
],
|
||||
"threshold": 500,
|
||||
"compare": "gt"
|
||||
},
|
||||
{
|
||||
"id": "application_present",
|
||||
"operator": "exists_any",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"业务招待",
|
||||
"人均超标",
|
||||
"未申请"
|
||||
],
|
||||
"condition_summary": "业务招待金额超过申请阈值且没有通过申请时触发。",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"amount_exceeds_preapproval_threshold",
|
||||
{
|
||||
"not": "application_present"
|
||||
}
|
||||
]
|
||||
},
|
||||
"formula": "amount > threshold AND NOT hasApplication",
|
||||
"condition_summary": "业务招待费超过 500 元且未关联已审批费用申请时触发。",
|
||||
"message_template": "业务招待费超过 500 元但未找到关联费用申请,请补充前置申请或审批说明。",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
"meal",
|
||||
"entertainment"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"threshold_amount": 500,
|
||||
"rule_ir": {
|
||||
"facts": [
|
||||
{
|
||||
"id": "A",
|
||||
"label": "报销金额",
|
||||
"fields": [
|
||||
"claim.amount"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "B",
|
||||
"label": "关联申请",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"hit_logic": "A > threshold AND NOT EXISTS(B)"
|
||||
},
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -144,29 +200,48 @@
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 84
|
||||
"risk_score": 88
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"owner": "财务制度管理组",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.818641+00:00",
|
||||
"source_ref": "公司费用申请审批规则",
|
||||
"created_at": "2026-06-05T00:00:00+08:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 84,
|
||||
"risk_score": 88,
|
||||
"risk_level": "high",
|
||||
"rule_title": "大额业务招待未申请",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"rule_title": "业务招待高金额无前置申请",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
"meal",
|
||||
"entertainment"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high"
|
||||
"risk_score": 88,
|
||||
"risk_level": "high",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.office_bulk_without_purchase",
|
||||
"name": "办公用品大额采购未申请",
|
||||
"description": "批量办公用品或设备采购达到阈值但未走采购申请。",
|
||||
"name": "办公用品批量采购无前置申请",
|
||||
"description": "办公用品或办公采购费用超过 2000 元且缺少关联费用申请或采购审批。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"template_key": "composite_rule_v1",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
@@ -58,13 +60,19 @@
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"label": "明细事由",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"label": "申请单ID",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.claim_no",
|
||||
"label": "申请单号",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
@@ -95,7 +103,8 @@
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "preapproval_required_amount_threshold",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
@@ -103,31 +112,82 @@
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.claim_no",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_preapproval_threshold",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": [
|
||||
"claim.amount"
|
||||
],
|
||||
"threshold": 2000,
|
||||
"compare": "gt"
|
||||
},
|
||||
{
|
||||
"id": "application_present",
|
||||
"operator": "exists_any",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"办公采购",
|
||||
"大额办公用品",
|
||||
"采购申请"
|
||||
],
|
||||
"condition_summary": "办公用品单次金额达到采购阈值且缺少采购申请时触发。",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"amount_exceeds_preapproval_threshold",
|
||||
{
|
||||
"not": "application_present"
|
||||
}
|
||||
]
|
||||
},
|
||||
"formula": "amount > threshold AND NOT hasApplication",
|
||||
"condition_summary": "办公用品费用超过 2000 元且未关联费用申请或采购审批时触发。",
|
||||
"message_template": "办公用品费用超过 2000 元但未找到关联费用申请,请补充采购申请或审批说明。",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"threshold_amount": 2000,
|
||||
"rule_ir": {
|
||||
"facts": [
|
||||
{
|
||||
"id": "A",
|
||||
"label": "报销金额",
|
||||
"fields": [
|
||||
"claim.amount"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "B",
|
||||
"label": "关联申请",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"hit_logic": "A > threshold AND NOT EXISTS(B)"
|
||||
},
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -135,31 +195,49 @@
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 78
|
||||
"risk_score": 84
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"owner": "财务制度管理组",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.811910+00:00",
|
||||
"source_ref": "公司费用申请审批规则",
|
||||
"created_at": "2026-06-05T00:00:00+08:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 78,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "办公用品大额采购未申请",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high",
|
||||
"rule_title": "办公用品批量采购无前置申请",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 78,
|
||||
"risk_level": "medium"
|
||||
"severity": "high",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
@@ -119,15 +119,55 @@
|
||||
"未申请"
|
||||
],
|
||||
"condition_summary": "差旅金额达到大额阈值且缺少有效出差申请时触发。",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_estimate",
|
||||
"sheet": "交通费用预估表",
|
||||
"name": "交通费用预估表",
|
||||
"component": "transport_estimate"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -149,17 +189,97 @@
|
||||
"risk_score": 82,
|
||||
"risk_level": "high",
|
||||
"rule_title": "大额差旅未申请",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_estimate",
|
||||
"sheet": "交通费用预估表",
|
||||
"name": "交通费用预估表",
|
||||
"component": "transport_estimate"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 82,
|
||||
"risk_level": "high"
|
||||
"risk_level": "high",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_estimate",
|
||||
"sheet": "交通费用预估表",
|
||||
"name": "交通费用预估表",
|
||||
"component": "transport_estimate"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"ontology_signal": "travel_city_mismatch",
|
||||
"evaluator": "template_rule",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -105,7 +103,31 @@
|
||||
"项目现场"
|
||||
],
|
||||
"condition_summary": "票据城市未覆盖申报目的地,或路线出现无法由本次票据起终点和申报目的地解释的额外城市且无合理说明。",
|
||||
"message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。"
|
||||
"message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -121,16 +143,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 90,
|
||||
"risk_level": "high",
|
||||
"rule_title": "差旅目的地与票据城市不一致高风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -146,7 +167,29 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 90,
|
||||
@@ -160,5 +203,27 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"ontology_signal": "travel_date_outside_trip_window",
|
||||
"evaluator": "template_rule",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -102,7 +100,37 @@
|
||||
],
|
||||
"hit_logic": "ticket_date_outside_trip",
|
||||
"condition_summary": "任一票据/明细日期早于出差开始日前 1 天或晚于结束日后 1 天。",
|
||||
"message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。"
|
||||
"message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -118,16 +146,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 88,
|
||||
"risk_level": "high",
|
||||
"rule_title": "票据日期超出差旅行程高风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -143,7 +170,35 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 88,
|
||||
@@ -157,5 +212,33 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"ontology_signal": "travel_personal_purpose",
|
||||
"evaluator": "template_rule",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -76,7 +74,37 @@
|
||||
],
|
||||
"condition_summary": "差旅事由或票据文本命中个人旅游/私人目的关键词。",
|
||||
"message_template": "识别到个人旅游或非公务目的表达,请确认是否属于公司差旅范围。",
|
||||
"template_key": "keyword_match_v1"
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -92,16 +120,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high",
|
||||
"rule_title": "个人旅游或非公务目的高风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -117,7 +144,35 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 86,
|
||||
@@ -131,5 +186,33 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
"risk_category": "差旅费-申请审批",
|
||||
"ontology_signal": "travel_preapproval_absent",
|
||||
"evaluator": "template_rule",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -76,7 +74,19 @@
|
||||
],
|
||||
"condition_summary": "差旅申请/报销文本命中未申请、未审批或事后补申请关键词。",
|
||||
"message_template": "识别到差旅未事前申请或事后补申请迹象,请补齐已审批的差旅申请后再提交。",
|
||||
"template_key": "keyword_match_v1"
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -92,16 +102,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:费用申请审批规则",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 92,
|
||||
"risk_level": "high",
|
||||
"rule_title": "差旅未申请或事后补申请高风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -117,7 +126,17 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 92,
|
||||
@@ -131,5 +150,15 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"risk_category": "差旅费-申请信息",
|
||||
"ontology_signal": "travel_application_fields_missing",
|
||||
"evaluator": "template_rule",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"expense_application"
|
||||
],
|
||||
@@ -80,7 +80,49 @@
|
||||
],
|
||||
"condition_summary": "差旅申请缺少事由、地点、起止时间或预计金额。",
|
||||
"message_template": "差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。",
|
||||
"template_key": "field_required_v1"
|
||||
"template_key": "field_required_v1",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_estimate",
|
||||
"sheet": "交通费用预估表",
|
||||
"name": "交通费用预估表",
|
||||
"component": "transport_estimate"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -96,14 +138,14 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:费用申请审批规则、差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、交通费用预估表、出差补助标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 42,
|
||||
"risk_level": "low",
|
||||
"rule_title": "差旅申请基础信息不完整低风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"expense_application"
|
||||
],
|
||||
@@ -120,7 +162,47 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_estimate",
|
||||
"sheet": "交通费用预估表",
|
||||
"name": "交通费用预估表",
|
||||
"component": "transport_estimate"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "low",
|
||||
"risk_score": 42,
|
||||
@@ -134,5 +216,45 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_estimate",
|
||||
"sheet": "交通费用预估表",
|
||||
"name": "交通费用预估表",
|
||||
"component": "transport_estimate"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_allowance_reimbursement",
|
||||
"sheet": "出差补助标准",
|
||||
"name": "出差补助报销标准",
|
||||
"component": "allowance"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"ontology_signal": "travel_attachment_ocr_missing",
|
||||
"evaluator": "template_rule",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -50,7 +48,31 @@
|
||||
],
|
||||
"condition_summary": "差旅附件缺少可读取 OCR 文本。",
|
||||
"message_template": "差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。",
|
||||
"template_key": "field_required_v1"
|
||||
"template_key": "field_required_v1",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -66,16 +88,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 38,
|
||||
"risk_level": "low",
|
||||
"rule_title": "差旅附件无法识别低风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -91,7 +112,29 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "low",
|
||||
"risk_score": 38,
|
||||
@@ -105,5 +148,27 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
"risk_category": "差旅费-市内交通",
|
||||
"ontology_signal": "travel_local_transport_detail_missing",
|
||||
"evaluator": "template_rule",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_code": "rule.expense.company_travel_transport_class",
|
||||
"finance_rule_sheet": "交通工具等级标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -102,7 +100,19 @@
|
||||
]
|
||||
},
|
||||
"condition_summary": "存在市内交通关键词,但文本中缺少起点、终点或路线说明。",
|
||||
"message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。"
|
||||
"message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。",
|
||||
"finance_rule_code": "rule.expense.company_travel_transport_class",
|
||||
"finance_rule_sheet": "交通工具等级标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_transport_class",
|
||||
"basic_rule_sheet": "交通工具等级标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -118,16 +128,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:交通工具等级标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 36,
|
||||
"risk_level": "low",
|
||||
"rule_title": "市内交通路线说明不足低风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_code": "rule.expense.company_travel_transport_class",
|
||||
"finance_rule_sheet": "交通工具等级标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -143,7 +152,17 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_transport_class",
|
||||
"basic_rule_sheet": "交通工具等级标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "low",
|
||||
"risk_score": 36,
|
||||
@@ -157,5 +176,15 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_transport_class",
|
||||
"basic_rule_sheet": "交通工具等级标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"ontology_signal": "travel_vague_ticket_content",
|
||||
"evaluator": "vague_goods_description",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -49,7 +47,31 @@
|
||||
},
|
||||
"params": {
|
||||
"condition_summary": "票据未识别为明确的酒店、交通等差旅票据,且商品或服务名称过于笼统,无法直接对应差旅事项。",
|
||||
"message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。"
|
||||
"message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -65,16 +87,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 34,
|
||||
"risk_level": "low",
|
||||
"rule_title": "差旅票据服务内容笼统低风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -90,7 +111,29 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "low",
|
||||
"risk_score": 34,
|
||||
@@ -103,5 +146,27 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"ontology_signal": "travel_duplicate_ticket",
|
||||
"evaluator": "duplicate_invoice",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -49,7 +47,31 @@
|
||||
},
|
||||
"params": {
|
||||
"condition_summary": "票据号码在当前单据或历史报销中重复出现。",
|
||||
"message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。"
|
||||
"message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -65,16 +87,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 75,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "差旅票据重复中风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -90,7 +111,29 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 75,
|
||||
@@ -103,5 +146,27 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"ontology_signal": "travel_multi_city_without_reason",
|
||||
"evaluator": "multi_city_reason_required",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -67,7 +65,31 @@
|
||||
},
|
||||
"params": {
|
||||
"condition_summary": "差旅行程涉及 3 个及以上城市,且事由未包含中转、多地、改签、绕行等说明。",
|
||||
"message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。"
|
||||
"message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -83,16 +105,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 72,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "多城市行程缺少说明中风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -108,7 +129,29 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 72,
|
||||
@@ -121,5 +164,27 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
"risk_category": "差旅费-事由完整性",
|
||||
"ontology_signal": "travel_reason_too_brief",
|
||||
"evaluator": "reason_too_brief",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -50,7 +48,19 @@
|
||||
"params": {
|
||||
"min_reason_length": 10,
|
||||
"condition_summary": "合并申请/报销事由后有效字符少于 10 个。",
|
||||
"message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。"
|
||||
"message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -66,16 +76,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:费用申请审批规则",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 68,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "差旅事由过短中风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -91,7 +100,17 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 68,
|
||||
@@ -104,5 +123,15 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "expense.preapproval.policy",
|
||||
"basic_rule_sheet": "费用申请审批规则",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "expense.preapproval.policy",
|
||||
"sheet": "费用申请审批规则",
|
||||
"name": "公司费用申请审批规则",
|
||||
"component": "preapproval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"ontology_signal": "travel_invoice_title_mismatch",
|
||||
"evaluator": "identity_consistency",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -27,7 +26,6 @@
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
@@ -60,7 +58,31 @@
|
||||
"远光软件"
|
||||
],
|
||||
"condition_summary": "票据抬头/购买方不包含报销人姓名,也不包含公司抬头关键词。",
|
||||
"message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。"
|
||||
"message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -76,16 +98,15 @@
|
||||
"metadata": {
|
||||
"owner": "admin",
|
||||
"stability": "admin_configured",
|
||||
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
|
||||
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
|
||||
"created_at": "2026-05-26T07:06:27.746703+00:00",
|
||||
"created_by": "admin",
|
||||
"risk_score": 64,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "差旅票据抬头不一致中风险",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "公司差旅费报销规则",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
@@ -101,7 +122,29 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 64,
|
||||
@@ -114,5 +157,27 @@
|
||||
"model": "risk_score_v3",
|
||||
"source": "admin_manual_travel_risk_catalog",
|
||||
"reason": "按差旅费报销高/中/低风险分层手工设定。"
|
||||
}
|
||||
},
|
||||
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"basic_rule_sheet": "差旅住宿费标准",
|
||||
"basic_rule_refs": [
|
||||
{
|
||||
"code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"sheet": "差旅住宿费标准",
|
||||
"name": "差旅住宿报销标准",
|
||||
"component": "lodging"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_season_mapping",
|
||||
"sheet": "地区淡旺季映射表",
|
||||
"name": "地区淡旺季映射表",
|
||||
"component": "season_mapping"
|
||||
},
|
||||
{
|
||||
"code": "rule.expense.company_travel_transport_class",
|
||||
"sheet": "交通工具等级标准",
|
||||
"name": "交通工具等级标准",
|
||||
"component": "transport"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
36
server/scripts/bootstrap_paddleocr_gpu.sh
Normal file
36
server/scripts/bootstrap_paddleocr_gpu.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OCR_VENV_DIR="${OCR_VENV_DIR:-${ROOT_DIR}/.venv-ocr312}"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||
PADDLEPADDLE_GPU_VERSION="${PADDLEPADDLE_GPU_VERSION:-3.3.0}"
|
||||
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
|
||||
PADDLE_GPU_INDEX_URL="${PADDLE_GPU_INDEX_URL:-https://www.paddlepaddle.org.cn/packages/stable/cu126/}"
|
||||
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils poppler-data
|
||||
|
||||
rm -rf "${OCR_VENV_DIR}"
|
||||
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
|
||||
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
|
||||
"${OCR_VENV_DIR}/bin/pip" install \
|
||||
"paddlepaddle-gpu==${PADDLEPADDLE_GPU_VERSION}" \
|
||||
-i "${PADDLE_GPU_INDEX_URL}"
|
||||
"${OCR_VENV_DIR}/bin/pip" install "paddleocr==${PADDLEOCR_VERSION}"
|
||||
|
||||
"${OCR_VENV_DIR}/bin/python" - <<'PY'
|
||||
import paddle
|
||||
|
||||
print("PaddlePaddle:", paddle.__version__)
|
||||
print("CUDA compiled:", paddle.is_compiled_with_cuda())
|
||||
print("CUDA device count:", paddle.device.cuda.device_count())
|
||||
paddle.utils.run_check()
|
||||
PY
|
||||
|
||||
echo "PaddleOCR GPU runtime ${PADDLEOCR_VERSION} 已安装到 ${OCR_VENV_DIR}"
|
||||
@@ -3,18 +3,20 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.2.2}"
|
||||
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
|
||||
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
|
||||
echo "${PYTHON_BIN} 不存在,请先安装 Python 3。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
apt-get install -y libgl1 libglib2.0-0
|
||||
apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils poppler-data
|
||||
|
||||
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
|
||||
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
|
||||
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==3.2.0" "paddleocr==3.5.0"
|
||||
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==${PADDLEPADDLE_VERSION}" "paddleocr==${PADDLEOCR_VERSION}"
|
||||
|
||||
echo "PaddleOCR mobile runtime 已安装到 ${OCR_VENV_DIR}"
|
||||
echo "PaddleOCR mobile runtime ${PADDLEOCR_VERSION} / PaddlePaddle ${PADDLEPADDLE_VERSION} 已安装到 ${OCR_VENV_DIR}"
|
||||
|
||||
@@ -21,6 +21,8 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--lang", default="ch")
|
||||
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
|
||||
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
|
||||
parser.add_argument("--device", default=os.environ.get("OCR_DEVICE", ""))
|
||||
parser.add_argument("--enable-mkldnn", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -99,14 +101,20 @@ def build_document(input_path: str, results: list[Any]) -> dict[str, Any]:
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
ocr = PaddleOCR(
|
||||
text_detection_model_name=args.text_detection_model,
|
||||
text_recognition_model_name=args.text_recognition_model,
|
||||
use_doc_orientation_classify=False,
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
lang=args.lang,
|
||||
)
|
||||
ocr_options = {
|
||||
"text_detection_model_name": args.text_detection_model,
|
||||
"text_recognition_model_name": args.text_recognition_model,
|
||||
"use_doc_orientation_classify": False,
|
||||
"use_doc_unwarping": False,
|
||||
"use_textline_orientation": False,
|
||||
"lang": args.lang,
|
||||
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
|
||||
"enable_mkldnn": args.enable_mkldnn,
|
||||
}
|
||||
configured_device = str(args.device or "").strip()
|
||||
if configured_device:
|
||||
ocr_options["device"] = configured_device
|
||||
ocr = PaddleOCR(**ocr_options)
|
||||
|
||||
documents = []
|
||||
for input_path in args.inputs:
|
||||
|
||||
@@ -88,8 +88,11 @@ if [ ! -f "$ROOT_ENV_FILE" ]; then
|
||||
fi
|
||||
|
||||
ENV_OVERRIDE_SERVER_HOST_SET=false
|
||||
ENV_OVERRIDE_SERVER_PORT_SET=false
|
||||
ENV_OVERRIDE_POSTGRES_HOST_SET=false
|
||||
ENV_OVERRIDE_DATABASE_URL_SET=false
|
||||
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET=false
|
||||
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET=false
|
||||
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
|
||||
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
|
||||
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
|
||||
@@ -107,6 +110,11 @@ if [ "${SERVER_HOST+x}" = x ]; then
|
||||
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
|
||||
fi
|
||||
|
||||
if [ "${SERVER_PORT+x}" = x ]; then
|
||||
ENV_OVERRIDE_SERVER_PORT_SET=true
|
||||
ENV_OVERRIDE_SERVER_PORT="$SERVER_PORT"
|
||||
fi
|
||||
|
||||
if [ "${POSTGRES_HOST+x}" = x ]; then
|
||||
ENV_OVERRIDE_POSTGRES_HOST_SET=true
|
||||
ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST"
|
||||
@@ -117,6 +125,16 @@ if [ "${DATABASE_URL+x}" = x ]; then
|
||||
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
|
||||
fi
|
||||
|
||||
if [ "${STARTUP_BOOTSTRAP_ENABLED+x}" = x ]; then
|
||||
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET=true
|
||||
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED="$STARTUP_BOOTSTRAP_ENABLED"
|
||||
fi
|
||||
|
||||
if [ "${BACKGROUND_SCHEDULERS_ENABLED+x}" = x ]; then
|
||||
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET=true
|
||||
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED="$BACKGROUND_SCHEDULERS_ENABLED"
|
||||
fi
|
||||
|
||||
if [ "$PREFER_ENV_FILE_FOR_ONLYOFFICE" != true ] && [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
|
||||
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
|
||||
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
|
||||
@@ -145,6 +163,10 @@ if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then
|
||||
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_SERVER_PORT_SET" = true ]; then
|
||||
SERVER_PORT="$ENV_OVERRIDE_SERVER_PORT"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then
|
||||
POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST"
|
||||
fi
|
||||
@@ -153,6 +175,14 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then
|
||||
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET" = true ]; then
|
||||
STARTUP_BOOTSTRAP_ENABLED="$ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET" = true ]; then
|
||||
BACKGROUND_SCHEDULERS_ENABLED="$ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
|
||||
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
|
||||
fi
|
||||
@@ -188,6 +218,8 @@ if is_container; then
|
||||
fi
|
||||
|
||||
SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}"
|
||||
SERVER_WORKERS="${SERVER_WORKERS:-${WEB_CONCURRENCY:-1}}"
|
||||
export SERVER_WORKERS
|
||||
|
||||
needs_windows_python() {
|
||||
is_msys || is_wsl
|
||||
@@ -355,6 +387,12 @@ start_server() {
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
|
||||
fi
|
||||
|
||||
if [ "$SERVER_WORKERS" -gt 1 ] 2>/dev/null; then
|
||||
BACKGROUND_SCHEDULERS_ENABLED="${BACKGROUND_SCHEDULERS_ENABLED:-false}"
|
||||
export BACKGROUND_SCHEDULERS_ENABLED
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" --workers "$SERVER_WORKERS"
|
||||
fi
|
||||
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ from sqlalchemy.orm import Session
|
||||
from app.db.session import get_session_factory
|
||||
|
||||
|
||||
PLATFORM_ADMIN_IDENTITIES = {"admin", "superadmin"}
|
||||
ADMIN_HEADER_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = get_session_factory()()
|
||||
try:
|
||||
@@ -124,14 +128,15 @@ def _resolve_platform_admin_flag(
|
||||
role_codes: list[str],
|
||||
header_value: str | None,
|
||||
) -> bool:
|
||||
if str(header_value or "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
if str(header_value or "").strip().lower() in ADMIN_HEADER_TRUE_VALUES:
|
||||
return True
|
||||
|
||||
identities = {
|
||||
str(username or "").strip().lower(),
|
||||
str(name or "").strip().lower(),
|
||||
}
|
||||
return "admin" in identities or "admin" in {_normalize_role_code(item) for item in role_codes}
|
||||
normalized_role_codes = {_normalize_role_code(item) for item in role_codes}
|
||||
return bool(identities & PLATFORM_ADMIN_IDENTITIES) or bool(normalized_role_codes & PLATFORM_ADMIN_IDENTITIES)
|
||||
|
||||
|
||||
def require_admin_user(
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
@@ -50,7 +51,7 @@ async def recognize_ocr_documents(
|
||||
upload.content_type,
|
||||
)
|
||||
)
|
||||
result = OcrService(db).recognize_files(payload)
|
||||
result = await run_in_threadpool(lambda: OcrService(db).recognize_files(payload))
|
||||
return ReceiptFolderService().persist_ocr_batch(
|
||||
files=payload,
|
||||
result=result,
|
||||
|
||||
@@ -92,7 +92,7 @@ def preview_receipt(receipt_id: str, current_user: CurrentUser) -> FileResponse:
|
||||
file_path, media_type, file_name = ReceiptFolderService().resolve_preview(receipt_id, current_user)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt preview not found") from exc
|
||||
return FileResponse(file_path, media_type=media_type, filename=file_name)
|
||||
return FileResponse(file_path, media_type=media_type, filename=file_name, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -10,13 +10,17 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
||||
from app.schemas.budget import BudgetClaimAnalysisRead
|
||||
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimAttachmentActionResponse,
|
||||
ExpenseApplicationPreviewActionPayload,
|
||||
ExpenseApplicationPreviewActionResponse,
|
||||
ExpenseApplicationPreviewActionResult,
|
||||
ExpenseClaimActionResponse,
|
||||
ExpenseClaimAttachmentRead,
|
||||
ExpenseClaimApprovalPayload,
|
||||
ExpenseClaimItemCreate,
|
||||
ExpenseClaimAttachmentActionResponse,
|
||||
ExpenseClaimAttachmentRead,
|
||||
ExpenseClaimItemActionResponse,
|
||||
ExpenseClaimItemCreate,
|
||||
ExpenseClaimItemUpdate,
|
||||
ExpenseClaimRead,
|
||||
ExpenseClaimReturnPayload,
|
||||
@@ -27,10 +31,13 @@ from app.schemas.reimbursement import (
|
||||
TravelReimbursementCalculatorRequest,
|
||||
TravelReimbursementCalculatorResponse,
|
||||
)
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.reimbursement import ReimbursementService
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
router = APIRouter()
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -88,6 +95,93 @@ def calculate_travel_reimbursement(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
|
||||
def _build_application_preview_action_context(
|
||||
payload: ExpenseApplicationPreviewActionPayload,
|
||||
current_user: CurrentUserContext,
|
||||
) -> dict[str, object]:
|
||||
context_json = dict(payload.context_json or {})
|
||||
context_json.setdefault("session_type", "application")
|
||||
context_json.setdefault("entry_source", "workbench_ai_inline")
|
||||
context_json.setdefault("document_type", "expense_application")
|
||||
context_json.setdefault("application_stage", "expense_application")
|
||||
context_json.setdefault("role_codes", current_user.role_codes)
|
||||
context_json.setdefault("is_admin", current_user.is_admin)
|
||||
context_json.setdefault("username", current_user.username)
|
||||
context_json.setdefault("name", current_user.name)
|
||||
context_json.setdefault("department_name", current_user.department_name)
|
||||
context_json.setdefault("position", current_user.position)
|
||||
context_json.setdefault("grade", current_user.grade)
|
||||
context_json.setdefault("employee_no", current_user.employee_no)
|
||||
context_json.setdefault("manager_name", current_user.manager_name)
|
||||
return context_json
|
||||
|
||||
|
||||
@router.post(
|
||||
"/application-preview-action",
|
||||
response_model=ExpenseApplicationPreviewActionResponse,
|
||||
summary="按申请核对预览快速保存或提交申请单",
|
||||
description=(
|
||||
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,"
|
||||
"避免重复进入通用 Orchestrator 编排。"
|
||||
),
|
||||
)
|
||||
def run_application_preview_action(
|
||||
payload: ExpenseApplicationPreviewActionPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseApplicationPreviewActionResponse:
|
||||
context_json = _build_application_preview_action_context(payload, current_user)
|
||||
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
|
||||
request = UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id or current_user.username or current_user.name,
|
||||
message=payload.message,
|
||||
ontology=OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
permission=OntologyPermission(
|
||||
level="approval_required",
|
||||
allowed=True,
|
||||
reason="application preview fast action",
|
||||
),
|
||||
confidence=1.0,
|
||||
run_id=run_id,
|
||||
),
|
||||
context_json=context_json,
|
||||
tool_payload={},
|
||||
selected_capability_codes=[],
|
||||
degraded=False,
|
||||
requires_confirmation=False,
|
||||
)
|
||||
try:
|
||||
user_agent_response = UserAgentService(db)._build_expense_application_response(
|
||||
request,
|
||||
risk_flags=[],
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
return ExpenseApplicationPreviewActionResponse(
|
||||
status="succeeded",
|
||||
conversation_id=payload.conversation_id,
|
||||
result=ExpenseApplicationPreviewActionResult(
|
||||
message=user_agent_response.answer,
|
||||
answer=user_agent_response.answer,
|
||||
suggested_actions=[
|
||||
action.model_dump(mode="json")
|
||||
for action in user_agent_response.suggested_actions
|
||||
],
|
||||
risk_flags=user_agent_response.risk_flags,
|
||||
requires_confirmation=user_agent_response.requires_confirmation,
|
||||
draft_payload=(
|
||||
user_agent_response.draft_payload.model_dump(mode="json")
|
||||
if user_agent_response.draft_payload is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/claims",
|
||||
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
||||
@@ -187,13 +281,13 @@ def get_expense_claim_budget_analysis(
|
||||
current_user: CurrentUser,
|
||||
) -> BudgetClaimAnalysisRead:
|
||||
service = ExpenseClaimService(db)
|
||||
if not service.can_view_budget_analysis(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有预算监控员或高级财务人员可以查看预算分析。")
|
||||
claim = service.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
if not service.can_view_budget_analysis(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
if not service.can_view_budget_analysis(current_user, claim):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有该部门 P8 预算监控员或高级财务人员可以查看预算分析。")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
|
||||
return BudgetService(db).analyze_claim_budget(claim)
|
||||
|
||||
|
||||
@@ -741,7 +835,7 @@ def pay_expense_claim(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除报销单",
|
||||
description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
|
||||
description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);系统管理员可删除单据;已归档单据仅系统管理员可删除。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
@@ -765,7 +859,11 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
|
||||
claim_no = str(claim.claim_no or "").strip()
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
document_label = "申请单" if claim_no.upper().startswith(("AP-", "APP-")) or expense_type.endswith("_application") else "报销单"
|
||||
document_label = (
|
||||
"申请单"
|
||||
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
|
||||
else "报销单"
|
||||
)
|
||||
return ExpenseClaimActionResponse(
|
||||
message=f"{claim.claim_no} {document_label}已删除。",
|
||||
claim_id=claim.id,
|
||||
|
||||
437
server/src/app/api/v1/endpoints/steward.py
Normal file
437
server/src/app/api/v1/endpoints/steward.py
Normal file
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.steward import (
|
||||
StewardPlanRequest,
|
||||
StewardPlanResponse,
|
||||
StewardRuntimeDecisionRequest,
|
||||
StewardRuntimeDecisionResponse,
|
||||
StewardSlotDecisionRequest,
|
||||
StewardSlotDecisionResponse,
|
||||
StewardThinkingEvent,
|
||||
)
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.expense_claim_draft_flow import APPROVED_APPLICATION_LINK_STATUSES
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.steward_flow_state import StewardFlowStateService
|
||||
from app.services.steward_intent_agent import StewardIntentAgent
|
||||
from app.services.steward_off_topic_agent import StewardOffTopicAgent
|
||||
from app.services.steward_planner import StewardPlannerService
|
||||
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
|
||||
from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent
|
||||
|
||||
router = APIRouter(prefix="/steward")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plans",
|
||||
response_model=StewardPlanResponse,
|
||||
summary="生成小财管家任务计划",
|
||||
description="把首页自然语言和附件元信息拆解为可确认、可追踪、可分派的财务任务计划。",
|
||||
responses={
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "请求缺少任务描述,无法生成小财管家计划。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
|
||||
try:
|
||||
planner = _build_steward_planner(db)
|
||||
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
|
||||
plan = planner.build_plan(hydrated_payload)
|
||||
return _attach_conversation_state(db, hydrated_payload, plan)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/slot-decisions",
|
||||
response_model=StewardSlotDecisionResponse,
|
||||
summary="判断小财管家当前任务字段缺口",
|
||||
description="结合当前任务、本体字段和用户上下文,使用 function calling 判断下一步应先追问用户还是展示核对结果。",
|
||||
)
|
||||
def create_steward_slot_decision(
|
||||
payload: StewardSlotDecisionRequest,
|
||||
db: DbSession,
|
||||
) -> StewardSlotDecisionResponse:
|
||||
return StewardSlotDecisionAgent(RuntimeChatService(db)).decide(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/runtime-decisions",
|
||||
response_model=StewardRuntimeDecisionResponse,
|
||||
summary="判断小财管家运行时下一步动作",
|
||||
description="结合任务队列、当前结构化结果和用户输入,使用 function calling 判断应提交当前单据、继续下一任务、补字段或重新规划。",
|
||||
)
|
||||
def create_steward_runtime_decision(
|
||||
payload: StewardRuntimeDecisionRequest,
|
||||
db: DbSession,
|
||||
) -> StewardRuntimeDecisionResponse:
|
||||
hydrated_payload = _hydrate_runtime_decision_payload(db, payload)
|
||||
decision = StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(hydrated_payload)
|
||||
return _attach_runtime_conversation_state(db, hydrated_payload, decision)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plans/stream",
|
||||
summary="流式生成小财管家任务计划",
|
||||
description="以 NDJSON 逐条返回小财管家的过程摘要事件,最后返回完整任务计划。",
|
||||
)
|
||||
async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse:
|
||||
return StreamingResponse(
|
||||
_iter_steward_plan_events(payload, _build_steward_planner(db), db),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
|
||||
async def _iter_steward_plan_events(
|
||||
payload: StewardPlanRequest,
|
||||
planner: StewardPlannerService,
|
||||
db: Session,
|
||||
) -> AsyncIterator[str]:
|
||||
yield _encode_stream_event(
|
||||
"thinking",
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_agent_stream_start",
|
||||
stage="stream_start",
|
||||
title="读取用户输入",
|
||||
content="我先识别申请/报销边界;如果是历史差旅描述,会先查询可关联申请单再决定流程。",
|
||||
status="running",
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
try:
|
||||
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
|
||||
plan = planner.build_plan(hydrated_payload)
|
||||
plan = _attach_conversation_state(db, hydrated_payload, plan)
|
||||
except ValueError as exc:
|
||||
yield _encode_stream_event("error", {"message": str(exc)})
|
||||
return
|
||||
|
||||
for event in plan.thinking_events:
|
||||
yield _encode_stream_event("thinking", event.model_dump(mode="json"))
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
yield _encode_stream_event("plan", plan.model_dump(mode="json"))
|
||||
|
||||
|
||||
def _encode_stream_event(event: str, data: dict[str, Any]) -> str:
|
||||
return json.dumps({"event": event, "data": data}, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _build_steward_planner(db: Session) -> StewardPlannerService:
|
||||
runtime_chat = RuntimeChatService(db)
|
||||
return StewardPlannerService(
|
||||
intent_agent=StewardIntentAgent(runtime_chat),
|
||||
off_topic_agent=StewardOffTopicAgent(runtime_chat),
|
||||
)
|
||||
|
||||
|
||||
def _hydrate_required_application_gate(
|
||||
db: Session,
|
||||
payload: StewardPlanRequest,
|
||||
planner: StewardPlannerService,
|
||||
) -> StewardPlanRequest:
|
||||
context_json = dict(payload.context_json or {})
|
||||
required_gate = context_json.get("required_application_gate")
|
||||
if isinstance(required_gate, dict):
|
||||
travel_gate = required_gate.get("travel")
|
||||
if isinstance(travel_gate, dict) and travel_gate.get("checked") is True:
|
||||
return payload
|
||||
|
||||
message = planner._clean_text(payload.message)
|
||||
base_date = planner._resolve_base_date(payload.client_now_iso, context_json)
|
||||
if not planner._looks_like_ambiguous_travel_flow(message, base_date, payload):
|
||||
return payload
|
||||
|
||||
candidates = _query_required_application_gate_candidates(db, payload, context_json)
|
||||
next_required_gate = dict(required_gate) if isinstance(required_gate, dict) else {}
|
||||
next_required_gate["travel"] = {
|
||||
"checked": True,
|
||||
"candidate_count": len(candidates),
|
||||
"candidates": candidates[:5],
|
||||
}
|
||||
return payload.model_copy(
|
||||
update={
|
||||
"context_json": {
|
||||
**context_json,
|
||||
"required_application_gate": next_required_gate,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _query_required_application_gate_candidates(
|
||||
db: Session,
|
||||
payload: StewardPlanRequest,
|
||||
context_json: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
identities = _resolve_required_application_gate_identities(payload, context_json)
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.updated_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for claim in db.scalars(stmt).all():
|
||||
if not ExpenseClaimService._is_expense_application_claim(claim):
|
||||
continue
|
||||
if str(claim.status or "").strip().lower() not in APPROVED_APPLICATION_LINK_STATUSES:
|
||||
continue
|
||||
if identities and not _claim_matches_required_application_identity(claim, identities):
|
||||
continue
|
||||
if not _claim_matches_required_travel_application(claim, payload.message):
|
||||
continue
|
||||
candidates.append(_serialize_required_application_gate_candidate(claim))
|
||||
return candidates
|
||||
|
||||
|
||||
def _resolve_required_application_gate_identities(
|
||||
payload: StewardPlanRequest,
|
||||
context_json: dict[str, Any],
|
||||
) -> set[str]:
|
||||
raw_values = [
|
||||
payload.user_id,
|
||||
context_json.get("user_id"),
|
||||
context_json.get("username"),
|
||||
context_json.get("name"),
|
||||
context_json.get("employee_id"),
|
||||
context_json.get("employee_no"),
|
||||
context_json.get("employee_name"),
|
||||
]
|
||||
identities: set[str] = set()
|
||||
for value in raw_values:
|
||||
normalized = _normalize_required_application_identity(value)
|
||||
if normalized:
|
||||
identities.add(normalized)
|
||||
return identities
|
||||
|
||||
|
||||
def _normalize_required_application_identity(value: Any) -> str:
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def _claim_matches_required_application_identity(claim: ExpenseClaim, identities: set[str]) -> bool:
|
||||
claim_identities = {
|
||||
_normalize_required_application_identity(claim.employee_id),
|
||||
_normalize_required_application_identity(claim.employee_name),
|
||||
}
|
||||
claim_identities.discard("")
|
||||
return bool(claim_identities.intersection(identities))
|
||||
|
||||
|
||||
def _claim_matches_required_travel_application(claim: ExpenseClaim, message: str) -> bool:
|
||||
expense_type = str(claim.expense_type or "").strip().casefold()
|
||||
if any(token in expense_type for token in ("travel", "trip", "差旅", "出差")):
|
||||
return True
|
||||
|
||||
claim_text = "".join(
|
||||
[
|
||||
str(claim.reason or ""),
|
||||
str(claim.location or ""),
|
||||
str(claim.claim_no or ""),
|
||||
]
|
||||
)
|
||||
if "差旅" in claim_text or "出差" in claim_text:
|
||||
return True
|
||||
|
||||
compact_message = str(message or "").replace(" ", "")
|
||||
location = str(claim.location or "").strip()
|
||||
return bool(location and location in compact_message and "出差" in compact_message)
|
||||
|
||||
|
||||
def _serialize_required_application_gate_candidate(claim: ExpenseClaim) -> dict[str, Any]:
|
||||
business_time = _resolve_required_application_business_time(claim)
|
||||
status_label = _resolve_required_application_status_label(claim.status)
|
||||
return {
|
||||
"id": str(claim.id or "").strip(),
|
||||
"claim_no": str(claim.claim_no or "").strip(),
|
||||
"reason": str(claim.reason or "").strip(),
|
||||
"location": str(claim.location or "").strip(),
|
||||
"business_time": business_time,
|
||||
"status_label": status_label,
|
||||
"application_claim_id": str(claim.id or "").strip(),
|
||||
"application_claim_no": str(claim.claim_no or "").strip(),
|
||||
"application_reason": str(claim.reason or "").strip(),
|
||||
"application_location": str(claim.location or "").strip(),
|
||||
"application_business_time": business_time,
|
||||
"application_status_label": status_label,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_required_application_business_time(claim: ExpenseClaim) -> str:
|
||||
for flag in list(claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
for source in (
|
||||
flag,
|
||||
flag.get("application_detail"),
|
||||
flag.get("applicationDetail"),
|
||||
flag.get("review_form_values"),
|
||||
flag.get("reviewFormValues"),
|
||||
):
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
value = (
|
||||
source.get("application_business_time")
|
||||
or source.get("applicationBusinessTime")
|
||||
or source.get("business_time")
|
||||
or source.get("businessTime")
|
||||
)
|
||||
if str(value or "").strip():
|
||||
return str(value).strip()
|
||||
if claim.occurred_at is not None:
|
||||
return claim.occurred_at.date().isoformat()
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_required_application_status_label(status: Any) -> str:
|
||||
normalized = str(status or "").strip().lower()
|
||||
return {
|
||||
"approved": "已审批",
|
||||
"completed": "已完成",
|
||||
}.get(normalized, normalized)
|
||||
|
||||
|
||||
def _attach_conversation_state(
|
||||
db: Session,
|
||||
payload: StewardPlanRequest,
|
||||
plan: StewardPlanResponse,
|
||||
) -> StewardPlanResponse:
|
||||
context_json = dict(payload.context_json or {})
|
||||
context_json["session_type"] = str(context_json.get("session_type") or "steward").strip() or "steward"
|
||||
conversation_service = AgentConversationService(db)
|
||||
conversation = conversation_service.get_or_create_conversation(
|
||||
conversation_id=_resolve_conversation_id(context_json),
|
||||
user_id=payload.user_id,
|
||||
source="user_message",
|
||||
context_json=context_json,
|
||||
)
|
||||
current_state = _resolve_current_steward_state(conversation.state_json, context_json)
|
||||
steward_state = StewardFlowStateService().merge_plan(current_state, plan)
|
||||
conversation = conversation_service.update_state(
|
||||
conversation_id=conversation.conversation_id,
|
||||
run_id=None,
|
||||
scenario="steward",
|
||||
intent="plan",
|
||||
context_json={
|
||||
**context_json,
|
||||
"steward_state": steward_state,
|
||||
},
|
||||
) or conversation
|
||||
conversation_service.append_message(
|
||||
conversation_id=conversation.conversation_id,
|
||||
role="user",
|
||||
content=payload.message,
|
||||
message_json={"source": "steward_plan_request"},
|
||||
)
|
||||
conversation_service.append_message(
|
||||
conversation_id=conversation.conversation_id,
|
||||
role="assistant",
|
||||
content=plan.summary,
|
||||
message_json={
|
||||
"source": "steward_plan_response",
|
||||
"plan_id": plan.plan_id,
|
||||
"steward_state": steward_state,
|
||||
},
|
||||
)
|
||||
return plan.model_copy(
|
||||
update={
|
||||
"conversation_id": conversation.conversation_id,
|
||||
"steward_state": steward_state,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _attach_runtime_conversation_state(
|
||||
db: Session,
|
||||
payload: StewardRuntimeDecisionRequest,
|
||||
decision: StewardRuntimeDecisionResponse,
|
||||
) -> StewardRuntimeDecisionResponse:
|
||||
steward_state = decision.steward_state
|
||||
if not isinstance(steward_state, dict) or not steward_state:
|
||||
return decision
|
||||
context_json = dict(payload.context_json or {})
|
||||
conversation_id = _resolve_conversation_id(context_json)
|
||||
if not conversation_id:
|
||||
return decision
|
||||
|
||||
conversation_service = AgentConversationService(db)
|
||||
conversation_service.update_state(
|
||||
conversation_id=conversation_id,
|
||||
run_id=None,
|
||||
scenario="steward",
|
||||
intent="runtime_decision",
|
||||
context_json={
|
||||
**context_json,
|
||||
"steward_state": steward_state,
|
||||
},
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
def _hydrate_runtime_decision_payload(
|
||||
db: Session,
|
||||
payload: StewardRuntimeDecisionRequest,
|
||||
) -> StewardRuntimeDecisionRequest:
|
||||
context_json = dict(payload.context_json or {})
|
||||
runtime_state = dict(payload.runtime_state or {})
|
||||
if isinstance(runtime_state.get("steward_state"), dict) and runtime_state["steward_state"]:
|
||||
return payload
|
||||
if isinstance(context_json.get("steward_state"), dict) and context_json["steward_state"]:
|
||||
return payload
|
||||
|
||||
conversation_id = _resolve_conversation_id(context_json)
|
||||
if not conversation_id:
|
||||
return payload
|
||||
conversation = AgentConversationService(db).get_conversation(conversation_id)
|
||||
stored_state = conversation.state_json.get("steward_state") if conversation and isinstance(conversation.state_json, dict) else None
|
||||
if not isinstance(stored_state, dict) or not stored_state:
|
||||
return payload
|
||||
|
||||
runtime_state["steward_state"] = stored_state
|
||||
conversation_state = dict(context_json.get("conversation_state") or {})
|
||||
conversation_state["steward_state"] = stored_state
|
||||
context_json["conversation_state"] = conversation_state
|
||||
return payload.model_copy(
|
||||
update={
|
||||
"runtime_state": runtime_state,
|
||||
"context_json": context_json,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_conversation_id(context_json: dict[str, Any]) -> str | None:
|
||||
return str(
|
||||
context_json.get("conversation_id")
|
||||
or context_json.get("conversationId")
|
||||
or ""
|
||||
).strip() or None
|
||||
|
||||
|
||||
def _resolve_current_steward_state(
|
||||
conversation_state: dict[str, Any] | None,
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
state_json = conversation_state if isinstance(conversation_state, dict) else {}
|
||||
stored_state = state_json.get("steward_state")
|
||||
if isinstance(stored_state, dict) and stored_state:
|
||||
return stored_state
|
||||
incoming_state = context_json.get("steward_state") or context_json.get("stewardState")
|
||||
return incoming_state if isinstance(incoming_state, dict) else {}
|
||||
@@ -22,6 +22,7 @@ from app.api.v1.endpoints.receipt_folder import router as receipt_folder_router
|
||||
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
||||
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
|
||||
from app.api.v1.endpoints.settings import router as settings_router
|
||||
from app.api.v1.endpoints.steward import router as steward_router
|
||||
from app.api.v1.endpoints.system_logs import router as system_logs_router
|
||||
|
||||
router = APIRouter()
|
||||
@@ -47,4 +48,5 @@ router.include_router(employee_profiles_router, tags=["employee-profiles"])
|
||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||
router.include_router(risk_observations_router, tags=["risk-observations"])
|
||||
router.include_router(settings_router, tags=["settings"])
|
||||
router.include_router(steward_router, tags=["steward"])
|
||||
router.include_router(system_logs_router, tags=["system-logs"])
|
||||
|
||||
@@ -19,41 +19,52 @@ ONLYOFFICE_FIELD_NAMES = {
|
||||
|
||||
_settings_cache: Settings | None = None
|
||||
_settings_cache_signature: tuple[tuple[str, bool, int | None, int | None], ...] | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=DEFAULT_ENV_FILES,
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
|
||||
app_env: str = Field(default="local", alias="APP_ENV")
|
||||
app_debug: bool = Field(default=True, alias="APP_DEBUG")
|
||||
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
|
||||
|
||||
company_name: str = Field(default="", alias="COMPANY_NAME")
|
||||
company_code: str = Field(default="", alias="COMPANY_CODE")
|
||||
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
|
||||
|
||||
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
|
||||
web_port: int = Field(default=5173, alias="WEB_PORT")
|
||||
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
|
||||
app_port: int = Field(default=8000, alias="SERVER_PORT")
|
||||
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
|
||||
|
||||
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
|
||||
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
|
||||
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
|
||||
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
|
||||
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
|
||||
|
||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
|
||||
|
||||
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
||||
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
||||
|
||||
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
|
||||
app_env: str = Field(default="local", alias="APP_ENV")
|
||||
app_debug: bool = Field(default=True, alias="APP_DEBUG")
|
||||
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
|
||||
|
||||
company_name: str = Field(default="", alias="COMPANY_NAME")
|
||||
company_code: str = Field(default="", alias="COMPANY_CODE")
|
||||
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
|
||||
|
||||
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
|
||||
web_port: int = Field(default=5273, alias="WEB_PORT")
|
||||
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
|
||||
app_port: int = Field(default=8000, alias="SERVER_PORT")
|
||||
server_workers: int = Field(default=1, alias="SERVER_WORKERS")
|
||||
web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY")
|
||||
startup_bootstrap_enabled: bool = Field(default=True, alias="STARTUP_BOOTSTRAP_ENABLED")
|
||||
startup_cache_warmup_enabled: bool = Field(default=False, alias="STARTUP_CACHE_WARMUP_ENABLED")
|
||||
background_schedulers_enabled: bool = Field(
|
||||
default=True,
|
||||
alias="BACKGROUND_SCHEDULERS_ENABLED",
|
||||
)
|
||||
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
|
||||
|
||||
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
|
||||
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
|
||||
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
|
||||
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
|
||||
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
|
||||
|
||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
|
||||
sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE")
|
||||
sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW")
|
||||
sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT")
|
||||
|
||||
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
||||
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
||||
vite_api_base_url: str = Field(
|
||||
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
|
||||
)
|
||||
@@ -68,8 +79,10 @@ class Settings(BaseSettings):
|
||||
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
|
||||
storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR")
|
||||
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
|
||||
ocr_device: str = Field(default="", alias="OCR_DEVICE")
|
||||
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
|
||||
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
|
||||
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
|
||||
ocr_language: str = Field(default="ch", alias="OCR_LANGUAGE")
|
||||
seed_demo_financial_records: bool = Field(
|
||||
default=False,
|
||||
@@ -88,7 +101,7 @@ class Settings(BaseSettings):
|
||||
def resolved_database_url(self) -> str:
|
||||
if self.database_url:
|
||||
return self.database_url
|
||||
|
||||
|
||||
return (
|
||||
f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}"
|
||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
|
||||
@@ -18,11 +18,20 @@ def configure_session_factory() -> None:
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
|
||||
_engine = create_engine(
|
||||
settings.resolved_database_url,
|
||||
echo=settings.sqlalchemy_echo,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
engine_kwargs = {
|
||||
"echo": settings.sqlalchemy_echo,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
if not settings.resolved_database_url.startswith("sqlite"):
|
||||
engine_kwargs.update(
|
||||
{
|
||||
"pool_size": max(1, int(settings.sqlalchemy_pool_size or 10)),
|
||||
"max_overflow": max(0, int(settings.sqlalchemy_max_overflow or 20)),
|
||||
"pool_timeout": max(1, int(settings.sqlalchemy_pool_timeout or 30)),
|
||||
}
|
||||
)
|
||||
|
||||
_engine = create_engine(settings.resolved_database_url, **engine_kwargs)
|
||||
_session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from logging import Logger
|
||||
import threading
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -10,11 +12,13 @@ from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS
|
||||
from app.db.session import get_session_factory
|
||||
from app.middleware.logging import AccessLogMiddleware
|
||||
from app.schemas.common import RootStatusRead
|
||||
from app.services.agent_foundation import prepare_agent_foundation
|
||||
from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
|
||||
from app.services.employee import prepare_employee_directory
|
||||
from app.services.employee import EmployeeService
|
||||
from app.services.employee_profile_scheduler import employee_profile_scheduler
|
||||
from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler
|
||||
from app.services.finance_report_scheduler import finance_report_scheduler
|
||||
@@ -23,6 +27,61 @@ from app.services.knowledge import prepare_knowledge_library
|
||||
from app.services.knowledge_index_tasks import knowledge_index_task_manager
|
||||
from app.services.knowledge_rag import shutdown_knowledge_rag_runtime
|
||||
from app.services.knowledge_scheduler import knowledge_index_scheduler
|
||||
from app.services.settings import SettingsService
|
||||
from app.services.user_session_metrics import UserSessionMetricService
|
||||
|
||||
|
||||
def _effective_server_workers(settings: object) -> int:
|
||||
server_workers = getattr(settings, "server_workers", None)
|
||||
web_concurrency = getattr(settings, "web_concurrency", None)
|
||||
workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers
|
||||
try:
|
||||
return max(1, int(workers or 1))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def _should_start_background_schedulers(settings: object) -> bool:
|
||||
if not bool(getattr(settings, "background_schedulers_enabled", True)):
|
||||
return False
|
||||
|
||||
return _effective_server_workers(settings) <= 1
|
||||
|
||||
|
||||
def _run_startup_bootstrap(logger: Logger) -> None:
|
||||
steps = (
|
||||
("employee_directory", prepare_employee_directory),
|
||||
("agent_foundation", prepare_agent_foundation),
|
||||
("knowledge_library", prepare_knowledge_library),
|
||||
("hermes_skills", sync_repository_hermes_skills),
|
||||
)
|
||||
for name, step in steps:
|
||||
try:
|
||||
step()
|
||||
except Exception:
|
||||
logger.exception("Startup bootstrap step failed; continuing degraded name=%s", name)
|
||||
|
||||
|
||||
def _warm_startup_caches(logger: Logger) -> None:
|
||||
try:
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
SettingsService(db).ensure_settings_ready()
|
||||
EmployeeService(db).ensure_directory_ready()
|
||||
UserSessionMetricService(db).ensure_storage_ready()
|
||||
logger.info("Startup cache warmup complete")
|
||||
except Exception:
|
||||
logger.exception("Startup cache warmup failed; continuing without warm cache")
|
||||
|
||||
|
||||
def _start_cache_warmup_thread(logger: Logger) -> None:
|
||||
thread = threading.Thread(
|
||||
target=_warm_startup_caches,
|
||||
args=(logger,),
|
||||
name="x-financial-startup-cache-warmup",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -30,15 +89,27 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings = get_settings()
|
||||
logger = get_logger("app.main")
|
||||
|
||||
prepare_employee_directory()
|
||||
prepare_agent_foundation()
|
||||
prepare_knowledge_library()
|
||||
sync_repository_hermes_skills()
|
||||
knowledge_index_scheduler.start()
|
||||
finance_dashboard_scheduler.start()
|
||||
employee_profile_scheduler.start()
|
||||
digital_employee_reminder_scheduler.start()
|
||||
finance_report_scheduler.start()
|
||||
if settings.startup_bootstrap_enabled:
|
||||
_run_startup_bootstrap(logger)
|
||||
else:
|
||||
logger.warning("Startup bootstrap skipped because STARTUP_BOOTSTRAP_ENABLED=false")
|
||||
|
||||
if settings.startup_cache_warmup_enabled:
|
||||
_start_cache_warmup_thread(logger)
|
||||
|
||||
schedulers_started = _should_start_background_schedulers(settings)
|
||||
if schedulers_started:
|
||||
knowledge_index_scheduler.start()
|
||||
finance_dashboard_scheduler.start()
|
||||
employee_profile_scheduler.start()
|
||||
digital_employee_reminder_scheduler.start()
|
||||
finance_report_scheduler.start()
|
||||
else:
|
||||
logger.warning(
|
||||
"Background schedulers skipped - workers=%s enabled=%s",
|
||||
_effective_server_workers(settings),
|
||||
settings.background_schedulers_enabled,
|
||||
)
|
||||
logger.info(
|
||||
"Server ready - host=%s port=%s prefix=%s",
|
||||
settings.app_host,
|
||||
@@ -46,11 +117,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.api_v1_prefix,
|
||||
)
|
||||
yield
|
||||
finance_report_scheduler.shutdown()
|
||||
digital_employee_reminder_scheduler.shutdown()
|
||||
employee_profile_scheduler.shutdown()
|
||||
finance_dashboard_scheduler.shutdown()
|
||||
knowledge_index_scheduler.shutdown()
|
||||
if schedulers_started:
|
||||
finance_report_scheduler.shutdown()
|
||||
digital_employee_reminder_scheduler.shutdown()
|
||||
employee_profile_scheduler.shutdown()
|
||||
finance_dashboard_scheduler.shutdown()
|
||||
knowledge_index_scheduler.shutdown()
|
||||
knowledge_index_task_manager.shutdown()
|
||||
shutdown_knowledge_rag_runtime()
|
||||
|
||||
|
||||
@@ -58,6 +58,14 @@ class ExpenseClaim(Base):
|
||||
def employee_position(self) -> str | None:
|
||||
return str(self.employee.position).strip() if self.employee is not None and self.employee.position else None
|
||||
|
||||
@property
|
||||
def employee_no(self) -> str | None:
|
||||
return str(self.employee.employee_no).strip() if self.employee is not None and self.employee.employee_no else None
|
||||
|
||||
@property
|
||||
def employee_email(self) -> str | None:
|
||||
return str(self.employee.email).strip() if self.employee is not None and self.employee.email else None
|
||||
|
||||
@property
|
||||
def employee_grade(self) -> str | None:
|
||||
return str(self.employee.grade).strip() if self.employee is not None and self.employee.grade else None
|
||||
@@ -68,8 +76,16 @@ class ExpenseClaim(Base):
|
||||
return None
|
||||
if self.employee.manager is not None and self.employee.manager.name:
|
||||
return str(self.employee.manager.name).strip() or None
|
||||
if self.employee.organization_unit is not None and self.employee.organization_unit.manager_name:
|
||||
return str(self.employee.organization_unit.manager_name).strip() or None
|
||||
return None
|
||||
|
||||
@property
|
||||
def finance_owner_name(self) -> str | None:
|
||||
if self.employee is None or not self.employee.finance_owner_name:
|
||||
return None
|
||||
return str(self.employee.finance_owner_name).strip() or None
|
||||
|
||||
@property
|
||||
def role_labels(self) -> list[str]:
|
||||
if self.employee is None or not self.employee.roles:
|
||||
|
||||
@@ -20,7 +20,7 @@ class SystemSetting(Base):
|
||||
copyright_text: Mapped[str] = mapped_column(String(255), default="")
|
||||
theme_skin: Mapped[str] = mapped_column(String(64), default="sky")
|
||||
|
||||
admin_account: Mapped[str] = mapped_column(String(120), default="superadmin")
|
||||
admin_account: Mapped[str] = mapped_column(String(120), default="admin")
|
||||
admin_email: Mapped[str] = mapped_column(String(255), default="")
|
||||
session_timeout: Mapped[int] = mapped_column(Integer, default=30)
|
||||
conversation_retention_days: Mapped[int] = mapped_column(Integer, default=3)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -28,6 +30,74 @@ class AgentRunRepository:
|
||||
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def list_light(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
status: str | None = None,
|
||||
source: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(
|
||||
AgentRun.id.label("id"),
|
||||
AgentRun.run_id.label("run_id"),
|
||||
AgentRun.agent.label("agent"),
|
||||
AgentRun.source.label("source"),
|
||||
AgentRun.user_id.label("user_id"),
|
||||
AgentRun.task_id.label("task_id"),
|
||||
AgentRun.permission_level.label("permission_level"),
|
||||
AgentRun.status.label("status"),
|
||||
AgentRun.result_summary.label("result_summary"),
|
||||
AgentRun.error_message.label("error_message"),
|
||||
AgentRun.started_at.label("started_at"),
|
||||
AgentRun.finished_at.label("finished_at"),
|
||||
AgentRun.route_json["job_type"].as_string().label("route_job_type"),
|
||||
AgentRun.route_json["task_type"].as_string().label("route_task_type"),
|
||||
AgentRun.route_json["task_code"].as_string().label("route_task_code"),
|
||||
AgentRun.route_json["task_name"].as_string().label("route_task_name"),
|
||||
AgentRun.route_json["task_title"].as_string().label("route_task_title"),
|
||||
AgentRun.route_json["asset_name"].as_string().label("route_asset_name"),
|
||||
AgentRun.route_json["selected_agent"].as_string().label("route_selected_agent"),
|
||||
AgentRun.route_json["phase"].as_string().label("route_phase"),
|
||||
AgentRun.route_json["stage"].as_string().label("route_stage"),
|
||||
AgentRun.route_json["report_type"].as_string().label("route_report_type"),
|
||||
AgentRun.route_json["snapshot_key"].as_string().label("route_snapshot_key"),
|
||||
AgentRun.route_json["folder"].as_string().label("route_folder"),
|
||||
AgentRun.route_json["heartbeat_at"].as_string().label("route_heartbeat_at"),
|
||||
AgentRun.route_json["progress"].label("route_progress"),
|
||||
AgentRun.ontology_json["scenario"].as_string().label("ontology_scenario"),
|
||||
AgentRun.ontology_json["intent"].as_string().label("ontology_intent"),
|
||||
AgentRun.ontology_json["parse_strategy"].as_string().label("ontology_parse_strategy"),
|
||||
)
|
||||
if agent:
|
||||
stmt = stmt.where(AgentRun.agent == agent)
|
||||
if status:
|
||||
stmt = stmt.where(AgentRun.status == status)
|
||||
if source:
|
||||
stmt = stmt.where(AgentRun.source == source)
|
||||
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
|
||||
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
|
||||
|
||||
def list_light_tool_calls(self, run_ids: list[str]) -> list[dict[str, Any]]:
|
||||
if not run_ids:
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
AgentToolCall.id.label("id"),
|
||||
AgentToolCall.run_id.label("run_id"),
|
||||
AgentToolCall.tool_type.label("tool_type"),
|
||||
AgentToolCall.tool_name.label("tool_name"),
|
||||
AgentToolCall.status.label("status"),
|
||||
AgentToolCall.duration_ms.label("duration_ms"),
|
||||
AgentToolCall.error_message.label("error_message"),
|
||||
AgentToolCall.created_at.label("created_at"),
|
||||
)
|
||||
.where(AgentToolCall.run_id.in_(run_ids))
|
||||
.order_by(AgentToolCall.created_at.asc())
|
||||
)
|
||||
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
|
||||
|
||||
def get_by_run_id(self, run_id: str) -> AgentRun | None:
|
||||
stmt = select(AgentRun).where(AgentRun.run_id == run_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
@@ -28,7 +28,7 @@ class NotificationStatePatch(BaseModel):
|
||||
|
||||
|
||||
class NotificationStateBatchPatch(BaseModel):
|
||||
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100)
|
||||
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=500)
|
||||
|
||||
|
||||
class NotificationStateRead(BaseModel):
|
||||
|
||||
@@ -4,7 +4,9 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
|
||||
|
||||
|
||||
class ReimbursementCreate(BaseModel):
|
||||
@@ -126,6 +128,7 @@ class ExpenseClaimStandardAdjustmentRisk(BaseModel):
|
||||
item_id: str | None = Field(default=None, max_length=120)
|
||||
title: str | None = Field(default=None, max_length=120)
|
||||
risk: str | None = Field(default=None, max_length=500)
|
||||
application_days: int | None = Field(default=None, ge=1, le=365)
|
||||
original_amount: Decimal | None = None
|
||||
reimbursable_amount: Decimal | None = None
|
||||
|
||||
@@ -141,11 +144,15 @@ class ExpenseClaimRead(BaseModel):
|
||||
claim_no: str
|
||||
employee_id: str | None
|
||||
employee_name: str
|
||||
employee_no: str | None = None
|
||||
employee_email: str | None = None
|
||||
department_id: str | None
|
||||
department_name: str
|
||||
employee_position: str | None = None
|
||||
employee_grade: str | None = None
|
||||
manager_name: str | None = None
|
||||
finance_owner_name: str | None = None
|
||||
finance_approver_name: str | None = None
|
||||
budget_approver_name: str | None = None
|
||||
budget_approver_grade: str | None = None
|
||||
budget_approver_role_code: str | None = None
|
||||
@@ -166,6 +173,13 @@ class ExpenseClaimRead(BaseModel):
|
||||
updated_at: datetime
|
||||
items: list[ExpenseClaimItemRead] = Field(default_factory=list)
|
||||
|
||||
@field_validator("risk_flags_json", mode="before")
|
||||
@classmethod
|
||||
def dedupe_budget_risk_flags_for_read(cls, value: Any) -> list[Any]:
|
||||
if isinstance(value, list):
|
||||
return dedupe_budget_risk_flags(value)
|
||||
return []
|
||||
|
||||
|
||||
class ExpenseClaimActionResponse(BaseModel):
|
||||
message: str
|
||||
@@ -173,6 +187,29 @@ class ExpenseClaimActionResponse(BaseModel):
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class ExpenseApplicationPreviewActionPayload(BaseModel):
|
||||
source: str = Field(default="user_message", max_length=80)
|
||||
user_id: str | None = Field(default=None, max_length=120)
|
||||
conversation_id: str | None = Field(default=None, max_length=120)
|
||||
message: str = Field(min_length=1, max_length=4000)
|
||||
context_json: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExpenseApplicationPreviewActionResult(BaseModel):
|
||||
message: str
|
||||
answer: str
|
||||
suggested_actions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
risk_flags: list[str] = Field(default_factory=list)
|
||||
requires_confirmation: bool = False
|
||||
draft_payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ExpenseApplicationPreviewActionResponse(BaseModel):
|
||||
status: str = "succeeded"
|
||||
conversation_id: str | None = None
|
||||
result: ExpenseApplicationPreviewActionResult
|
||||
|
||||
|
||||
class ExpenseClaimReturnPayload(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
reason_codes: list[str] = Field(default_factory=list, max_length=10)
|
||||
@@ -186,6 +223,9 @@ class TravelReimbursementCalculatorRequest(BaseModel):
|
||||
days: int = Field(ge=1, le=365)
|
||||
location: str = Field(min_length=1, max_length=120)
|
||||
grade: str | None = Field(default=None, max_length=30)
|
||||
transport_mode: str | None = Field(default=None, max_length=30)
|
||||
origin_location: str | None = Field(default=None, max_length=120)
|
||||
travel_date: date | None = None
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorResponse(BaseModel):
|
||||
@@ -203,6 +243,17 @@ class TravelReimbursementCalculatorResponse(BaseModel):
|
||||
basic_allowance_rate: Decimal
|
||||
total_allowance_rate: Decimal
|
||||
allowance_amount: Decimal
|
||||
transport_mode: str = ""
|
||||
transport_origin: str = ""
|
||||
transport_destination: str = ""
|
||||
transport_estimated_amount: Decimal = Decimal("0.00")
|
||||
transport_estimate_basis: str = ""
|
||||
transport_estimate_confidence: str = ""
|
||||
transport_estimate_source: str = ""
|
||||
transport_estimate_rule_code: str = ""
|
||||
transport_estimate_rule_name: str = ""
|
||||
transport_estimate_rule_version: str = ""
|
||||
travel_date: date | None = None
|
||||
total_amount: Decimal
|
||||
rule_name: str
|
||||
rule_version: str
|
||||
|
||||
195
server/src/app/schemas/steward.py
Normal file
195
server/src/app/schemas/steward.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
StewardTaskType = Literal["expense_application", "reimbursement"]
|
||||
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
|
||||
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
|
||||
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardSlotNextAction = Literal["ask_user", "render_preview"]
|
||||
StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardRuntimeNextAction = Literal[
|
||||
"plan_new_tasks",
|
||||
"continue_selected_flow",
|
||||
"submit_current_application",
|
||||
"continue_next_task",
|
||||
"fill_current_slot",
|
||||
"ask_user",
|
||||
"cancel_current_action",
|
||||
"no_op",
|
||||
]
|
||||
StewardTaskStatus = Literal[
|
||||
"planned",
|
||||
"needs_confirmation",
|
||||
"ready_to_delegate",
|
||||
"delegated",
|
||||
"completed",
|
||||
"blocked",
|
||||
]
|
||||
StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"]
|
||||
StewardFlowId = Literal["travel_application", "travel_reimbursement"]
|
||||
StewardPendingFlowStatus = Literal["none", "pending", "confirmed", "rejected"]
|
||||
|
||||
|
||||
class StewardAttachmentInput(BaseModel):
|
||||
name: str = Field(description="附件原始文件名。")
|
||||
media_type: str = Field(default="", description="附件 MIME 类型。")
|
||||
ocr_summary: str = Field(default="", description="可选 OCR 摘要。")
|
||||
ocr_fields: dict[str, Any] = Field(default_factory=dict, description="可选 OCR 结构化字段。")
|
||||
|
||||
|
||||
class StewardPlanRequest(BaseModel):
|
||||
message: str = Field(description="用户在首页输入的自然语言任务。")
|
||||
user_id: str | None = Field(default=None, description="当前用户 ID。")
|
||||
client_now_iso: str | None = Field(default=None, description="客户端当前时间 ISO 字符串。")
|
||||
attachments: list[StewardAttachmentInput] = Field(default_factory=list, description="随本次输入上传的附件。")
|
||||
context_json: dict[str, Any] = Field(default_factory=dict, description="调用方上下文。")
|
||||
|
||||
|
||||
class StewardThinkingEvent(BaseModel):
|
||||
event_id: str = Field(description="过程摘要事件 ID。")
|
||||
stage: str = Field(description="阶段编码。")
|
||||
title: str = Field(description="面向用户展示的阶段标题。")
|
||||
content: str = Field(description="面向用户展示的过程摘要。")
|
||||
status: str = Field(default="completed", description="事件状态。")
|
||||
|
||||
|
||||
class StewardTask(BaseModel):
|
||||
task_id: str = Field(description="小财管家任务 ID。")
|
||||
task_type: StewardTaskType = Field(description="任务类型。")
|
||||
assigned_agent: StewardAssignedAgent = Field(description="建议分派的下游助手。")
|
||||
title: str = Field(description="任务标题。")
|
||||
summary: str = Field(description="任务摘要。")
|
||||
status: StewardTaskStatus = Field(default="needs_confirmation", description="任务状态。")
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="识别置信度。")
|
||||
ontology_fields: dict[str, str] = Field(default_factory=dict, description="归一化后的业务本体字段。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="仍缺失的本体字段。")
|
||||
confirmation_required: bool = Field(default=True, description="执行前是否需要用户确认。")
|
||||
|
||||
|
||||
class StewardAttachmentGroup(BaseModel):
|
||||
group_id: str = Field(description="附件归集组 ID。")
|
||||
target_task_id: str | None = Field(default=None, description="建议归属的任务 ID。")
|
||||
scene: str = Field(description="归集场景编码。")
|
||||
scene_label: str = Field(description="归集场景展示名。")
|
||||
attachment_names: list[str] = Field(default_factory=list, description="建议纳入的附件名称。")
|
||||
excluded_attachment_names: list[str] = Field(default_factory=list, description="建议排除或单独处理的附件名称。")
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="归集置信度。")
|
||||
rationale: str = Field(default="", description="归集依据。")
|
||||
confirmation_required: bool = Field(default=True, description="归集前是否需要用户确认。")
|
||||
|
||||
|
||||
class StewardConfirmationAction(BaseModel):
|
||||
confirmation_id: str = Field(description="确认动作 ID。")
|
||||
action_type: str = Field(description="确认动作类型。")
|
||||
label: str = Field(description="确认按钮文案。")
|
||||
description: str = Field(default="", description="确认动作说明。")
|
||||
target_task_id: str | None = Field(default=None, description="关联任务 ID。")
|
||||
attachment_group_id: str | None = Field(default=None, description="关联附件归集组 ID。")
|
||||
status: StewardConfirmationStatus = Field(default="pending", description="确认状态。")
|
||||
payload: dict[str, Any] = Field(default_factory=dict, description="确认后继续执行所需载荷。")
|
||||
|
||||
|
||||
class StewardCandidateFlow(BaseModel):
|
||||
flow_id: StewardFlowId = Field(description="候选业务流程。")
|
||||
label: str = Field(description="用户可见候选流程名称。")
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="候选流程置信度。")
|
||||
reason: str = Field(default="", description="候选流程依据。")
|
||||
ontology_fields: dict[str, str] = Field(default_factory=dict, description="候选流程可继承的 canonical ontology 字段。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="候选流程仍缺失的 canonical ontology 字段。")
|
||||
|
||||
|
||||
class StewardPendingFlowConfirmation(BaseModel):
|
||||
status: StewardPendingFlowStatus = Field(default="none", description="候选流程确认状态。")
|
||||
source_message: str = Field(default="", description="触发候选流程确认的用户原始输入。")
|
||||
reason: str = Field(default="", description="需要确认流程方向的原因。")
|
||||
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="候选业务流程。")
|
||||
|
||||
|
||||
class StewardPlanResponse(BaseModel):
|
||||
plan_id: str = Field(description="小财管家计划 ID。")
|
||||
plan_status: str = Field(default="needs_confirmation", description="计划状态。")
|
||||
planning_source: StewardPlanningSource = Field(default="rule_fallback", description="计划生成来源。")
|
||||
next_action: StewardPlanNextAction = Field(default="confirm_task", description="计划完成后的下一步动作。")
|
||||
conversation_id: str = Field(default="", description="持久化会话 ID。")
|
||||
steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家跨轮业务状态。")
|
||||
summary: str = Field(description="计划摘要。")
|
||||
thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。")
|
||||
tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。")
|
||||
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
|
||||
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
|
||||
pending_flow_confirmation: StewardPendingFlowConfirmation = Field(
|
||||
default_factory=StewardPendingFlowConfirmation,
|
||||
description="申请/报销流程不明确时等待用户确认的候选流程。",
|
||||
)
|
||||
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="等待用户确认的候选流程快捷列表。")
|
||||
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
|
||||
suggested_prompts: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="当 plan_status 为 off_topic 等场景时,给用户的推荐话术示例。",
|
||||
)
|
||||
|
||||
|
||||
class StewardSlotOption(BaseModel):
|
||||
label: str = Field(description="用户可见选项文案。")
|
||||
value: str = Field(description="写回本体字段的选项值。")
|
||||
field_key: str = Field(description="对应 canonical ontology field。")
|
||||
description: str = Field(default="", description="选项说明。")
|
||||
|
||||
|
||||
class StewardSlotDecisionRequest(BaseModel):
|
||||
task_type: StewardTaskType = Field(description="当前小财管家正在推进的任务类型。")
|
||||
user_message: str = Field(description="用户原始话术或小财管家携带的任务上下文。")
|
||||
ontology_fields: dict[str, str] = Field(default_factory=dict, description="当前已抽取的 canonical ontology 字段。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="上游意图识别给出的 canonical 缺失字段。")
|
||||
task_context: dict[str, Any] = Field(default_factory=dict, description="当前任务、附件、申请预览等上下文。")
|
||||
|
||||
|
||||
class StewardSlotDecisionResponse(BaseModel):
|
||||
decision_source: StewardSlotDecisionSource = Field(default="rule_fallback", description="字段决策来源。")
|
||||
next_action: StewardSlotNextAction = Field(description="下一步应追问用户还是展示核对结果。")
|
||||
required_fields: list[str] = Field(default_factory=list, description="模型认为当前业务需要的 canonical 字段。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="当前仍缺失的 canonical 字段。")
|
||||
question: str = Field(default="", description="需要追问时展示给用户的问题。")
|
||||
options: list[StewardSlotOption] = Field(default_factory=list, description="可直接选择的补充选项。")
|
||||
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
|
||||
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
|
||||
|
||||
|
||||
class StewardRuntimeDecisionRequest(BaseModel):
|
||||
user_message: str = Field(description="用户当前输入。")
|
||||
session_type: str = Field(default="steward", description="当前前端会话类型。")
|
||||
runtime_state: dict[str, Any] = Field(default_factory=dict, description="小财管家运行时上下文。")
|
||||
context_json: dict[str, Any] = Field(default_factory=dict, description="调用方补充上下文。")
|
||||
|
||||
|
||||
class StewardRuntimeDecisionResponse(BaseModel):
|
||||
decision_source: StewardRuntimeDecisionSource = Field(default="rule_fallback", description="运行时决策来源。")
|
||||
next_action: StewardRuntimeNextAction = Field(description="小财管家下一步动作。")
|
||||
target_task_id: str = Field(default="", description="关联的小财管家任务 ID。")
|
||||
target_message_id: str = Field(default="", description="关联的前端消息 ID。")
|
||||
field_key: str = Field(default="", description="补字段时对应 canonical ontology field。")
|
||||
field_value: str = Field(default="", description="补字段时用户提供的字段值。")
|
||||
confirmation_required: bool = Field(default=False, description="执行该动作前是否仍需要用户二次确认。")
|
||||
question: str = Field(default="", description="需要追问用户时展示的问题。")
|
||||
response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。")
|
||||
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
|
||||
steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家更新后的跨轮业务状态。")
|
||||
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
|
||||
|
||||
|
||||
class StewardFlowStatePatch(BaseModel):
|
||||
active_flow: StewardFlowId = Field(description="本轮对话正在推进的业务流程。")
|
||||
flow_id: StewardFlowId = Field(description="需要合并字段的目标业务流程。")
|
||||
intent: str = Field(default="", description="本轮识别出的业务意图。")
|
||||
status: str = Field(default="collecting", description="流程状态。")
|
||||
fields: dict[str, Any] = Field(default_factory=dict, description="待写入流程的本体字段 patch。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="仍缺失的 canonical ontology 字段。")
|
||||
application_claim_id: str = Field(default="", description="出差申请流程已生成的申请单 ID。")
|
||||
linked_application_claim_id: str = Field(default="", description="报销流程关联的申请单 ID。")
|
||||
attachments: list[dict[str, Any]] = Field(default_factory=list, description="流程关联附件摘要。")
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list, description="字段来源证据。")
|
||||
62
server/src/app/services/agent_asset_finance_spreadsheets.py
Normal file
62
server/src/app/services/agent_asset_finance_spreadsheets.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.agent_asset_travel_spreadsheets import build_styled_workbook
|
||||
|
||||
|
||||
def build_communication_expense_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
"通信费报销标准",
|
||||
[
|
||||
"序号",
|
||||
"适用对象",
|
||||
"岗位/职级范围",
|
||||
"月度报销上限",
|
||||
"票据要求",
|
||||
"申请阶段预算口径",
|
||||
"审批/例外说明",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
[
|
||||
1,
|
||||
"一线销售/客户成功",
|
||||
"销售经理、客户成功经理、项目驻场岗位",
|
||||
200,
|
||||
"运营商通信费发票或电子账单",
|
||||
"按月度上限占用预算",
|
||||
"超出上限需直属领导审批并说明客户项目",
|
||||
"仅覆盖因公通信支出",
|
||||
],
|
||||
[
|
||||
2,
|
||||
"项目交付/实施",
|
||||
"实施顾问、项目经理、现场支持岗位",
|
||||
150,
|
||||
"运营商通信费发票或电子账单",
|
||||
"按月度上限占用预算",
|
||||
"长期驻场可按项目专项审批调整",
|
||||
"需关联项目或客户",
|
||||
],
|
||||
[
|
||||
3,
|
||||
"管理岗位",
|
||||
"部门负责人及以上",
|
||||
120,
|
||||
"运营商通信费发票或电子账单",
|
||||
"按月度上限占用预算",
|
||||
"超出上限需补充业务说明",
|
||||
"按自然月核算",
|
||||
],
|
||||
[
|
||||
4,
|
||||
"普通员工",
|
||||
"未单列岗位",
|
||||
80,
|
||||
"运营商通信费发票或电子账单",
|
||||
"按月度上限占用预算",
|
||||
"原则上不支持超额报销",
|
||||
"特殊岗位需先维护适用对象",
|
||||
],
|
||||
],
|
||||
column_widths=[8, 22, 30, 16, 30, 24, 38, 28],
|
||||
)
|
||||
@@ -14,6 +14,16 @@ from zipfile import ZIP_DEFLATED, ZipFile
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.core.config import SERVER_DIR, get_settings
|
||||
from app.services.agent_asset_finance_spreadsheets import build_communication_expense_workbook
|
||||
from app.services.agent_asset_travel_spreadsheets import (
|
||||
build_travel_allowance_workbook,
|
||||
build_travel_grade_mapping_workbook,
|
||||
build_travel_lodging_workbook_from_source,
|
||||
build_travel_season_mapping_workbook,
|
||||
build_travel_transport_class_workbook,
|
||||
build_travel_transport_estimate_workbook,
|
||||
build_xlsx_bytes_from_source_sheet,
|
||||
)
|
||||
|
||||
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
||||
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
|
||||
@@ -21,9 +31,29 @@ RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "差旅住宿费标准.xlsx"
|
||||
COMPANY_TRAVEL_SOURCE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE = "rule.expense.company_travel_allowance_reimbursement"
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME = "出差补助标准.xlsx"
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_CODE = "rule.expense.company_travel_transport_class"
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME = "交通工具等级标准.xlsx"
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE = "rule.expense.company_travel_transport_estimate"
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME = "交通费用预估表.xlsx"
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE = "rule.expense.company_travel_grade_mapping"
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME = "差旅职级映射表.xlsx"
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE = "rule.expense.company_travel_season_mapping"
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME = "地区淡旺季映射表.xlsx"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
|
||||
COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement"
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx"
|
||||
TRAVEL_SPREADSHEET_RULE_CODES = {
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
}
|
||||
FINANCE_RULES_LIBRARY = "finance-rules"
|
||||
RISK_RULES_LIBRARY = "risk-rules"
|
||||
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
||||
@@ -282,65 +312,79 @@ class AgentAssetSpreadsheetManager:
|
||||
|
||||
@staticmethod
|
||||
def build_company_travel_rule_template() -> bytes:
|
||||
standard_rows = [
|
||||
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
||||
[
|
||||
"长途交通",
|
||||
"飞机、高铁、火车等跨城出行",
|
||||
"行程单、车票、发票",
|
||||
"据实报销",
|
||||
"超预算需直属领导审批",
|
||||
"优先选择公共交通",
|
||||
],
|
||||
[
|
||||
"住宿费",
|
||||
"出差住宿",
|
||||
"酒店发票、入住清单",
|
||||
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
|
||||
"超标需总监审批",
|
||||
"协议酒店优先",
|
||||
],
|
||||
[
|
||||
"市内交通",
|
||||
"出租车、网约车、地铁、公交",
|
||||
"发票或电子行程单",
|
||||
"150/天",
|
||||
"超限需补充说明",
|
||||
"夜间或无公共交通场景可豁免",
|
||||
],
|
||||
[
|
||||
"餐补",
|
||||
"出差期间日常补助",
|
||||
"无需票据",
|
||||
"120/天",
|
||||
"系统自动核定",
|
||||
"当天往返默认不享受",
|
||||
],
|
||||
[
|
||||
"招待餐费",
|
||||
"客户接待或项目宴请",
|
||||
"餐饮发票、参与人清单",
|
||||
"300/人",
|
||||
"需业务负责人审批",
|
||||
"需关联客户或项目",
|
||||
],
|
||||
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
|
||||
|
||||
@staticmethod
|
||||
def build_travel_lodging_rule_template() -> bytes:
|
||||
lodging_rows = [
|
||||
["地区(城市)", "城市级别", "P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "备注"],
|
||||
["北京", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"],
|
||||
["上海", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"],
|
||||
["广州", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "广交会期间可按例外流程说明"],
|
||||
["深圳", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "旺季需补充超标说明"],
|
||||
["杭州", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
|
||||
["南京", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
|
||||
["成都", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
|
||||
["武汉", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
|
||||
["其他地区", "其他地区", 320, 320, 320, 320, 380, 380, 380, 450, 450, "未单列城市按其他地区执行"],
|
||||
]
|
||||
instruction_rows = [
|
||||
["字段", "填写说明"],
|
||||
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
|
||||
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
|
||||
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
|
||||
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
|
||||
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
|
||||
["备注", "记录豁免条件、灰度口径或制度来源。"],
|
||||
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
|
||||
]
|
||||
return _build_xlsx_bytes(
|
||||
[
|
||||
("差旅报销标准", standard_rows),
|
||||
("填表说明", instruction_rows),
|
||||
]
|
||||
source_path = (
|
||||
SERVER_DIR
|
||||
/ "rules"
|
||||
/ FINANCE_RULES_LIBRARY
|
||||
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
|
||||
)
|
||||
return build_travel_lodging_workbook_from_source(source_path, lodging_rows)
|
||||
|
||||
@staticmethod
|
||||
def build_travel_allowance_rule_template() -> bytes:
|
||||
return build_travel_allowance_workbook()
|
||||
|
||||
@staticmethod
|
||||
def build_travel_transport_rule_template() -> bytes:
|
||||
return build_travel_transport_class_workbook()
|
||||
|
||||
@staticmethod
|
||||
def build_travel_grade_mapping_template() -> bytes:
|
||||
return build_travel_grade_mapping_workbook()
|
||||
|
||||
@staticmethod
|
||||
def build_travel_season_mapping_template() -> bytes:
|
||||
source_path = (
|
||||
SERVER_DIR
|
||||
/ "rules"
|
||||
/ FINANCE_RULES_LIBRARY
|
||||
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
|
||||
)
|
||||
return build_travel_season_mapping_workbook(source_path)
|
||||
|
||||
@staticmethod
|
||||
def build_travel_transport_estimate_rule_template() -> bytes:
|
||||
return build_travel_transport_estimate_workbook()
|
||||
|
||||
@staticmethod
|
||||
def build_company_communication_rule_template() -> bytes:
|
||||
return build_communication_expense_workbook()
|
||||
|
||||
@staticmethod
|
||||
def _build_travel_source_sheet(
|
||||
sheet_name: str,
|
||||
*,
|
||||
fallback_rows: list[list[object]],
|
||||
) -> bytes:
|
||||
source_path = (
|
||||
SERVER_DIR
|
||||
/ "rules"
|
||||
/ FINANCE_RULES_LIBRARY
|
||||
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
|
||||
)
|
||||
if source_path.exists():
|
||||
try:
|
||||
return build_xlsx_bytes_from_source_sheet(source_path, sheet_name)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
return _build_xlsx_bytes([(sheet_name, fallback_rows)])
|
||||
|
||||
@staticmethod
|
||||
def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
||||
@@ -348,7 +392,17 @@ class AgentAssetSpreadsheetManager:
|
||||
|
||||
@staticmethod
|
||||
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
|
||||
return _build_xlsx_bytes([(sheet_name, [[""]])])
|
||||
return _build_xlsx_bytes(
|
||||
[
|
||||
(
|
||||
sheet_name,
|
||||
[
|
||||
["规则项", "适用条件", "标准/阈值", "所需材料", "审批要求", "备注"],
|
||||
["", "", "", "", "", ""],
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def rebuild_from_uploaded_content(content: bytes) -> bytes:
|
||||
@@ -358,23 +412,20 @@ class AgentAssetSpreadsheetManager:
|
||||
try:
|
||||
workbook = load_workbook(
|
||||
filename=BytesIO(content),
|
||||
read_only=True,
|
||||
read_only=False,
|
||||
data_only=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError("无法解析上传的 Excel 表格。") from exc
|
||||
|
||||
sheets: list[tuple[str, list[list[object]]]] = []
|
||||
for worksheet in workbook.worksheets:
|
||||
rows = [
|
||||
list(row)
|
||||
for row in worksheet.iter_rows(values_only=True)
|
||||
]
|
||||
sheets.append((worksheet.title, _trim_empty_table(rows)))
|
||||
|
||||
if not sheets:
|
||||
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
|
||||
return _build_xlsx_bytes(sheets)
|
||||
try:
|
||||
if not workbook.worksheets:
|
||||
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
|
||||
rebuilt_buffer = BytesIO()
|
||||
workbook.save(rebuilt_buffer)
|
||||
return rebuilt_buffer.getvalue()
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
|
||||
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
||||
@@ -542,7 +593,7 @@ def _build_styles_xml() -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||||
'<fonts count="1"><font><sz val="13"/><name val="Microsoft YaHei"/></font></fonts>'
|
||||
'<fills count="2"><fill><patternFill patternType="none"/></fill>'
|
||||
'<fill><patternFill patternType="gray125"/></fill></fills>'
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
||||
@@ -560,6 +611,14 @@ def _build_styles_xml() -> str:
|
||||
def _build_sheet_xml(rows: list[list[object]]) -> str:
|
||||
normalized_rows = rows or [[""]]
|
||||
max_column_count = max((len(row) for row in normalized_rows), default=1)
|
||||
column_widths = _build_sheet_column_widths(normalized_rows, max_column_count)
|
||||
column_xml = "".join(
|
||||
(
|
||||
f'<col min="{index}" max="{index}" width="{width}" '
|
||||
'customWidth="1" bestFit="1"/>'
|
||||
)
|
||||
for index, width in enumerate(column_widths, start=1)
|
||||
)
|
||||
worksheet_rows: list[str] = []
|
||||
|
||||
for row_index, row in enumerate(normalized_rows, start=1):
|
||||
@@ -571,15 +630,18 @@ def _build_sheet_xml(rows: list[list[object]]) -> str:
|
||||
cells.append(
|
||||
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>'
|
||||
)
|
||||
worksheet_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
|
||||
worksheet_rows.append(
|
||||
f'<row r="{row_index}" ht="25" customHeight="1">{"".join(cells)}</row>'
|
||||
)
|
||||
|
||||
dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
f'<dimension ref="{dimension}"/>'
|
||||
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>"
|
||||
"<sheetFormatPr defaultRowHeight=\"18\"/>"
|
||||
'<sheetViews><sheetView workbookViewId="0" zoomScale="120" zoomScaleNormal="120"/></sheetViews>'
|
||||
"<sheetFormatPr defaultRowHeight=\"25\" customHeight=\"1\"/>"
|
||||
f"<cols>{column_xml}</cols>"
|
||||
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
|
||||
"</worksheet>"
|
||||
)
|
||||
@@ -594,6 +656,31 @@ def _column_letter(index: int) -> str:
|
||||
return result
|
||||
|
||||
|
||||
def _build_sheet_column_widths(
|
||||
rows: list[list[object]],
|
||||
max_column_count: int,
|
||||
) -> list[str]:
|
||||
widths: list[str] = []
|
||||
for column_index in range(max_column_count):
|
||||
max_text_width = 0.0
|
||||
for row in rows[:120]:
|
||||
value = row[column_index] if column_index < len(row) else ""
|
||||
text = "" if value is None else str(value)
|
||||
if not text:
|
||||
continue
|
||||
max_text_width = max(max_text_width, _estimate_display_width(text))
|
||||
width = min(max(max_text_width + 4, 16), 42)
|
||||
widths.append(f"{width:.1f}")
|
||||
return widths
|
||||
|
||||
|
||||
def _estimate_display_width(text: str) -> float:
|
||||
width = 0.0
|
||||
for char in text:
|
||||
width += 2.0 if ord(char) > 127 else 1.0
|
||||
return width
|
||||
|
||||
|
||||
def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
|
||||
normalized_rows = [list(row) for row in rows]
|
||||
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):
|
||||
|
||||
@@ -13,8 +13,18 @@ from app.schemas.agent_asset import (
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
RULE_LIBRARY_NAMES,
|
||||
SPREADSHEET_MIME_TYPE,
|
||||
@@ -133,7 +143,7 @@ class AgentAssetSpreadsheetHelperMixin:
|
||||
}
|
||||
if config_json.get("rule_document") != expected_document:
|
||||
config_json["detail_mode"] = "spreadsheet"
|
||||
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
|
||||
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
|
||||
config_json["rule_library"] = library
|
||||
config_json["rule_document"] = expected_document
|
||||
asset.config_json = config_json
|
||||
@@ -160,7 +170,7 @@ class AgentAssetSpreadsheetHelperMixin:
|
||||
)
|
||||
config_json = dict(asset.config_json or {})
|
||||
config_json["detail_mode"] = "spreadsheet"
|
||||
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
|
||||
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
|
||||
config_json["rule_library"] = library
|
||||
config_json["rule_document"] = {
|
||||
**self.spreadsheet_manager.build_rule_document_config(
|
||||
@@ -187,6 +197,16 @@ class AgentAssetSpreadsheetHelperMixin:
|
||||
return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
|
||||
if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE:
|
||||
return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_ALLOWANCE_RULE_CODE:
|
||||
return COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_TRANSPORT_RULE_CODE:
|
||||
return COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE:
|
||||
return COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE:
|
||||
return COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE:
|
||||
return COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
|
||||
fallback = Path(str(asset.name or "规则表").strip()).name
|
||||
return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx"
|
||||
|
||||
|
||||
554
server/src/app/services/agent_asset_travel_spreadsheets.py
Normal file
554
server/src/app/services/agent_asset_travel_spreadsheets.py
Normal file
@@ -0,0 +1,554 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import copy
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
|
||||
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
|
||||
|
||||
|
||||
LODGING_SHEET_NAME = "差旅住宿费标准"
|
||||
ALLOWANCE_SHEET_NAME = "出差补助标准"
|
||||
TRANSPORT_CLASS_SHEET_NAME = "交通工具等级标准"
|
||||
TRANSPORT_ESTIMATE_SHEET_NAME = "交通费用预估表"
|
||||
|
||||
|
||||
TRAVEL_GRADE_LABELS = {
|
||||
"P0": "实习/见习",
|
||||
"P1": "基础员工",
|
||||
"P2": "初级员工",
|
||||
"P3": "普通员工",
|
||||
"P4": "资深员工/主管",
|
||||
"P5": "基层经理",
|
||||
"P6": "中层经理",
|
||||
"P7": "高层经理",
|
||||
"P8": "董事会",
|
||||
}
|
||||
|
||||
|
||||
def build_travel_lodging_workbook_from_source(
|
||||
source_path: Path,
|
||||
fallback_rows: list[list[object]],
|
||||
) -> bytes:
|
||||
rows: list[list[object]] = []
|
||||
if source_path.exists():
|
||||
workbook = load_workbook(source_path, read_only=True, data_only=True)
|
||||
try:
|
||||
if LODGING_SHEET_NAME in workbook.sheetnames:
|
||||
rows = _extract_lodging_rows(
|
||||
list(workbook[LODGING_SHEET_NAME].iter_rows(values_only=True))
|
||||
)
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
if not rows:
|
||||
rows = _fallback_lodging_rows(fallback_rows)
|
||||
|
||||
return build_styled_workbook(
|
||||
LODGING_SHEET_NAME,
|
||||
["序号", "地区", "地区(城市)", *TRAVEL_GRADE_KEYS, "常规超标限额"],
|
||||
[
|
||||
[
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
*_expand_lodging_grade_amounts(row),
|
||||
row[7],
|
||||
]
|
||||
for row in rows
|
||||
],
|
||||
column_widths=[8, 14, 28, *([12] * len(TRAVEL_GRADE_KEYS)), 16],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_grade_mapping_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
"差旅职级映射表",
|
||||
["序号", "职级", "职级名称", "住宿标准列", "交通标准行", "适用说明", "备注"],
|
||||
[
|
||||
[index, grade, TRAVEL_GRADE_LABELS[grade], grade, grade, _grade_usage_note(grade), ""]
|
||||
for index, grade in enumerate(TRAVEL_GRADE_KEYS, start=1)
|
||||
],
|
||||
column_widths=[8, 14, 28, 14, 14, 32, 32],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_allowance_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
ALLOWANCE_SHEET_NAME,
|
||||
["序号", "补助区域", "伙食补助/天", "基本补助/天", "补助合计/天", "适用说明", "备注"],
|
||||
[
|
||||
[1, "直辖市/特区", 65, 35, 100, "北京、上海、天津、重庆、深圳等地区", "按出差自然日计算"],
|
||||
[2, "其他地区", 55, 35, 90, "未单列的境内城市和地区", "申请阶段用于预算占用"],
|
||||
[3, "新疆-乌鲁木齐", 75, 45, 120, "乌鲁木齐市", "按高原/远途地区补助口径执行"],
|
||||
[4, "新疆-其他", 65, 40, 105, "新疆除乌鲁木齐外地区", "按远途地区补助口径执行"],
|
||||
[5, "西藏", 80, 50, 130, "西藏自治区", "按高原地区补助口径执行"],
|
||||
[6, "港澳台", 120, 80, 200, "香港、澳门、台湾地区", "需按出入境及外币票据要求补充材料"],
|
||||
[7, "国外", 180, 120, 300, "境外国家和地区", "外币折算按财务汇率口径执行"],
|
||||
],
|
||||
column_widths=[8, 18, 16, 16, 16, 34, 34],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_transport_class_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
TRANSPORT_CLASS_SHEET_NAME,
|
||||
[
|
||||
"序号",
|
||||
"职级",
|
||||
"职级说明",
|
||||
"飞机标准",
|
||||
"火车标准",
|
||||
"轮船标准",
|
||||
"适用说明",
|
||||
"超标处理",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
[
|
||||
index,
|
||||
grade,
|
||||
TRAVEL_GRADE_LABELS[grade],
|
||||
"经济舱",
|
||||
"二等座/硬卧/硬座" if grade != "P8" else "二等座/软卧/硬卧",
|
||||
"二等舱",
|
||||
"按已审批出差申请执行" if grade in {"P6", "P7", "P8"} else "优先选择火车或高铁;确需飞机时按经济舱执行",
|
||||
"超出标准需说明原因并走审批" if grade != "P8" else "超出标准需董事会或授权审批确认",
|
||||
"申请阶段按交通费用预估表占用预算" if grade != "P8" else "P8 为董事会级别",
|
||||
]
|
||||
for index, grade in enumerate(TRAVEL_GRADE_KEYS, start=1)
|
||||
],
|
||||
column_widths=[8, 18, 34, 14, 22, 14, 42, 34, 34],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_season_mapping_workbook(source_path: Path) -> bytes:
|
||||
rows: list[list[object]] = []
|
||||
if source_path.exists():
|
||||
workbook = load_workbook(source_path, read_only=True, data_only=True)
|
||||
try:
|
||||
if LODGING_SHEET_NAME in workbook.sheetnames:
|
||||
lodging_rows = _extract_lodging_rows(
|
||||
list(workbook[LODGING_SHEET_NAME].iter_rows(values_only=True))
|
||||
)
|
||||
rows = [
|
||||
[row[0], row[1], row[2], row[3], row[7], row[8]]
|
||||
for row in lodging_rows
|
||||
]
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
if not rows:
|
||||
rows = [[1, "北京", "北京", "", 500, ""]]
|
||||
|
||||
return build_styled_workbook(
|
||||
"地区淡旺季映射表",
|
||||
["序号", "地区", "地区(城市)", "旺季期间(月)", "常规超标限额", "旺季超标限额"],
|
||||
rows,
|
||||
column_widths=[8, 14, 28, 18, 16, 16],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_transport_estimate_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
TRANSPORT_ESTIMATE_SHEET_NAME,
|
||||
[
|
||||
"序号",
|
||||
"出发城市",
|
||||
"目的地",
|
||||
"目的地范围",
|
||||
"交通方式",
|
||||
"单程预估金额",
|
||||
"往返预估金额",
|
||||
"置信度",
|
||||
"预算占用口径",
|
||||
"来源说明",
|
||||
],
|
||||
[
|
||||
[1, "武汉", "北京", "高频城市", "火车", 520, 1040, "基础规则", "往返二等座/硬卧预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[2, "武汉", "北京", "高频城市", "飞机", 700, 1400, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
|
||||
[3, "武汉", "上海", "高频城市", "火车", 360, 720, "基础规则", "往返二等座预估", "参考历史票据样例与 12306 公布票价查询口径"],
|
||||
[4, "武汉", "上海", "高频城市", "飞机", 600, 1200, "基础规则", "往返经济舱预估", "参考高频航线公开往返价格,按申请预算保守占用"],
|
||||
[5, "武汉", "广州", "高频城市", "火车", 470, 940, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[6, "武汉", "广州", "高频城市", "飞机", 650, 1300, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
|
||||
[7, "武汉", "深圳", "高频城市", "火车", 540, 1080, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[8, "武汉", "深圳", "高频城市", "飞机", 700, 1400, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
|
||||
[9, "武汉", "杭州", "高频城市", "火车", 330, 660, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[10, "武汉", "南京", "高频城市", "火车", 260, 520, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[11, "武汉", "成都", "普通城市", "火车", 350, 700, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[12, "武汉", "成都", "普通城市", "飞机", 600, 1200, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
|
||||
[13, "武汉", "西安", "普通城市", "火车", 300, 600, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[14, "武汉", "厦门", "沿海城市", "火车", 450, 900, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[15, "武汉", "厦门", "沿海城市", "飞机", 650, 1300, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和沿海航线公开价格"],
|
||||
[16, "武汉", "三亚", "远途地区", "飞机", 900, 1800, "基础规则", "往返经济舱预估", "参考旅游/远途航线公开价格,申请阶段占用预算用"],
|
||||
[17, "武汉", "乌鲁木齐", "远途地区", "飞机", 1600, 3200, "基础规则", "往返经济舱预估", "远途航线按预算保守占用"],
|
||||
[18, "武汉", "拉萨", "远途地区", "飞机", 1800, 3600, "基础规则", "往返经济舱预估", "远途航线按预算保守占用"],
|
||||
[19, "*", "", "高频城市", "火车", 520, 1040, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
|
||||
[20, "*", "", "高频城市", "飞机", 650, 1300, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
|
||||
[21, "*", "", "沿海城市", "火车", 520, 1040, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
|
||||
[22, "*", "", "沿海城市", "飞机", 700, 1400, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
|
||||
[23, "*", "", "远途地区", "火车", 900, 1800, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
|
||||
[24, "*", "", "远途地区", "飞机", 1600, 3200, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
|
||||
[25, "*", "", "普通城市", "火车", 360, 720, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
|
||||
[26, "*", "", "普通城市", "飞机", 600, 1200, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
|
||||
[27, "*", "", "普通城市", "轮船", 320, 640, "兜底", "往返二等舱预估", "水路交通暂无实时接口时使用"],
|
||||
],
|
||||
column_widths=[8, 14, 18, 16, 12, 16, 16, 12, 24, 42],
|
||||
)
|
||||
|
||||
|
||||
def build_xlsx_bytes_from_source_sheet(source_path: Path, sheet_name: str) -> bytes:
|
||||
source_workbook = load_workbook(source_path, read_only=False, data_only=False)
|
||||
try:
|
||||
if sheet_name not in source_workbook.sheetnames:
|
||||
raise ValueError("原始规则表中没有对应工作表。")
|
||||
|
||||
source_sheet = source_workbook[sheet_name]
|
||||
target_workbook = Workbook()
|
||||
target_sheet = target_workbook.active
|
||||
target_sheet.title = sheet_name
|
||||
_copy_worksheet(source_sheet, target_sheet)
|
||||
_clarify_travel_source_sheet_headers(sheet_name, target_sheet)
|
||||
_remove_redundant_title_row(target_sheet, sheet_name)
|
||||
target_sheet.sheet_view.zoomScale = 120
|
||||
target_sheet.sheet_view.zoomScaleNormal = 120
|
||||
|
||||
workbook_buffer = BytesIO()
|
||||
target_workbook.save(workbook_buffer)
|
||||
target_workbook.close()
|
||||
return workbook_buffer.getvalue()
|
||||
finally:
|
||||
source_workbook.close()
|
||||
|
||||
|
||||
def build_styled_workbook(
|
||||
sheet_name: str,
|
||||
headers: list[str],
|
||||
rows: list[list[object]],
|
||||
*,
|
||||
column_widths: list[int],
|
||||
) -> bytes:
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.title = sheet_name
|
||||
|
||||
header_fill = PatternFill(fill_type="solid", fgColor="FFD9EAF7")
|
||||
thin_side = Side(style="thin", color="FF7F9DB9")
|
||||
table_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
|
||||
for column_index, header in enumerate(headers, start=1):
|
||||
cell = worksheet.cell(row=1, column=column_index, value=header)
|
||||
cell.font = Font(name="Microsoft YaHei", size=12, bold=True, color="FF0F172A")
|
||||
cell.fill = header_fill
|
||||
cell.border = table_border
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
worksheet.row_dimensions[1].height = 30
|
||||
|
||||
for row_index, row in enumerate(rows, start=2):
|
||||
for column_index, value in enumerate(row, start=1):
|
||||
cell = worksheet.cell(row=row_index, column=column_index, value=value)
|
||||
cell.font = Font(name="Microsoft YaHei", size=11, color="FF0F172A")
|
||||
cell.border = table_border
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
worksheet.row_dimensions[row_index].height = 30
|
||||
|
||||
for column_index, width in enumerate(column_widths, start=1):
|
||||
worksheet.column_dimensions[_column_letter(column_index)].width = width
|
||||
|
||||
worksheet.freeze_panes = "A2"
|
||||
worksheet.sheet_view.zoomScale = 120
|
||||
worksheet.sheet_view.zoomScaleNormal = 120
|
||||
|
||||
workbook_buffer = BytesIO()
|
||||
workbook.save(workbook_buffer)
|
||||
workbook.close()
|
||||
return workbook_buffer.getvalue()
|
||||
|
||||
|
||||
def _extract_lodging_rows(source_rows: list[tuple[Any, ...]]) -> list[list[object]]:
|
||||
header_index = -1
|
||||
indexes: dict[str, int] = {}
|
||||
expected_headers = {
|
||||
"seq": "序号",
|
||||
"region": "地区",
|
||||
"city": "地区(城市)",
|
||||
"peak_period": "旺季期间",
|
||||
"p7": "公司级管理人员、高层经理(P7及以上)",
|
||||
"p4": "中层经理、基层经理(P4-P6、外聘专家)",
|
||||
"p1": "其他员工",
|
||||
"regular_limit": "超标限额",
|
||||
"peak_limit": "旺季超标限额",
|
||||
}
|
||||
for row_index, row in enumerate(source_rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "地区(城市)" not in values:
|
||||
continue
|
||||
for key, label in expected_headers.items():
|
||||
if label in values:
|
||||
indexes[key] = values.index(label)
|
||||
header_index = row_index
|
||||
break
|
||||
|
||||
if header_index < 0 or "city" not in indexes:
|
||||
return []
|
||||
|
||||
rows: list[list[object]] = []
|
||||
for row in source_rows[header_index + 1 :]:
|
||||
region = _row_value(row, indexes.get("region", -1))
|
||||
raw_city = _row_value(row, indexes.get("city", -1))
|
||||
cities = _split_location_names(raw_city)
|
||||
if not cities:
|
||||
continue
|
||||
period_by_city, shared_period = _parse_peak_periods(
|
||||
_row_value(row, indexes.get("peak_period", -1))
|
||||
)
|
||||
for city in cities:
|
||||
period = period_by_city.get(_normalize_period_key(city), shared_period)
|
||||
rows.append(
|
||||
[
|
||||
_row_value(row, indexes.get("seq", -1)),
|
||||
region,
|
||||
city,
|
||||
period,
|
||||
_row_value(row, indexes.get("p7", -1)),
|
||||
_row_value(row, indexes.get("p4", -1)),
|
||||
_row_value(row, indexes.get("p1", -1)),
|
||||
_row_value(row, indexes.get("regular_limit", -1)),
|
||||
_row_value(row, indexes.get("peak_limit", -1)) if period else "",
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _fallback_lodging_rows(fallback_rows: list[list[object]]) -> list[list[object]]:
|
||||
rows: list[list[object]] = []
|
||||
for index, row in enumerate(fallback_rows[1:], start=1):
|
||||
if len(row) >= 11:
|
||||
junior_amount = row[5]
|
||||
manager_amount = row[8]
|
||||
executive_amount = row[10]
|
||||
else:
|
||||
junior_amount = row[2] if len(row) > 2 else ""
|
||||
manager_amount = row[3] if len(row) > 3 else ""
|
||||
executive_amount = row[4] if len(row) > 4 else ""
|
||||
rows.append(
|
||||
[
|
||||
index,
|
||||
"",
|
||||
row[0] if len(row) > 0 else "",
|
||||
"",
|
||||
executive_amount,
|
||||
manager_amount,
|
||||
junior_amount,
|
||||
executive_amount,
|
||||
"",
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _expand_lodging_grade_amounts(row: list[object]) -> list[object]:
|
||||
executive_amount = row[4] if len(row) > 4 else ""
|
||||
manager_amount = row[5] if len(row) > 5 else ""
|
||||
junior_amount = row[6] if len(row) > 6 else ""
|
||||
return [
|
||||
junior_amount,
|
||||
junior_amount,
|
||||
junior_amount,
|
||||
junior_amount,
|
||||
manager_amount,
|
||||
manager_amount,
|
||||
manager_amount,
|
||||
executive_amount,
|
||||
executive_amount,
|
||||
]
|
||||
|
||||
|
||||
def _grade_usage_note(grade: str) -> str:
|
||||
if grade == "P8":
|
||||
return "最高职级,适用于董事会"
|
||||
if grade in {"P6", "P7"}:
|
||||
return "适用于中高层管理人员"
|
||||
if grade in {"P4", "P5"}:
|
||||
return "适用于主管及基层管理人员"
|
||||
return "适用于员工序列"
|
||||
|
||||
|
||||
def _split_location_names(value: object) -> list[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
text = re.sub(r"[((].*?[))]", "", text)
|
||||
text = re.sub(r"^\s*\d+\s*个中心城区[、,,]?", "", text)
|
||||
text = re.sub(r"[;;,,/]+", "、", text)
|
||||
names: list[str] = []
|
||||
for part in text.split("、"):
|
||||
cleaned = _normalize_location_name(part)
|
||||
if not cleaned or cleaned == "中心城区":
|
||||
continue
|
||||
names.append(cleaned)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _parse_peak_periods(value: object) -> tuple[dict[str, str], str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ({}, "")
|
||||
period_by_city: dict[str, str] = {}
|
||||
for part in re.split(r"[;;]", text):
|
||||
if ":" not in part and ":" not in part:
|
||||
continue
|
||||
city, period = re.split(r"[::]", part, maxsplit=1)
|
||||
normalized_city = _normalize_period_key(city)
|
||||
normalized_period = _normalize_peak_period(period)
|
||||
if normalized_city and normalized_period:
|
||||
period_by_city[normalized_city] = normalized_period
|
||||
if period_by_city:
|
||||
return (period_by_city, "")
|
||||
return ({}, _normalize_peak_period(text))
|
||||
|
||||
|
||||
def _normalize_peak_period(value: object) -> str:
|
||||
text = str(value or "").strip()
|
||||
text = re.sub(r"\s+", "", text)
|
||||
text = re.sub(r"(月|上旬|中旬|下旬)", "", text)
|
||||
text = re.sub(r"[、,;;]+", ",", text)
|
||||
text = re.sub(r"[^0-9,\-]", "", text)
|
||||
text = re.sub(r",{2,}", ",", text).strip(",")
|
||||
return text
|
||||
|
||||
|
||||
def _normalize_period_key(value: object) -> str:
|
||||
return _normalize_location_name(value).removesuffix("市")
|
||||
|
||||
|
||||
def _normalize_location_name(value: object) -> str:
|
||||
text = str(value or "").strip()
|
||||
text = re.sub(r"\s+", "", text)
|
||||
text = text.removesuffix("市")
|
||||
if text != "其他地区":
|
||||
text = text.removesuffix("地区")
|
||||
return text
|
||||
|
||||
|
||||
def _row_value(row: tuple[Any, ...], index: int) -> object:
|
||||
if index < 0 or index >= len(row):
|
||||
return ""
|
||||
return "" if row[index] is None else row[index]
|
||||
|
||||
|
||||
def _copy_worksheet(source_sheet, target_sheet) -> None:
|
||||
target_sheet.freeze_panes = source_sheet.freeze_panes
|
||||
target_sheet.sheet_format = copy(source_sheet.sheet_format)
|
||||
target_sheet.sheet_properties = copy(source_sheet.sheet_properties)
|
||||
target_sheet.page_margins = copy(source_sheet.page_margins)
|
||||
target_sheet.page_setup = copy(source_sheet.page_setup)
|
||||
target_sheet.print_options = copy(source_sheet.print_options)
|
||||
|
||||
for row in source_sheet.iter_rows():
|
||||
for source_cell in row:
|
||||
target_cell = target_sheet[source_cell.coordinate]
|
||||
target_cell.value = source_cell.value
|
||||
if source_cell.has_style:
|
||||
target_cell.font = copy(source_cell.font)
|
||||
target_cell.fill = copy(source_cell.fill)
|
||||
target_cell.border = copy(source_cell.border)
|
||||
target_cell.alignment = copy(source_cell.alignment)
|
||||
target_cell.protection = copy(source_cell.protection)
|
||||
target_cell.number_format = source_cell.number_format
|
||||
if source_cell.hyperlink:
|
||||
target_cell._hyperlink = copy(source_cell.hyperlink)
|
||||
if source_cell.comment:
|
||||
target_cell.comment = copy(source_cell.comment)
|
||||
|
||||
for merged_range in source_sheet.merged_cells.ranges:
|
||||
target_sheet.merge_cells(str(merged_range))
|
||||
|
||||
for key, source_dimension in source_sheet.column_dimensions.items():
|
||||
target_dimension = target_sheet.column_dimensions[key]
|
||||
target_dimension.width = source_dimension.width
|
||||
target_dimension.hidden = source_dimension.hidden
|
||||
target_dimension.bestFit = source_dimension.bestFit
|
||||
target_dimension.outlineLevel = source_dimension.outlineLevel
|
||||
target_dimension.collapsed = source_dimension.collapsed
|
||||
|
||||
for index, source_dimension in source_sheet.row_dimensions.items():
|
||||
target_dimension = target_sheet.row_dimensions[index]
|
||||
target_dimension.height = source_dimension.height
|
||||
target_dimension.hidden = source_dimension.hidden
|
||||
target_dimension.outlineLevel = source_dimension.outlineLevel
|
||||
target_dimension.collapsed = source_dimension.collapsed
|
||||
|
||||
|
||||
def _clarify_travel_source_sheet_headers(sheet_name: str, worksheet) -> None:
|
||||
if sheet_name == "交通工具等级标准":
|
||||
worksheet["A4"] = "P5+"
|
||||
worksheet["A5"] = "P1-P4"
|
||||
worksheet.row_dimensions[4].height = max(worksheet.row_dimensions[4].height or 0, 42)
|
||||
worksheet.row_dimensions[5].height = max(worksheet.row_dimensions[5].height or 0, 42)
|
||||
worksheet.column_dimensions["A"].width = max(worksheet.column_dimensions["A"].width or 0, 18)
|
||||
|
||||
|
||||
def _remove_redundant_title_row(worksheet, title: str) -> None:
|
||||
first_cell_value = str(worksheet["A1"].value or "").strip()
|
||||
if first_cell_value != str(title or "").strip():
|
||||
return
|
||||
|
||||
has_other_first_row_values = any(
|
||||
str(worksheet.cell(row=1, column=column_index).value or "").strip()
|
||||
for column_index in range(2, worksheet.max_column + 1)
|
||||
)
|
||||
if has_other_first_row_values:
|
||||
return
|
||||
|
||||
shifted_merged_ranges: list[tuple[int, int, int, int]] = []
|
||||
for merged_range in list(worksheet.merged_cells.ranges):
|
||||
range_text = str(merged_range)
|
||||
min_col = merged_range.min_col
|
||||
min_row = merged_range.min_row
|
||||
max_col = merged_range.max_col
|
||||
max_row = merged_range.max_row
|
||||
worksheet.unmerge_cells(range_text)
|
||||
if min_row <= 1:
|
||||
continue
|
||||
shifted_merged_ranges.append((min_col, min_row - 1, max_col, max_row - 1))
|
||||
|
||||
old_freeze_panes = worksheet.freeze_panes
|
||||
worksheet.delete_rows(1, 1)
|
||||
for min_col, min_row, max_col, max_row in shifted_merged_ranges:
|
||||
worksheet.merge_cells(
|
||||
start_row=min_row,
|
||||
start_column=min_col,
|
||||
end_row=max_row,
|
||||
end_column=max_col,
|
||||
)
|
||||
worksheet.freeze_panes = _shift_freeze_panes_after_deleted_first_row(old_freeze_panes)
|
||||
|
||||
|
||||
def _shift_freeze_panes_after_deleted_first_row(freeze_panes: object) -> str | None:
|
||||
if not freeze_panes:
|
||||
return None
|
||||
|
||||
coordinate = str(freeze_panes)
|
||||
match = re.fullmatch(r"([A-Z]+)([0-9]+)", coordinate)
|
||||
if not match:
|
||||
return coordinate
|
||||
|
||||
column, row_text = match.groups()
|
||||
row_index = int(row_text)
|
||||
if row_index <= 1:
|
||||
return None
|
||||
return f"{column}{row_index - 1}"
|
||||
|
||||
|
||||
def _column_letter(index: int) -> str:
|
||||
value = max(1, int(index))
|
||||
result = ""
|
||||
while value > 0:
|
||||
value, remainder = divmod(value - 1, 26)
|
||||
result = f"{chr(65 + remainder)}{result}"
|
||||
return result
|
||||
@@ -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)
|
||||
@@ -74,7 +362,7 @@ class AgentAssetService(
|
||||
) -> list[AgentAssetListItem]:
|
||||
self._ensure_ready()
|
||||
if asset_type in {None, "", AgentAssetType.RULE.value}:
|
||||
self.sync_platform_risk_rules_from_library()
|
||||
self.sync_rule_assets_from_libraries()
|
||||
assets = self.repository.list(
|
||||
asset_type=asset_type, status=status, domain=domain, keyword=keyword
|
||||
)
|
||||
@@ -94,7 +382,7 @@ class AgentAssetService(
|
||||
) -> PageResult[AgentAssetListItem]:
|
||||
self._ensure_ready()
|
||||
if asset_type in {None, "", AgentAssetType.RULE.value}:
|
||||
self.sync_platform_risk_rules_from_library()
|
||||
self.sync_rule_assets_from_libraries()
|
||||
assets = self.repository.list(
|
||||
asset_type=asset_type,
|
||||
status=status,
|
||||
@@ -552,298 +840,10 @@ class AgentAssetService(
|
||||
self.db.commit()
|
||||
return manifest_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 sync_rule_assets_from_libraries(self) -> int:
|
||||
foundation = AgentFoundationService(self.db)
|
||||
synced_count = foundation.sync_finance_rule_assets_from_catalog()
|
||||
synced_count += foundation.sync_platform_risk_rules_from_library()
|
||||
self.db.commit()
|
||||
return synced_count
|
||||
|
||||
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
|
||||
|
||||
@@ -19,6 +19,7 @@ STATEFUL_CONTEXT_KEYS = (
|
||||
"ocr_summary",
|
||||
"ocr_documents",
|
||||
"review_form_values",
|
||||
"steward_state",
|
||||
"business_time_context",
|
||||
)
|
||||
REVIEW_FLOW_CONTEXT_KEYS = {
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.core.logging import get_logger
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
AgentAssetSpreadsheetManager,
|
||||
@@ -26,6 +27,8 @@ from app.services.agent_foundation_constants import (
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG,
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
@@ -267,7 +270,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
@@ -293,7 +296,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
@@ -301,6 +304,35 @@ class AgentFoundationAssetSeedMixin:
|
||||
"rule_template_label": "通信费报销 Excel 模板",
|
||||
},
|
||||
)
|
||||
company_preapproval_rule = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
name="公司费用申请审批规则",
|
||||
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宣",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
published_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
working_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "申请规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
},
|
||||
)
|
||||
skill_expense_asset = AgentAsset(
|
||||
asset_type=AgentAssetType.SKILL.value,
|
||||
code="skill.expense.summary_lookup",
|
||||
@@ -468,6 +500,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
*platform_risk_assets,
|
||||
company_travel_rule,
|
||||
company_communication_rule,
|
||||
company_preapproval_rule,
|
||||
skill_expense_asset,
|
||||
skill_ar_asset,
|
||||
invoice_mcp_asset,
|
||||
@@ -495,6 +528,11 @@ class AgentFoundationAssetSeedMixin:
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
|
||||
self._hide_deprecated_finance_rule_assets()
|
||||
|
||||
@@ -581,6 +619,18 @@ class AgentFoundationAssetSeedMixin:
|
||||
change_note="初始化通信费报销 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=company_preapproval_rule.name,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
metadata=company_preapproval_rule_meta,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化费用申请审批 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=skill_expense_asset,
|
||||
version="v1.0.0",
|
||||
@@ -679,7 +729,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
),
|
||||
AgentAssetReview(
|
||||
@@ -687,7 +737,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.agent_asset import AgentAsset
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
AgentAssetSpreadsheetManager,
|
||||
@@ -25,6 +26,8 @@ from app.services.agent_foundation_constants import (
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG,
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
@@ -115,6 +118,10 @@ class AgentFoundationAssetTopUpMixin:
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
|
||||
)
|
||||
|
||||
company_preapproval_rule = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_PREAPPROVAL_RULE_CODE)
|
||||
)
|
||||
|
||||
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
|
||||
|
||||
attachment_rule = self._create_seed_asset(
|
||||
@@ -361,7 +368,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
@@ -384,7 +391,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
@@ -392,6 +399,36 @@ class AgentFoundationAssetTopUpMixin:
|
||||
},
|
||||
)
|
||||
|
||||
if COMPANY_PREAPPROVAL_RULE_CODE not in existing_codes:
|
||||
|
||||
company_preapproval_rule = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
name="公司费用申请审批规则",
|
||||
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宣",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "申请规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
},
|
||||
)
|
||||
|
||||
if company_travel_rule is not None:
|
||||
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
|
||||
if not str(company_travel_rule.current_version or "").strip():
|
||||
@@ -416,7 +453,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
**(company_travel_rule.config_json or {}),
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
@@ -452,7 +489,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@@ -486,7 +523,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
**(company_communication_rule.config_json or {}),
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
@@ -532,7 +569,78 @@ class AgentFoundationAssetTopUpMixin:
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
if company_preapproval_rule is not None:
|
||||
company_preapproval_rule.scenario_json = list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
|
||||
if not str(company_preapproval_rule.current_version or "").strip():
|
||||
company_preapproval_rule.current_version = COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
if not str(company_preapproval_rule.working_version or "").strip():
|
||||
company_preapproval_rule.working_version = company_preapproval_rule.current_version
|
||||
if not str(company_preapproval_rule.published_version or "").strip():
|
||||
company_preapproval_rule.published_version = company_preapproval_rule.current_version
|
||||
if not str(company_preapproval_rule.status or "").strip():
|
||||
company_preapproval_rule.status = AgentAssetStatus.ACTIVE.value
|
||||
|
||||
company_preapproval_rule.description = (
|
||||
"通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。"
|
||||
)
|
||||
company_preapproval_rule.config_json = {
|
||||
**(company_preapproval_rule.config_json or {}),
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "申请规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
}
|
||||
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
company_preapproval_rule,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
|
||||
self._ensure_asset_version(
|
||||
company_preapproval_rule,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=company_preapproval_rule.name,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
metadata=company_preapproval_rule_meta,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化费用申请审批 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
if (
|
||||
str(company_preapproval_rule.current_version or "").strip()
|
||||
== COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
):
|
||||
self._ensure_asset_review(
|
||||
company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
reviewer="顾承宣",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版费用申请审批规则表已确认,可作为申请规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
@@ -82,10 +82,14 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",)
|
||||
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",)
|
||||
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON = ("费用申请",)
|
||||
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
|
||||
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def build_preapproval_rule_workbook_sheets() -> list[tuple[str, list[list[object]]]]:
|
||||
return [
|
||||
(
|
||||
"费用申请审批规则",
|
||||
[
|
||||
[
|
||||
"费用类型代码",
|
||||
"费用类型",
|
||||
"触发条件",
|
||||
"阈值金额",
|
||||
"前置要求",
|
||||
"审批要求",
|
||||
"风险动作",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
"meal/entertainment",
|
||||
"业务招待费",
|
||||
"单次费用金额大于 500 元",
|
||||
500,
|
||||
"必须先提交费用申请单,并说明客户、参与人和招待事由",
|
||||
"申请单需按审批链完成审批后方可报销",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"适配 meal 与 entertainment 两个本体费用类型",
|
||||
],
|
||||
[
|
||||
"office",
|
||||
"办公用品费",
|
||||
"单次或批量采购金额大于 2000 元",
|
||||
2000,
|
||||
"必须先提交办公采购或费用申请单",
|
||||
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"覆盖办公用品、办公耗材、低值易耗品等场景",
|
||||
],
|
||||
[
|
||||
"all",
|
||||
"通用大额费用",
|
||||
"任意费用金额大于 2000 元",
|
||||
2000,
|
||||
"必须进入费用申请和审批流程",
|
||||
"至少完成直属领导审批;按预算和基础规则继续流转",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"差旅、通信等已有专项规则时可同时适用专项规则",
|
||||
],
|
||||
],
|
||||
),
|
||||
(
|
||||
"字段说明",
|
||||
[
|
||||
["字段", "说明"],
|
||||
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
|
||||
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
|
||||
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
|
||||
["审批要求", "说明申请单进入审批链后的最低审批要求"],
|
||||
["风险动作", "说明报销阶段未满足规则时的系统处理"],
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -5,6 +5,10 @@ from pathlib import Path
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.agent_enums import (
|
||||
AgentAssetContentType,
|
||||
AgentAssetDomain,
|
||||
AgentAssetType,
|
||||
AgentReviewStatus,
|
||||
AgentAssetStatus,
|
||||
)
|
||||
from app.core.logging import get_logger
|
||||
@@ -12,19 +16,38 @@ from app.models.agent_asset import AgentAsset
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
AgentAssetSpreadsheetManager,
|
||||
)
|
||||
from app.services.agent_foundation_constants import (
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
)
|
||||
from app.services.finance_rule_catalog import (
|
||||
DEPRECATED_FINANCE_RULE_CODES,
|
||||
DEPRECATED_FINANCE_RULE_REPLACEMENTS,
|
||||
)
|
||||
from app.services.agent_foundation_preapproval_spreadsheet import (
|
||||
build_preapproval_rule_workbook_sheets,
|
||||
)
|
||||
|
||||
logger = get_logger("app.services.agent_foundation")
|
||||
|
||||
@@ -41,17 +64,131 @@ class AgentFoundationSpreadsheetMixin:
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
name="差旅住宿报销标准",
|
||||
description="按地区和职级维护差旅住宿费报销上限。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="差旅住宿费标准",
|
||||
expense_types=["travel", "hotel", "transport"],
|
||||
expense_types=["hotel"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_lodging_rule_template(),
|
||||
rule_template_label="差旅住宿 Excel 模板",
|
||||
travel_policy_component="lodging",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
||||
name="出差补助报销标准",
|
||||
description="按地区维护伙食补助、基本出差补贴和补助合计。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="出差补助标准",
|
||||
expense_types=["travel"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_allowance_rule_template(),
|
||||
rule_template_label="出差补助 Excel 模板",
|
||||
travel_policy_component="allowance",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
|
||||
name="交通工具等级标准",
|
||||
description="按员工职级维护飞机、火车等长途交通工具等级。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="交通工具等级标准",
|
||||
expense_types=["travel", "transport"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_rule_template(),
|
||||
rule_template_label="交通工具等级 Excel 模板",
|
||||
travel_policy_component="transport",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
name="交通费用预估表",
|
||||
description="按出发城市、目的地和交通方式维护申请阶段预算占用的交通费用预估金额。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="交通费用预估表",
|
||||
expense_types=["travel", "transport"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_estimate_rule_template(),
|
||||
rule_template_label="交通费用预估 Excel 模板",
|
||||
travel_policy_component="transport_estimate",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
|
||||
name="差旅职级映射表",
|
||||
description="明确 P0-P8 九级职级与住宿、交通规则列之间的对应关系,其中 P8 为董事会。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="差旅职级映射表",
|
||||
expense_types=["hotel", "travel", "transport"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_grade_mapping_template(),
|
||||
rule_template_label="差旅职级映射 Excel 模板",
|
||||
travel_policy_component="grade_mapping",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
name="地区淡旺季映射表",
|
||||
description="明确住宿标准中旺季地区、旺季月份和旺季超标限额的对应关系。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="地区淡旺季映射表",
|
||||
expense_types=["hotel", "travel"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_season_mapping_template(),
|
||||
rule_template_label="地区淡旺季映射 Excel 模板",
|
||||
travel_policy_component="season_mapping",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
name="公司通信费报销规则",
|
||||
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
||||
scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="通信费报销标准",
|
||||
expense_types=["communication"],
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_company_communication_rule_template(),
|
||||
rule_template_label="通信费报销 Excel 模板",
|
||||
finance_rule_code="expense.communication.policy",
|
||||
refresh_workbook_content=True,
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
name="公司费用申请审批规则",
|
||||
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
|
||||
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="费用申请审批规则",
|
||||
expense_types=["meal", "entertainment", "office", "all"],
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
reviewer="顾承宣",
|
||||
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
workbook_content=None,
|
||||
rule_template_label="费用申请审批 Excel 模板",
|
||||
finance_rule_code="expense.preapproval.policy",
|
||||
tag="申请规则",
|
||||
)
|
||||
)
|
||||
return synced_count
|
||||
@@ -60,30 +197,183 @@ class AgentFoundationSpreadsheetMixin:
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
name: str,
|
||||
description: str,
|
||||
scenario_category: str,
|
||||
finance_rule_sheet: str,
|
||||
expense_types: list[str],
|
||||
version: str,
|
||||
reviewer: str,
|
||||
file_name: str,
|
||||
workbook_content: bytes | None,
|
||||
rule_template_label: str,
|
||||
finance_rule_code: str | None = None,
|
||||
travel_policy_component: str = "",
|
||||
tag: str = "基础规则",
|
||||
refresh_workbook_content: bool = False,
|
||||
) -> bool:
|
||||
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
|
||||
created_asset = asset is None
|
||||
if asset is None:
|
||||
return False
|
||||
asset = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=code,
|
||||
name=name,
|
||||
description=description,
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=[scenario_category],
|
||||
owner="财务制度管理组",
|
||||
reviewer=reviewer,
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=version,
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": tag,
|
||||
"rule_tag": tag,
|
||||
"tags": [tag],
|
||||
"rule_tags": [tag],
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": scenario_category,
|
||||
"ai_review_category": scenario_category,
|
||||
"finance_rule_code": code,
|
||||
"finance_rule_sheet": finance_rule_sheet,
|
||||
"expense_types": expense_types,
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": rule_template_label,
|
||||
},
|
||||
)
|
||||
else:
|
||||
asset.name = name
|
||||
asset.description = description
|
||||
asset.owner = asset.owner or "财务制度管理组"
|
||||
asset.reviewer = asset.reviewer or reviewer
|
||||
if not str(asset.current_version or "").strip():
|
||||
asset.current_version = version
|
||||
if not str(asset.working_version or "").strip():
|
||||
asset.working_version = asset.current_version
|
||||
if not str(asset.published_version or "").strip():
|
||||
asset.published_version = asset.current_version
|
||||
if not str(asset.status or "").strip() or asset.status == AgentAssetStatus.DISABLED.value:
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
|
||||
asset.scenario_json = [scenario_category]
|
||||
asset.config_json = {
|
||||
config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": tag,
|
||||
"rule_tag": tag,
|
||||
"tags": [tag],
|
||||
"rule_tags": [tag],
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": scenario_category,
|
||||
"ai_review_category": scenario_category,
|
||||
"finance_rule_code": code,
|
||||
"finance_rule_code": finance_rule_code or code,
|
||||
"finance_rule_sheet": finance_rule_sheet,
|
||||
"expense_types": expense_types,
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": rule_template_label,
|
||||
}
|
||||
if travel_policy_component:
|
||||
config_json["travel_policy_component"] = travel_policy_component
|
||||
asset.config_json = config_json
|
||||
rule_document = (asset.config_json or {}).get("rule_document")
|
||||
has_rule_document = isinstance(rule_document, dict) and bool(
|
||||
str(rule_document.get("storage_key") or "").strip()
|
||||
)
|
||||
if workbook_content is not None and (
|
||||
created_asset or not has_rule_document or refresh_workbook_content
|
||||
):
|
||||
self._ensure_finance_rule_asset_document(
|
||||
asset,
|
||||
version=version,
|
||||
reviewer=reviewer,
|
||||
file_name=file_name,
|
||||
content=workbook_content,
|
||||
force_live_document=refresh_workbook_content,
|
||||
)
|
||||
return True
|
||||
|
||||
def _ensure_finance_rule_asset_document(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
*,
|
||||
version: str,
|
||||
reviewer: str,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
force_live_document: bool = False,
|
||||
) -> None:
|
||||
manager = AgentAssetSpreadsheetManager()
|
||||
manager.ensure_rule_library_dirs()
|
||||
rule_document = (asset.config_json or {}).get("rule_document")
|
||||
storage_key = (
|
||||
str(rule_document.get("storage_key") or "").strip()
|
||||
if isinstance(rule_document, dict)
|
||||
else ""
|
||||
)
|
||||
should_seed_file = force_live_document or not storage_key
|
||||
if storage_key:
|
||||
try:
|
||||
current_path = manager.resolve_storage_path(storage_key)
|
||||
except FileNotFoundError:
|
||||
current_path = None
|
||||
should_seed_file = should_seed_file or current_path is None or not current_path.exists()
|
||||
|
||||
if should_seed_file:
|
||||
metadata = manager.store_rule_library_spreadsheet(
|
||||
library=FINANCE_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
content=content,
|
||||
actor_name="系统初始化",
|
||||
source="rule-library",
|
||||
)
|
||||
asset.config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"rule_document": {
|
||||
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||
metadata,
|
||||
asset_version=version,
|
||||
),
|
||||
"storage_key": metadata.storage_key,
|
||||
},
|
||||
}
|
||||
else:
|
||||
metadata = manager.store_rule_library_spreadsheet_snapshot(
|
||||
library=FINANCE_RULES_LIBRARY,
|
||||
asset_id=asset.id,
|
||||
version=version,
|
||||
file_name=file_name,
|
||||
content=content,
|
||||
actor_name="系统初始化",
|
||||
source="rule-library-version",
|
||||
)
|
||||
|
||||
self._ensure_asset_version(
|
||||
asset,
|
||||
version=version,
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=asset.name,
|
||||
version=version,
|
||||
metadata=metadata,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note=f"初始化{asset.name} Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
self._ensure_asset_review(
|
||||
asset,
|
||||
version=version,
|
||||
reviewer=reviewer,
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=None,
|
||||
)
|
||||
|
||||
def _hide_deprecated_finance_rule_assets(self) -> None:
|
||||
for code in DEPRECATED_FINANCE_RULE_CODES:
|
||||
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
|
||||
@@ -92,14 +382,19 @@ class AgentFoundationSpreadsheetMixin:
|
||||
asset.status = AgentAssetStatus.DISABLED.value
|
||||
asset.scenario_json = ["已废弃"]
|
||||
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
|
||||
deprecated_reason = (
|
||||
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
|
||||
if replacement
|
||||
else (
|
||||
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
|
||||
"不再作为独立财务规则表展示。"
|
||||
if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||
deprecated_reason = (
|
||||
"交通/住宿细分并入公司差旅费报销规则,不再作为独立基础规则展示。"
|
||||
)
|
||||
elif replacement == COMPANY_PREAPPROVAL_RULE_CODE:
|
||||
deprecated_reason = (
|
||||
"申请审批阈值已并入公司费用申请审批规则,不再作为独立基础规则展示。"
|
||||
)
|
||||
else:
|
||||
deprecated_reason = (
|
||||
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
|
||||
"不再作为独立基础规则表展示。"
|
||||
)
|
||||
)
|
||||
asset.config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"enabled": False,
|
||||
@@ -180,7 +475,10 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
"detail_mode": "spreadsheet",
|
||||
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"rule_tag": "基础规则",
|
||||
"tags": ["基础规则"],
|
||||
"rule_tags": ["基础规则"],
|
||||
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
|
||||
@@ -208,7 +506,10 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
"detail_mode": "spreadsheet",
|
||||
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"rule_tag": "基础规则",
|
||||
"tags": ["基础规则"],
|
||||
"rule_tags": ["基础规则"],
|
||||
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
|
||||
@@ -258,6 +559,37 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
)
|
||||
|
||||
def _ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
|
||||
self,
|
||||
|
||||
asset: AgentAsset,
|
||||
|
||||
*,
|
||||
|
||||
version: str,
|
||||
|
||||
actor_name: str,
|
||||
|
||||
):
|
||||
|
||||
return self._ensure_finance_rule_spreadsheet_seed(
|
||||
|
||||
asset,
|
||||
|
||||
version=version,
|
||||
|
||||
actor_name=actor_name,
|
||||
|
||||
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
|
||||
fallback_sheet_name="费用申请审批规则",
|
||||
tag="申请规则",
|
||||
|
||||
workbook_sheets=build_preapproval_rule_workbook_sheets(),
|
||||
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
def _read_or_build_company_travel_rule_file(
|
||||
@@ -282,7 +614,7 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
return live_path.read_bytes()
|
||||
|
||||
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
|
||||
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
|
||||
|
||||
def _ensure_finance_rule_spreadsheet_seed(
|
||||
|
||||
@@ -301,6 +633,7 @@ class AgentFoundationSpreadsheetMixin:
|
||||
fallback_sheet_name: str,
|
||||
|
||||
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
|
||||
tag: str = "基础规则",
|
||||
|
||||
):
|
||||
|
||||
@@ -370,7 +703,10 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
"detail_mode": "spreadsheet",
|
||||
|
||||
"tag": "财务规则",
|
||||
"tag": tag,
|
||||
"rule_tag": tag,
|
||||
"tags": [tag],
|
||||
"rule_tags": [tag],
|
||||
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
|
||||
@@ -398,7 +734,10 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
"detail_mode": "spreadsheet",
|
||||
|
||||
"tag": "财务规则",
|
||||
"tag": tag,
|
||||
"rule_tag": tag,
|
||||
"tags": [tag],
|
||||
"rule_tags": [tag],
|
||||
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -24,6 +25,34 @@ logger = get_logger("app.services.agent_runs")
|
||||
|
||||
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
|
||||
KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"}
|
||||
LIST_ROUTE_FIELDS = (
|
||||
("route_job_type", "job_type"),
|
||||
("route_task_type", "task_type"),
|
||||
("route_task_code", "task_code"),
|
||||
("route_task_name", "task_name"),
|
||||
("route_task_title", "task_title"),
|
||||
("route_asset_name", "asset_name"),
|
||||
("route_selected_agent", "selected_agent"),
|
||||
("route_phase", "phase"),
|
||||
("route_stage", "stage"),
|
||||
("route_report_type", "report_type"),
|
||||
("route_snapshot_key", "snapshot_key"),
|
||||
("route_folder", "folder"),
|
||||
("route_heartbeat_at", "heartbeat_at"),
|
||||
)
|
||||
LIST_ONTOLOGY_FIELDS = (
|
||||
("ontology_scenario", "scenario"),
|
||||
("ontology_intent", "intent"),
|
||||
("ontology_parse_strategy", "parse_strategy"),
|
||||
)
|
||||
LIST_PROGRESS_FIELDS = {
|
||||
"percent",
|
||||
"total_documents",
|
||||
"completed_documents",
|
||||
"failed_documents",
|
||||
"skipped_documents",
|
||||
"current_stage",
|
||||
}
|
||||
|
||||
|
||||
class AgentRunService:
|
||||
@@ -41,8 +70,22 @@ class AgentRunService:
|
||||
) -> list[AgentRunRead]:
|
||||
self._ensure_ready()
|
||||
self._reconcile_stale_knowledge_index_runs()
|
||||
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
|
||||
return [self._serialize_run(item) for item in runs]
|
||||
rows = self.repository.list_light(
|
||||
agent=agent,
|
||||
status=status,
|
||||
source=source,
|
||||
limit=limit,
|
||||
)
|
||||
tool_calls_by_run_id = self._group_light_tool_calls(
|
||||
self.repository.list_light_tool_calls([str(item["run_id"]) for item in rows])
|
||||
)
|
||||
return [
|
||||
self._serialize_run_list_item(
|
||||
item,
|
||||
tool_calls_by_run_id.get(str(item["run_id"]), []),
|
||||
)
|
||||
for item in rows
|
||||
]
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRunRead | None:
|
||||
self._ensure_ready()
|
||||
@@ -435,3 +478,99 @@ class AgentRunService:
|
||||
if semantic_parse
|
||||
else None,
|
||||
)
|
||||
|
||||
def _serialize_run_list_item(
|
||||
self,
|
||||
row: dict[str, Any],
|
||||
tool_calls: list[dict[str, Any]],
|
||||
) -> AgentRunRead:
|
||||
return AgentRunRead(
|
||||
id=str(row["id"]),
|
||||
run_id=str(row["run_id"]),
|
||||
agent=str(row["agent"]),
|
||||
source=str(row["source"]),
|
||||
user_id=row.get("user_id"),
|
||||
task_id=row.get("task_id"),
|
||||
ontology_json=self._build_list_ontology_json(row),
|
||||
route_json=self._build_list_route_json(row),
|
||||
permission_level=str(row["permission_level"]),
|
||||
status=str(row["status"]),
|
||||
result_summary=row.get("result_summary"),
|
||||
error_message=row.get("error_message"),
|
||||
started_at=row["started_at"],
|
||||
finished_at=row.get("finished_at"),
|
||||
tool_calls=[self._serialize_light_tool_call(item) for item in tool_calls],
|
||||
semantic_parse=None,
|
||||
)
|
||||
|
||||
def _build_list_route_json(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for source_key, target_key in LIST_ROUTE_FIELDS:
|
||||
self._set_if_present(payload, target_key, row.get(source_key))
|
||||
|
||||
progress = self._coerce_json_object(row.get("route_progress"))
|
||||
compact_progress = {
|
||||
key: value
|
||||
for key, value in progress.items()
|
||||
if key in LIST_PROGRESS_FIELDS and self._is_scalar_json_value(value)
|
||||
}
|
||||
if compact_progress:
|
||||
payload["progress"] = compact_progress
|
||||
return payload
|
||||
|
||||
def _build_list_ontology_json(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for source_key, target_key in LIST_ONTOLOGY_FIELDS:
|
||||
self._set_if_present(payload, target_key, row.get(source_key))
|
||||
return payload
|
||||
|
||||
def _serialize_light_tool_call(self, row: dict[str, Any]) -> AgentToolCallRead:
|
||||
return AgentToolCallRead(
|
||||
id=str(row["id"]),
|
||||
run_id=str(row["run_id"]),
|
||||
tool_type=str(row["tool_type"]),
|
||||
tool_name=str(row["tool_name"]),
|
||||
request_json={},
|
||||
response_json={},
|
||||
status=str(row["status"]),
|
||||
duration_ms=int(row.get("duration_ms") or 0),
|
||||
error_message=row.get("error_message"),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _group_light_tool_calls(
|
||||
tool_calls: list[dict[str, Any]],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for tool_call in tool_calls:
|
||||
grouped.setdefault(str(tool_call.get("run_id") or ""), []).append(tool_call)
|
||||
return grouped
|
||||
|
||||
@staticmethod
|
||||
def _coerce_json_object(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if normalized.startswith("{") and normalized.endswith("}"):
|
||||
try:
|
||||
loaded = json.loads(normalized)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _set_if_present(payload: dict[str, Any], key: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return
|
||||
if not AgentRunService._is_scalar_json_value(value):
|
||||
return
|
||||
payload[key] = value
|
||||
|
||||
@staticmethod
|
||||
def _is_scalar_json_value(value: Any) -> bool:
|
||||
return value is None or isinstance(value, str | int | float | bool)
|
||||
|
||||
141
server/src/app/services/application_fact_resolver.py
Normal file
141
server/src/app/services/application_fact_resolver.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
CITY_NAMES = (
|
||||
"北京",
|
||||
"上海",
|
||||
"广州",
|
||||
"深圳",
|
||||
"杭州",
|
||||
"南京",
|
||||
"苏州",
|
||||
"成都",
|
||||
"重庆",
|
||||
"天津",
|
||||
"武汉",
|
||||
"西安",
|
||||
"长沙",
|
||||
"郑州",
|
||||
"青岛",
|
||||
"厦门",
|
||||
"福州",
|
||||
"合肥",
|
||||
"济南",
|
||||
"沈阳",
|
||||
"大连",
|
||||
"宁波",
|
||||
"无锡",
|
||||
)
|
||||
|
||||
MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?")
|
||||
ISO_DATE_PATTERN = re.compile(
|
||||
r"(?P<year>\d{4})[-/年](?P<month>\d{1,2})[-/月](?P<day>\d{1,2})(?:日)?"
|
||||
)
|
||||
|
||||
|
||||
class ApplicationFactResolver:
|
||||
@staticmethod
|
||||
def infer_expense_type(segment: str, task_type: str) -> str:
|
||||
compact = re.sub(r"\s+", "", segment)
|
||||
if re.search(r"招待|接待|餐饮|宴请|客户吃饭|业务餐", compact):
|
||||
return "entertainment"
|
||||
if re.search(r"出差|差旅|住宿|酒店|机票|航班|高铁|火车", compact):
|
||||
return "travel"
|
||||
if re.search(r"交通|出租车|的士|网约车|打车|地铁|公交", compact):
|
||||
return "transport" if task_type == "reimbursement" else "travel"
|
||||
return "travel" if task_type == "expense_application" else "other"
|
||||
|
||||
@staticmethod
|
||||
def extract_time_range(segment: str, base_date: date) -> str:
|
||||
compact = re.sub(r"\s+", "", segment)
|
||||
if "昨天" in compact:
|
||||
return (base_date - timedelta(days=1)).isoformat()
|
||||
if "前天" in compact:
|
||||
return (base_date - timedelta(days=2)).isoformat()
|
||||
if "明天" in compact:
|
||||
return (base_date + timedelta(days=1)).isoformat()
|
||||
if "后天" in compact:
|
||||
return (base_date + timedelta(days=2)).isoformat()
|
||||
|
||||
iso_match = ISO_DATE_PATTERN.search(compact)
|
||||
if iso_match:
|
||||
return ApplicationFactResolver.safe_date(
|
||||
int(iso_match.group("year")),
|
||||
int(iso_match.group("month")),
|
||||
int(iso_match.group("day")),
|
||||
)
|
||||
|
||||
month_day = MONTH_DAY_PATTERN.search(compact)
|
||||
if month_day:
|
||||
return ApplicationFactResolver.safe_date(
|
||||
base_date.year,
|
||||
int(month_day.group("month")),
|
||||
int(month_day.group("day")),
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def safe_date(year: int, month: int, day: int) -> str:
|
||||
try:
|
||||
return date(year, month, day).isoformat()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def extract_location(segment: str) -> str:
|
||||
compact = re.sub(r"\s+", "", segment)
|
||||
for prefix in ("去", "到", "赴", "前往"):
|
||||
match = re.search(fr"{prefix}({'|'.join(CITY_NAMES)})", compact)
|
||||
if match:
|
||||
return match.group(1)
|
||||
for city in CITY_NAMES:
|
||||
if city in compact:
|
||||
return city
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def extract_reason(segment: str, task_type: str) -> str:
|
||||
cleaned = re.sub(r"\s+", "", segment).strip(",,。;; ")
|
||||
if task_type == "expense_application":
|
||||
match = re.search(r"(辅助|支持|协助|支撑|参加|拜访|调研|实施|部署|审核).+", cleaned)
|
||||
if match:
|
||||
return strip_trailing_connectors(match.group(0))
|
||||
reason = re.sub(r"^.*?(?:出差|差旅)", "", cleaned).strip(",,。;;的费用")
|
||||
return strip_trailing_connectors(reason) or cleaned
|
||||
cleaned = re.sub(r"^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销", "", cleaned)
|
||||
if not cleaned or cleaned in {"费用", "报销单", "报销流程"}:
|
||||
return ""
|
||||
cleaned = re.sub(r"^(?:昨天|前天|明天|后天|\d{1,2}月\d{1,2}(?:日|号)?)的?", "", cleaned)
|
||||
return cleaned.strip(",,。;; ")
|
||||
|
||||
@staticmethod
|
||||
def extract_transport_mode(segment: str) -> str:
|
||||
compact = re.sub(r"\s+", "", segment)
|
||||
if re.search(r"高铁|动车|火车", compact):
|
||||
return "train"
|
||||
if re.search(r"飞机|机票|航班", compact):
|
||||
return "flight"
|
||||
if re.search(r"出租车|的士|网约车|打车", compact):
|
||||
return "taxi"
|
||||
if "交通" in compact:
|
||||
return "other"
|
||||
return ""
|
||||
|
||||
|
||||
def strip_trailing_connectors(value: str) -> str:
|
||||
cleaned = str(value or "").strip(",,。;; ")
|
||||
return re.sub(r"(?:并且|而且|同时|另外|还需要|需要)$", "", cleaned).strip(",,。;; ")
|
||||
|
||||
|
||||
def resolve_application_facts(segment: str, task_type: str, base_date: date) -> dict[str, str]:
|
||||
fields = {
|
||||
"expense_type": ApplicationFactResolver.infer_expense_type(segment, task_type),
|
||||
"time_range": ApplicationFactResolver.extract_time_range(segment, base_date),
|
||||
"location": ApplicationFactResolver.extract_location(segment),
|
||||
"reason": ApplicationFactResolver.extract_reason(segment, task_type),
|
||||
"transport_mode": ApplicationFactResolver.extract_transport_mode(segment),
|
||||
}
|
||||
return {key: value for key, value in fields.items() if value}
|
||||
@@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -127,8 +128,15 @@ class AuthService:
|
||||
if not self.settings.setup_completed:
|
||||
return None
|
||||
|
||||
EmployeeService(self.db).ensure_directory_ready()
|
||||
employee = self._find_employee_by_email(identifier)
|
||||
try:
|
||||
employee = self._find_employee_by_email(identifier)
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
employee = None
|
||||
|
||||
if employee is None:
|
||||
EmployeeService(self.db).ensure_directory_ready()
|
||||
employee = self._find_employee_by_email(identifier)
|
||||
|
||||
if employee is None or not employee.password_hash:
|
||||
return None
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.services.budget_types import (
|
||||
SUBJECT_CODE_ALIASES,
|
||||
SUPPORTED_BUDGET_SUBJECT_CODES,
|
||||
)
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
@@ -349,7 +350,11 @@ class BudgetSupportMixin:
|
||||
def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str:
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
if claim_no.startswith(("AP-", "APP-")) or expense_type == "application" or expense_type.endswith("_application"):
|
||||
if (
|
||||
is_application_claim_no(claim_no)
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
):
|
||||
return "application"
|
||||
return "claim"
|
||||
|
||||
|
||||
@@ -25,11 +25,15 @@ AMOUNT_PATTERNS = (
|
||||
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
|
||||
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
|
||||
)
|
||||
DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
|
||||
DATE_PATTERN = re.compile(
|
||||
r"((?:20\d{2}|19\d{2})(?:[-/年.]|\s+)(?:1[0-2]|0?[1-9])"
|
||||
r"(?:[-/月.]|\s+)(?:3[01]|[12]\d|0?[1-9])日?)"
|
||||
)
|
||||
TIME_PATTERN = re.compile(r"(?<!\d)([01]?\d|2[0-3])[::]([0-5]\d)(?!\d)")
|
||||
INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[::\s]*([A-Za-z0-9-]{6,24})")
|
||||
INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[::\s]*([A-Za-z0-9-]{6,24})")
|
||||
TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[::\s]*([A-Za-z0-9]{2,12})")
|
||||
TRAIN_STANDALONE_NO_PATTERN = re.compile(r"(?<![A-Za-z0-9])([GCDZKTLYS]\d{1,5})(?![A-Za-z0-9])", re.IGNORECASE)
|
||||
ROUTE_PATTERN = re.compile(r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-)\s*([\u4e00-\u9fa5]{2,12})")
|
||||
MERCHANT_PATTERNS = (
|
||||
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[::\s]*([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40})"),
|
||||
@@ -300,6 +304,14 @@ def _match_document_rule(compact_text: str) -> RuleMatch:
|
||||
best_score = score
|
||||
|
||||
if best_score <= 0:
|
||||
train_rule = DOCUMENT_TYPE_RULE_MAP.get("train_ticket")
|
||||
if train_rule and _looks_like_train_ticket(compact_text):
|
||||
return RuleMatch(
|
||||
rule=train_rule,
|
||||
confidence=0.82,
|
||||
evidence=("车次", "12306"),
|
||||
score=3.8,
|
||||
)
|
||||
return RuleMatch(rule=None, confidence=0.0, evidence=(), score=0.0)
|
||||
|
||||
confidence = min(0.94, 0.30 + min(best_score, 4.8) * 0.12)
|
||||
@@ -311,6 +323,17 @@ def _match_document_rule(compact_text: str) -> RuleMatch:
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_train_ticket(compact_text: str) -> bool:
|
||||
text = str(compact_text or "").lower()
|
||||
if not re.search(r"[gcdzktlys]\d{1,5}", text, flags=re.IGNORECASE):
|
||||
return False
|
||||
if "12306" in text or "95306" in text:
|
||||
return True
|
||||
if re.search(r"[\u4e00-\u9fa5]{2,12}(?:至|到|→|->|—|–|-)[\u4e00-\u9fa5]{2,12}", text):
|
||||
return True
|
||||
return "wuhan" in text and "shanghai" in text
|
||||
|
||||
|
||||
def _extract_json_payload(response_text: str | None) -> dict[str, Any] | None:
|
||||
if not response_text:
|
||||
return None
|
||||
@@ -521,33 +544,48 @@ def _merge_document_fields(
|
||||
|
||||
def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
|
||||
fields: list[DocumentField] = []
|
||||
normalized_type = str(document_type or "").strip().lower()
|
||||
|
||||
def append_field(key: str, label: str, value: str) -> None:
|
||||
cleaned = _clean_field_value(value)
|
||||
if not cleaned:
|
||||
return
|
||||
if any(field.key == key for field in fields if field.key):
|
||||
return
|
||||
fields.append(DocumentField(key=key, label=label, value=cleaned))
|
||||
|
||||
amount = _extract_amount(text)
|
||||
if amount:
|
||||
fields.append(DocumentField(key="amount", label="金额", value=amount))
|
||||
append_field("amount", "金额", amount)
|
||||
|
||||
date_value = _extract_date(text, document_type=document_type)
|
||||
if date_value:
|
||||
fields.append(DocumentField(key="date", label="日期", value=date_value))
|
||||
append_field("date", "日期", date_value)
|
||||
|
||||
merchant = _extract_merchant(text)
|
||||
if merchant:
|
||||
fields.append(DocumentField(key="merchant_name", label="商户", value=merchant))
|
||||
append_field("merchant_name", "商户", merchant)
|
||||
|
||||
invoice_number = _extract_pattern(INVOICE_NUMBER_PATTERN, text)
|
||||
if invoice_number:
|
||||
fields.append(DocumentField(key="invoice_number", label="票据号码", value=invoice_number))
|
||||
append_field("invoice_number", "票据号码", invoice_number)
|
||||
|
||||
invoice_code = _extract_pattern(INVOICE_CODE_PATTERN, text)
|
||||
if invoice_code:
|
||||
fields.append(DocumentField(key="invoice_code", label="发票代码", value=invoice_code))
|
||||
append_field("invoice_code", "发票代码", invoice_code)
|
||||
|
||||
trip_no = _extract_pattern(TRIP_NO_PATTERN, text)
|
||||
if not trip_no and normalized_type == "train_ticket":
|
||||
trip_no = _extract_pattern(TRAIN_STANDALONE_NO_PATTERN, text)
|
||||
if trip_no:
|
||||
fields.append(DocumentField(key="trip_no", label="车次/航班", value=trip_no))
|
||||
append_field("trip_no", "车次/航班", trip_no.upper())
|
||||
|
||||
route = _extract_route(text)
|
||||
if route:
|
||||
fields.append(DocumentField(key="route", label="行程", value=route))
|
||||
append_field("route", "行程", route)
|
||||
|
||||
if normalized_type == "train_ticket" and not any(field.key == "amount" for field in fields):
|
||||
append_field("amount", "金额", _extract_loose_decimal_amount(text))
|
||||
|
||||
return fields
|
||||
|
||||
@@ -621,6 +659,7 @@ def _format_date_match_with_time(text: str, match: re.Match[str]) -> str:
|
||||
raw_value = str(match.group(1) or "").strip()
|
||||
normalized = raw_value.replace("年", "-").replace("月", "-").replace("日", "")
|
||||
normalized = normalized.replace("/", "-").replace(".", "-")
|
||||
normalized = re.sub(r"\s+", "-", normalized)
|
||||
parts = [part for part in normalized.split("-") if part]
|
||||
if len(parts) != 3:
|
||||
return raw_value
|
||||
@@ -703,6 +742,23 @@ def _extract_route(text: str) -> str:
|
||||
return f"{start}-{end}"
|
||||
|
||||
|
||||
def _extract_loose_decimal_amount(text: str) -> str:
|
||||
best_value: Decimal | None = None
|
||||
for match in re.finditer(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", str(text or "")):
|
||||
try:
|
||||
candidate = Decimal(match.group(1)).quantize(Decimal("0.01"))
|
||||
except InvalidOperation:
|
||||
continue
|
||||
if candidate <= Decimal("0.00"):
|
||||
continue
|
||||
if best_value is None or candidate > best_value:
|
||||
best_value = candidate
|
||||
if best_value is None:
|
||||
return ""
|
||||
text_value = format(best_value, "f").rstrip("0").rstrip(".")
|
||||
return f"{text_value}元"
|
||||
|
||||
|
||||
def _extract_pattern(pattern: re.Pattern[str], text: str) -> str:
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from typing import Callable, Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -13,20 +13,29 @@ from app.models.financial_record import ExpenseClaim
|
||||
DocumentNumberKind = Literal["application", "reimbursement", "audit"]
|
||||
|
||||
DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = {
|
||||
"application": "AP",
|
||||
"reimbursement": "RE",
|
||||
"audit": "AD",
|
||||
"application": "A",
|
||||
"reimbursement": "R",
|
||||
"audit": "D",
|
||||
}
|
||||
DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
DOCUMENT_NUMBER_TOKEN_LENGTH = 8
|
||||
DOCUMENT_NUMBER_SHORT_BODY = (
|
||||
rf"(?:A|R|D)[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
||||
)
|
||||
DOCUMENT_NUMBER_LEGACY_BODY = (
|
||||
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
||||
)
|
||||
DOCUMENT_NUMBER_PATTERN = re.compile(
|
||||
rf"^(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}$",
|
||||
rf"^(?:{DOCUMENT_NUMBER_SHORT_BODY}|{DOCUMENT_NUMBER_LEGACY_BODY})$",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile(
|
||||
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
||||
rf"(?<![A-Z0-9])(?:"
|
||||
rf"{DOCUMENT_NUMBER_SHORT_BODY}"
|
||||
rf"|{DOCUMENT_NUMBER_LEGACY_BODY}"
|
||||
r"|APP-\d{8}-[A-Z0-9]{6}"
|
||||
r"|EXP-\d{6}-\d{3}",
|
||||
r"|EXP-\d{6}-\d{3}"
|
||||
r")(?![A-Z0-9])",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -45,16 +54,13 @@ def build_document_number(
|
||||
token: str | None = None,
|
||||
) -> str:
|
||||
prefix = DOCUMENT_NUMBER_PREFIXES[kind]
|
||||
generated_at = timestamp or datetime.now(UTC)
|
||||
if generated_at.tzinfo is None:
|
||||
generated_at = generated_at.replace(tzinfo=UTC)
|
||||
normalized_token = (token or generate_document_token()).strip().upper()
|
||||
if not re.fullmatch(
|
||||
rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
|
||||
normalized_token,
|
||||
):
|
||||
raise ValueError("document number token must be 8 chars from the configured alphabet")
|
||||
return f"{prefix}-{generated_at.astimezone(UTC):%Y%m%d%H%M%S}-{normalized_token}"
|
||||
return f"{prefix}{normalized_token}"
|
||||
|
||||
|
||||
def generate_unique_expense_claim_no(
|
||||
@@ -83,4 +89,9 @@ def generate_unique_expense_claim_no(
|
||||
|
||||
def is_application_claim_no(value: object) -> bool:
|
||||
normalized = str(value or "").strip().upper()
|
||||
return normalized.startswith(("AP-", "APP-"))
|
||||
return bool(
|
||||
re.fullmatch(
|
||||
rf"A[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
|
||||
normalized,
|
||||
)
|
||||
) or normalized.startswith(("AP-", "APP-"))
|
||||
|
||||
98
server/src/app/services/document_preview.py
Normal file
98
server/src/app/services/document_preview.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import mimetypes
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DocumentPreviewAssets:
|
||||
PDF_RENDERER_ID = "pdftoppm-png-r160-poppler-data"
|
||||
PDF_PREVIEW_MEDIA_TYPE = "image/png"
|
||||
PDF_PREVIEW_SUFFIX = ".png"
|
||||
|
||||
@staticmethod
|
||||
def decode_data_url(payload: str) -> tuple[str, bytes] | None:
|
||||
normalized = str(payload or "").strip()
|
||||
matched = re.match(
|
||||
r"^data:(?P<media>[\w.+-]+/[\w.+-]+);base64,(?P<body>.+)$",
|
||||
normalized,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
if not matched:
|
||||
return None
|
||||
try:
|
||||
content = base64.b64decode(matched.group("body"), validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
return matched.group("media"), content
|
||||
|
||||
@classmethod
|
||||
def renderer_id_for_source(cls, media_type: str | None) -> str:
|
||||
return cls.PDF_RENDERER_ID if str(media_type or "").strip() == "application/pdf" else ""
|
||||
|
||||
@classmethod
|
||||
def write_data_url_preview(
|
||||
cls,
|
||||
*,
|
||||
preview_dir: Path,
|
||||
preview_name_stem: str,
|
||||
preview_data_url: str,
|
||||
) -> tuple[Path, str, str] | None:
|
||||
decoded = cls.decode_data_url(preview_data_url)
|
||||
if decoded is None:
|
||||
return None
|
||||
|
||||
preview_media_type, preview_content = decoded
|
||||
suffix = mimetypes.guess_extension(preview_media_type) or ".bin"
|
||||
preview_name = f"{Path(preview_name_stem).stem}{suffix}"
|
||||
preview_path = preview_dir / preview_name
|
||||
preview_path.write_bytes(preview_content)
|
||||
return preview_path, preview_media_type, preview_name
|
||||
|
||||
@classmethod
|
||||
def render_pdf_first_page(
|
||||
cls,
|
||||
*,
|
||||
pdf_path: Path,
|
||||
preview_path: Path,
|
||||
timeout_seconds: int | float,
|
||||
) -> Path:
|
||||
preview_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".pdf-preview-", dir=str(preview_path.parent)) as temp_dir:
|
||||
prefix = Path(temp_dir) / "page"
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"pdftoppm",
|
||||
"-png",
|
||||
"-r",
|
||||
"160",
|
||||
str(pdf_path),
|
||||
str(prefix),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()
|
||||
raise RuntimeError(detail or "pdftoppm failed to render PDF preview.")
|
||||
|
||||
pages = sorted(Path(temp_dir).glob("page-*.png"), key=cls._extract_pdf_page_sort_key)
|
||||
if not pages:
|
||||
raise RuntimeError("pdftoppm did not generate a preview image.")
|
||||
shutil.copyfile(pages[0], preview_path)
|
||||
return preview_path
|
||||
|
||||
@staticmethod
|
||||
def _extract_pdf_page_sort_key(path: Path) -> tuple[int, str]:
|
||||
suffix = path.stem.rsplit("-", 1)[-1]
|
||||
try:
|
||||
return int(suffix), path.name
|
||||
except ValueError:
|
||||
return 0, path.name
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import UTC, date, datetime
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -81,11 +82,31 @@ def prepare_employee_directory() -> None:
|
||||
|
||||
|
||||
class EmployeeService:
|
||||
_directory_ready_lock = threading.Lock()
|
||||
_directory_ready_keys: set[tuple[str, int]] = set()
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.repository = EmployeeRepository(db)
|
||||
|
||||
@staticmethod
|
||||
def _bind_cache_key(db: Session) -> tuple[str, int]:
|
||||
bind = db.get_bind()
|
||||
return (bind.url.render_as_string(hide_password=True), id(bind.pool))
|
||||
|
||||
def ensure_directory_ready(self) -> None:
|
||||
cache_key = self._bind_cache_key(self.db)
|
||||
if cache_key in self._directory_ready_keys:
|
||||
return
|
||||
|
||||
with self._directory_ready_lock:
|
||||
if cache_key in self._directory_ready_keys:
|
||||
return
|
||||
|
||||
self._ensure_directory_ready_uncached()
|
||||
self._directory_ready_keys.add(cache_key)
|
||||
|
||||
def _ensure_directory_ready_uncached(self) -> None:
|
||||
try:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
ensure_employee_schema(self.db)
|
||||
|
||||
@@ -11,8 +11,9 @@ from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
@@ -28,9 +29,8 @@ APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
BUDGET_APPROVAL_ROLE_CODES = {"budget_monitor", "executive"}
|
||||
BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
|
||||
BUDGET_MONITOR_APPROVAL_GRADE = "P8"
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPLICATION_ARCHIVE_STAGE,)
|
||||
ARCHIVED_REIMBURSEMENT_STAGES = (
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
@@ -43,6 +43,14 @@ class ExpenseClaimAccessPolicy:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _build_application_claim_no_condition(claim_no: Any) -> Any:
|
||||
return or_(
|
||||
claim_no.like("AP-%"),
|
||||
claim_no.like("APP-%"),
|
||||
claim_no.like("A________"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def has_privileged_claim_access(current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
@@ -62,54 +70,54 @@ class ExpenseClaimAccessPolicy:
|
||||
normalized_type = func.lower(func.coalesce(ExpenseClaim.expense_type, ""))
|
||||
claim_no = func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
|
||||
application_condition = or_(
|
||||
claim_no.like("AP-%"),
|
||||
claim_no.like("APP-%"),
|
||||
ExpenseClaimAccessPolicy._build_application_claim_no_condition(claim_no),
|
||||
normalized_type == "application",
|
||||
normalized_type.like("%\\_application", escape="\\"),
|
||||
)
|
||||
return or_(
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
and_(
|
||||
application_condition,
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
stage.in_(APPLICATION_ARCHIVED_STAGES),
|
||||
),
|
||||
and_(
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
or_(
|
||||
stage == "",
|
||||
stage.is_(None),
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
reimbursement_condition = and_(
|
||||
~application_condition,
|
||||
or_(
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
and_(
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
or_(
|
||||
stage == "",
|
||||
stage.is_(None),
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
application_archive_condition = and_(
|
||||
application_condition,
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
stage.in_(APPLICATION_ARCHIVED_STAGES),
|
||||
)
|
||||
return or_(
|
||||
reimbursement_condition,
|
||||
application_archive_condition,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def has_claim_delete_access(current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & CLAIM_DELETE_ROLE_CODES)
|
||||
return bool(current_user.is_admin)
|
||||
|
||||
@staticmethod
|
||||
def is_archived_claim(claim: ExpenseClaim) -> bool:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
|
||||
return True
|
||||
normalized_type = str(claim.expense_type or "").strip().lower()
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
claim_no = str(claim.claim_no or "").strip()
|
||||
is_application_claim = (
|
||||
claim_no.startswith(("AP-", "APP-"))
|
||||
is_application_claim_no(claim_no)
|
||||
or normalized_type == "application"
|
||||
or normalized_type.endswith("_application")
|
||||
)
|
||||
if (
|
||||
is_application_claim
|
||||
and normalized_status in ARCHIVED_CLAIM_STATUSES
|
||||
and stage in APPLICATION_ARCHIVED_STAGES
|
||||
):
|
||||
if is_application_claim:
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in APPLICATION_ARCHIVED_STAGES
|
||||
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
|
||||
return True
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES}
|
||||
|
||||
@@ -247,6 +255,45 @@ class ExpenseClaimAccessPolicy:
|
||||
return role_code
|
||||
return BUDGET_MONITOR_ROLE_CODE
|
||||
|
||||
@staticmethod
|
||||
def resolve_claim_finance_owner_name(claim: ExpenseClaim) -> str:
|
||||
employee = claim.employee
|
||||
if employee is not None and employee.finance_owner_name:
|
||||
return str(employee.finance_owner_name).strip()
|
||||
return ""
|
||||
|
||||
def resolve_finance_approver(self, claim: ExpenseClaim) -> Employee | None:
|
||||
claim_employee_id = str(claim.employee_id or "").strip()
|
||||
base_stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.roles))
|
||||
.where(Employee.roles.any(Role.role_code == "finance"))
|
||||
)
|
||||
if claim_employee_id:
|
||||
base_stmt = base_stmt.where(Employee.id != claim_employee_id)
|
||||
|
||||
finance_owner_name = self.resolve_claim_finance_owner_name(claim)
|
||||
if finance_owner_name:
|
||||
named_finance = self.db.scalar(
|
||||
base_stmt
|
||||
.where(Employee.name == finance_owner_name)
|
||||
.order_by(Employee.name.asc(), Employee.employee_no.asc())
|
||||
.limit(1)
|
||||
)
|
||||
if named_finance is not None:
|
||||
return named_finance
|
||||
|
||||
owner_matched_finance = self.db.scalar(
|
||||
base_stmt
|
||||
.where(func.lower(func.coalesce(Employee.finance_owner_name, "")) == finance_owner_name.lower())
|
||||
.order_by(Employee.name.asc(), Employee.employee_no.asc())
|
||||
.limit(1)
|
||||
)
|
||||
if owner_matched_finance is not None:
|
||||
return owner_matched_finance
|
||||
|
||||
return self.db.scalar(base_stmt.order_by(Employee.name.asc(), Employee.employee_no.asc()).limit(1))
|
||||
|
||||
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
||||
if claim is None:
|
||||
return None
|
||||
@@ -266,9 +313,25 @@ class ExpenseClaimAccessPolicy:
|
||||
)
|
||||
return claim
|
||||
|
||||
def attach_finance_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
||||
if claim is None:
|
||||
return None
|
||||
if str(claim.approval_stage or "").strip() != FINANCE_APPROVAL_STAGE:
|
||||
return claim
|
||||
|
||||
finance_approver = self.resolve_finance_approver(claim)
|
||||
if finance_approver is not None and finance_approver.name:
|
||||
setattr(claim, "finance_approver_name", str(finance_approver.name).strip())
|
||||
return claim
|
||||
|
||||
def attach_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
||||
self.attach_budget_approval_snapshot(claim)
|
||||
self.attach_finance_approval_snapshot(claim)
|
||||
return claim
|
||||
|
||||
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
|
||||
for claim in claims:
|
||||
self.attach_budget_approval_snapshot(claim)
|
||||
self.attach_approval_snapshot(claim)
|
||||
return claims
|
||||
|
||||
@staticmethod
|
||||
@@ -446,6 +509,20 @@ class ExpenseClaimAccessPolicy:
|
||||
if employee is not None:
|
||||
return employee
|
||||
|
||||
for candidate in normalized_candidates:
|
||||
if self.is_email_like(candidate):
|
||||
continue
|
||||
matches = list(
|
||||
self.db.scalars(
|
||||
select(Employee)
|
||||
.options(*load_options)
|
||||
.where(func.lower(Employee.email).like(f"{candidate.lower()}@%"))
|
||||
.limit(2)
|
||||
).all()
|
||||
)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
|
||||
for candidate in normalized_candidates:
|
||||
matches = list(
|
||||
self.db.scalars(
|
||||
@@ -644,6 +721,11 @@ class ExpenseClaimAccessPolicy:
|
||||
*,
|
||||
include_approval_scope: bool = False,
|
||||
) -> Any:
|
||||
if current_user.is_admin:
|
||||
if include_approval_scope:
|
||||
return stmt
|
||||
return stmt.where(~self.build_archived_claim_condition())
|
||||
|
||||
conditions = self.build_personal_claim_conditions(current_user)
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
|
||||
@@ -655,8 +737,9 @@ class ExpenseClaimAccessPolicy:
|
||||
"%\\_application",
|
||||
escape="\\",
|
||||
),
|
||||
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("AP-%"),
|
||||
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("APP-%"),
|
||||
~self._build_application_claim_no_condition(
|
||||
func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
|
||||
),
|
||||
~self.build_archived_claim_condition(),
|
||||
)
|
||||
conditions.append(company_reimbursement_condition)
|
||||
|
||||
@@ -5,7 +5,11 @@ from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE
|
||||
|
||||
|
||||
APPLICATION_REIMBURSEMENT_TYPE_MAP = {
|
||||
@@ -15,6 +19,7 @@ APPLICATION_REIMBURSEMENT_TYPE_MAP = {
|
||||
"expense_application": "other",
|
||||
"application": "other",
|
||||
}
|
||||
APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"}
|
||||
|
||||
|
||||
class ExpenseClaimApplicationHandoffMixin:
|
||||
@@ -130,3 +135,116 @@ class ExpenseClaimApplicationHandoffMixin:
|
||||
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
|
||||
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}。"
|
||||
return draft_claim
|
||||
|
||||
@staticmethod
|
||||
def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]:
|
||||
application_ids: set[str] = set()
|
||||
application_nos: set[str] = set()
|
||||
for flag in list(claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
source = str(flag.get("source") or "").strip()
|
||||
has_application_reference = any(
|
||||
str(flag.get(key) or "").strip()
|
||||
for key in (
|
||||
"application_claim_id",
|
||||
"applicationClaimId",
|
||||
"application_claim_no",
|
||||
"applicationClaimNo",
|
||||
)
|
||||
)
|
||||
if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference:
|
||||
continue
|
||||
application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip()
|
||||
application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip()
|
||||
if application_id:
|
||||
application_ids.add(application_id)
|
||||
if application_no:
|
||||
application_nos.add(application_no)
|
||||
return application_ids, application_nos
|
||||
|
||||
def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]:
|
||||
application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim)
|
||||
conditions = []
|
||||
if application_ids:
|
||||
conditions.append(ExpenseClaim.id.in_(application_ids))
|
||||
if application_nos:
|
||||
conditions.append(ExpenseClaim.claim_no.in_(application_nos))
|
||||
if not conditions:
|
||||
return []
|
||||
|
||||
claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all())
|
||||
return [claim for claim in claims if self._is_expense_application_claim(claim)]
|
||||
|
||||
def _archive_linked_applications_after_reimbursement_paid(
|
||||
self,
|
||||
*,
|
||||
reimbursement_claim: ExpenseClaim,
|
||||
payment_flag: dict[str, Any],
|
||||
operator: str,
|
||||
current_user: Any,
|
||||
) -> list[dict[str, str]]:
|
||||
archived_applications: list[dict[str, str]] = []
|
||||
payment_event_id = str(payment_flag.get("payment_event_id") or "").strip()
|
||||
for application_claim in self._find_linked_application_claims(reimbursement_claim):
|
||||
previous_status = str(application_claim.status or "").strip()
|
||||
previous_stage = str(application_claim.approval_stage or "").strip()
|
||||
if previous_stage == APPLICATION_ARCHIVE_STAGE:
|
||||
continue
|
||||
|
||||
normalized_status = previous_status.lower()
|
||||
if normalized_status not in {"approved", "completed"}:
|
||||
continue
|
||||
|
||||
before_json = self._serialize_claim(application_claim)
|
||||
archive_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "application_archive_sync",
|
||||
"event_type": "expense_application_archived_by_reimbursement",
|
||||
"archive_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "申请归档",
|
||||
"message": (
|
||||
f"关联报销单 {reimbursement_claim.claim_no} 已完成付款,"
|
||||
"系统同步将申请单归档。"
|
||||
),
|
||||
"operator": operator,
|
||||
"operator_username": getattr(current_user, "username", ""),
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in getattr(current_user, "role_codes", [])
|
||||
if str(item).strip()
|
||||
],
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": application_claim.claim_no,
|
||||
"reimbursement_claim_id": reimbursement_claim.id,
|
||||
"reimbursement_claim_no": reimbursement_claim.claim_no,
|
||||
"payment_event_id": payment_event_id,
|
||||
"previous_status": previous_status,
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": "approved",
|
||||
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"expense_application",
|
||||
)
|
||||
application_claim.status = "approved"
|
||||
application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE
|
||||
application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag]
|
||||
archived_applications.append(
|
||||
{
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": str(application_claim.claim_no or "").strip(),
|
||||
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
|
||||
}
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_application.archive_by_reimbursement",
|
||||
resource_type="expense_claim",
|
||||
resource_id=application_claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(application_claim),
|
||||
)
|
||||
|
||||
return archived_applications
|
||||
|
||||
@@ -2,11 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
@@ -62,7 +64,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
if merged_budget_approval:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
@@ -73,10 +75,19 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
else:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
if requires_budget_review:
|
||||
merged_budget_approval = (
|
||||
requires_budget_review
|
||||
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
)
|
||||
if merged_budget_approval:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}。"
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
@@ -99,7 +110,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
label = "预算管理者审核通过"
|
||||
if is_application_claim:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
next_status = "submitted"
|
||||
@@ -120,6 +131,19 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
raise ValueError("当前节点不支持审批通过。")
|
||||
|
||||
approval_opinion = str(opinion or "").strip()
|
||||
if (
|
||||
previous_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
and self._budget_approval_opinion_required(claim)
|
||||
and not approval_opinion
|
||||
):
|
||||
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
|
||||
if (
|
||||
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
and merged_budget_approval
|
||||
and self._budget_approval_opinion_required(claim)
|
||||
and not approval_opinion
|
||||
):
|
||||
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
|
||||
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
|
||||
approval_opinion = "同意"
|
||||
|
||||
@@ -186,7 +210,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
if is_application_claim and next_stage == APPROVAL_DONE_STAGE:
|
||||
if is_application_claim and next_stage == APPLICATION_LINK_STATUS_STAGE:
|
||||
if previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion(
|
||||
claim,
|
||||
@@ -289,6 +313,15 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"reimbursement",
|
||||
)
|
||||
|
||||
archived_applications = self._archive_linked_applications_after_reimbursement_paid(
|
||||
reimbursement_claim=claim,
|
||||
payment_flag=payment_flag,
|
||||
operator=operator,
|
||||
current_user=current_user,
|
||||
)
|
||||
if archived_applications:
|
||||
payment_flag["archived_application_claims"] = archived_applications
|
||||
|
||||
claim.status = PAYMENT_PAID_STATUS
|
||||
claim.approval_stage = PAYMENT_PAID_STAGE
|
||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag]
|
||||
@@ -318,3 +351,28 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
if opinion:
|
||||
return opinion
|
||||
return ""
|
||||
|
||||
def _budget_approval_opinion_required(self, claim) -> bool:
|
||||
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
|
||||
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
|
||||
context = (
|
||||
budget_result.get("budget_context")
|
||||
if isinstance(budget_result.get("budget_context"), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
over_budget_amount = self._budget_decimal(metrics.get("over_budget_amount"))
|
||||
if over_budget_amount > Decimal("0.00"):
|
||||
return True
|
||||
|
||||
after_usage_rate = self._budget_decimal(metrics.get("after_usage_rate"))
|
||||
claim_amount_ratio = self._budget_decimal(metrics.get("claim_amount_ratio"))
|
||||
warning_threshold = self._budget_decimal(context.get("warning_threshold") or "80.00")
|
||||
return max(after_usage_rate, claim_amount_ratio) >= warning_threshold
|
||||
|
||||
@staticmethod
|
||||
def _budget_decimal(value: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0.00")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user