Refine travel reimbursement steward flow

Align planner, runtime rules, and policy assets so travel guidance
matches the updated reimbursement workflow.
This commit is contained in:
caoxiaozhu
2026-06-15 22:55:18 +08:00
parent 792741709a
commit 9f7b8b46a3
85 changed files with 9496 additions and 2555 deletions

View File

@@ -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
```

View 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 模板。

View 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: 风险与开放问题]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,17 +1,19 @@
{ {
"schema_version": "2.0", "schema_version": "2.0",
"rule_code": "risk.application.large_expense_without_preapproval", "rule_code": "risk.application.large_expense_without_preapproval",
"name": "?????????", "name": "通用大额费用无前置申请",
"description": "???????? 2000 ?????????????", "description": "非业务招待、非办公用品的通用费用超过 2000 元且缺少关联费用申请。",
"enabled": true, "enabled": true,
"requires_attachment": false, "requires_attachment": false,
"risk_dimension": "expense_control_demo", "risk_dimension": "expense_control_demo",
"risk_category": "????", "risk_category": "申请前置",
"ontology_signal": "application_required", "ontology_signal": "application_required",
"evaluator": "template_rule", "evaluator": "template_rule",
"template_key": "composite_rule_v1", "template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -34,67 +36,67 @@
"fields": [ "fields": [
{ {
"key": "claim.amount", "key": "claim.amount",
"label": "????", "label": "报销金额",
"type": "number", "type": "number",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.expense_type", "key": "claim.expense_type",
"label": "????", "label": "费用类型",
"type": "enum", "type": "enum",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.department_name", "key": "claim.department_name",
"label": "??", "label": "部门",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.reason", "key": "claim.reason",
"label": "??", "label": "事由",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "item.item_reason", "key": "item.item_reason",
"label": "????", "label": "明细事由",
"type": "text", "type": "text",
"source": "item" "source": "item"
}, },
{ {
"key": "application.id", "key": "application.id",
"label": "???ID", "label": "申请单ID",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.claim_no", "key": "application.claim_no",
"label": "????", "label": "申请单号",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.status", "key": "application.status",
"label": "????", "label": "申请状态",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.approved_amount", "key": "application.approved_amount",
"label": "??????", "label": "申请审批金额",
"type": "number", "type": "number",
"source": "application" "source": "application"
}, },
{ {
"key": "application.expense_type", "key": "application.expense_type",
"label": "??????", "label": "申请费用类型",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.department_name", "key": "application.department_name",
"label": "????", "label": "申请部门",
"type": "text", "type": "text",
"source": "application" "source": "application"
} }
@@ -144,10 +146,10 @@
"meal", "meal",
"entertainment", "entertainment",
"office", "office",
"????", "业务招待",
"??", "招待",
"????", "办公用品",
"??" "办公"
] ]
} }
], ],
@@ -161,10 +163,12 @@
] ]
}, },
"formula": "amount > threshold AND NOT hasApplication", "formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "?????????????????? 2000 ????????????????", "condition_summary": "非业务招待、非办公用品的通用费用超过 2000 元且未关联费用申请时触发。",
"message_template": "?????? 2000 ?????????????????????????", "message_template": "通用大额费用超过 2000 元但未找到关联费用申请,请补充前置申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -177,14 +181,14 @@
"facts": [ "facts": [
{ {
"id": "A", "id": "A",
"label": "????", "label": "报销金额",
"fields": [ "fields": [
"claim.amount" "claim.amount"
] ]
}, },
{ {
"id": "B", "id": "B",
"label": "???", "label": "关联申请",
"fields": [ "fields": [
"application.id", "application.id",
"application.claim_no" "application.claim_no"
@@ -192,7 +196,15 @@
} }
], ],
"hit_logic": "A > threshold AND NOT EXISTS(B)" "hit_logic": "A > threshold AND NOT EXISTS(B)"
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
} }
]
}, },
"outcomes": { "outcomes": {
"pass": { "pass": {
@@ -206,25 +218,43 @@
} }
}, },
"metadata": { "metadata": {
"owner": "??????", "owner": "财务制度管理组",
"stability": "platform", "stability": "platform",
"source_ref": "??????????", "source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00", "created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system", "created_by": "system",
"risk_score": 86, "risk_score": 86,
"risk_level": "high", "risk_level": "high",
"rule_title": "?????????", "rule_title": "通用大额费用无前置申请",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"all" "all"
], ],
"budget_required": true "budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}, },
"severity": "high", "severity": "high",
"risk_score": 86, "risk_score": 86,
"risk_level": "high" "risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
} }

View File

@@ -1,17 +1,19 @@
{ {
"schema_version": "2.0", "schema_version": "2.0",
"rule_code": "risk.application.meal_high_value_without_preapproval", "rule_code": "risk.application.meal_high_value_without_preapproval",
"name": "??????????", "name": "业务招待高金额无前置申请",
"description": "????????? 500 ?????????????", "description": "业务招待费超过 500 元且缺少关联费用申请或审批记录。",
"enabled": true, "enabled": true,
"requires_attachment": false, "requires_attachment": false,
"risk_dimension": "expense_control_demo", "risk_dimension": "expense_control_demo",
"risk_category": "????", "risk_category": "申请前置",
"ontology_signal": "application_required", "ontology_signal": "application_required",
"evaluator": "template_rule", "evaluator": "template_rule",
"template_key": "composite_rule_v1", "template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -36,67 +38,67 @@
"fields": [ "fields": [
{ {
"key": "claim.amount", "key": "claim.amount",
"label": "????", "label": "报销金额",
"type": "number", "type": "number",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.expense_type", "key": "claim.expense_type",
"label": "????", "label": "费用类型",
"type": "enum", "type": "enum",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.department_name", "key": "claim.department_name",
"label": "??", "label": "部门",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.reason", "key": "claim.reason",
"label": "??", "label": "事由",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "item.item_reason", "key": "item.item_reason",
"label": "????", "label": "明细事由",
"type": "text", "type": "text",
"source": "item" "source": "item"
}, },
{ {
"key": "application.id", "key": "application.id",
"label": "???ID", "label": "申请单ID",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.claim_no", "key": "application.claim_no",
"label": "????", "label": "申请单号",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.status", "key": "application.status",
"label": "????", "label": "申请状态",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.approved_amount", "key": "application.approved_amount",
"label": "??????", "label": "申请审批金额",
"type": "number", "type": "number",
"source": "application" "source": "application"
}, },
{ {
"key": "application.expense_type", "key": "application.expense_type",
"label": "??????", "label": "申请费用类型",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.department_name", "key": "application.department_name",
"label": "????", "label": "申请部门",
"type": "text", "type": "text",
"source": "application" "source": "application"
} }
@@ -146,10 +148,12 @@
] ]
}, },
"formula": "amount > threshold AND NOT hasApplication", "formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "??????????? 500 ????????????????", "condition_summary": "业务招待费超过 500 元且未关联已审批费用申请时触发。",
"message_template": "??????? 500 ?????????????????????????", "message_template": "业务招待费超过 500 元但未找到关联费用申请,请补充前置申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -163,14 +167,14 @@
"facts": [ "facts": [
{ {
"id": "A", "id": "A",
"label": "????", "label": "报销金额",
"fields": [ "fields": [
"claim.amount" "claim.amount"
] ]
}, },
{ {
"id": "B", "id": "B",
"label": "???", "label": "关联申请",
"fields": [ "fields": [
"application.id", "application.id",
"application.claim_no" "application.claim_no"
@@ -178,7 +182,15 @@
} }
], ],
"hit_logic": "A > threshold AND NOT EXISTS(B)" "hit_logic": "A > threshold AND NOT EXISTS(B)"
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
} }
]
}, },
"outcomes": { "outcomes": {
"pass": { "pass": {
@@ -192,16 +204,18 @@
} }
}, },
"metadata": { "metadata": {
"owner": "??????", "owner": "财务制度管理组",
"stability": "platform", "stability": "platform",
"source_ref": "??????????", "source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00", "created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system", "created_by": "system",
"risk_score": 88, "risk_score": 88,
"risk_level": "high", "risk_level": "high",
"rule_title": "??????????", "rule_title": "业务招待高金额无前置申请",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -209,9 +223,25 @@
"meal", "meal",
"entertainment" "entertainment"
], ],
"budget_required": true "budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}, },
"severity": "high", "severity": "high",
"risk_score": 88, "risk_score": 88,
"risk_level": "high" "risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
} }

View File

@@ -1,17 +1,19 @@
{ {
"schema_version": "2.0", "schema_version": "2.0",
"rule_code": "risk.application.office_bulk_without_purchase", "rule_code": "risk.application.office_bulk_without_purchase",
"name": "???????????", "name": "办公用品批量采购无前置申请",
"description": "???????????????? 2000 ???????????", "description": "办公用品或办公采购费用超过 2000 元且缺少关联费用申请或采购审批。",
"enabled": true, "enabled": true,
"requires_attachment": false, "requires_attachment": false,
"risk_dimension": "expense_control_demo", "risk_dimension": "expense_control_demo",
"risk_category": "????", "risk_category": "申请前置",
"ontology_signal": "application_required", "ontology_signal": "application_required",
"evaluator": "template_rule", "evaluator": "template_rule",
"template_key": "composite_rule_v1", "template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -34,67 +36,67 @@
"fields": [ "fields": [
{ {
"key": "claim.amount", "key": "claim.amount",
"label": "????", "label": "报销金额",
"type": "number", "type": "number",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.expense_type", "key": "claim.expense_type",
"label": "????", "label": "费用类型",
"type": "enum", "type": "enum",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.department_name", "key": "claim.department_name",
"label": "??", "label": "部门",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.reason", "key": "claim.reason",
"label": "??", "label": "事由",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "item.item_reason", "key": "item.item_reason",
"label": "????", "label": "明细事由",
"type": "text", "type": "text",
"source": "item" "source": "item"
}, },
{ {
"key": "application.id", "key": "application.id",
"label": "???ID", "label": "申请单ID",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.claim_no", "key": "application.claim_no",
"label": "????", "label": "申请单号",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.status", "key": "application.status",
"label": "????", "label": "申请状态",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.approved_amount", "key": "application.approved_amount",
"label": "??????", "label": "申请审批金额",
"type": "number", "type": "number",
"source": "application" "source": "application"
}, },
{ {
"key": "application.expense_type", "key": "application.expense_type",
"label": "??????", "label": "申请费用类型",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.department_name", "key": "application.department_name",
"label": "????", "label": "申请部门",
"type": "text", "type": "text",
"source": "application" "source": "application"
} }
@@ -144,10 +146,12 @@
] ]
}, },
"formula": "amount > threshold AND NOT hasApplication", "formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "???????????????? 2000 ????????????????", "condition_summary": "办公用品费用超过 2000 元且未关联费用申请或采购审批时触发。",
"message_template": "??????? 2000 ??????????????????????????????", "message_template": "办公用品费用超过 2000 元但未找到关联费用申请,请补充采购申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -160,14 +164,14 @@
"facts": [ "facts": [
{ {
"id": "A", "id": "A",
"label": "????", "label": "报销金额",
"fields": [ "fields": [
"claim.amount" "claim.amount"
] ]
}, },
{ {
"id": "B", "id": "B",
"label": "???", "label": "关联申请",
"fields": [ "fields": [
"application.id", "application.id",
"application.claim_no" "application.claim_no"
@@ -175,7 +179,15 @@
} }
], ],
"hit_logic": "A > threshold AND NOT EXISTS(B)" "hit_logic": "A > threshold AND NOT EXISTS(B)"
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
} }
]
}, },
"outcomes": { "outcomes": {
"pass": { "pass": {
@@ -189,25 +201,43 @@
} }
}, },
"metadata": { "metadata": {
"owner": "??????", "owner": "财务制度管理组",
"stability": "platform", "stability": "platform",
"source_ref": "??????????", "source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00", "created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system", "created_by": "system",
"risk_score": 84, "risk_score": 84,
"risk_level": "high", "risk_level": "high",
"rule_title": "???????????", "rule_title": "办公用品批量采购无前置申请",
"finance_rule_code": "expense.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????", "finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"office" "office"
], ],
"budget_required": true "budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}, },
"severity": "high", "severity": "high",
"risk_score": 84, "risk_score": 84,
"risk_level": "high" "risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
} }

View File

@@ -10,8 +10,8 @@
"ontology_signal": "application_required", "ontology_signal": "application_required",
"evaluator": "template_rule", "evaluator": "template_rule",
"template_key": "keyword_match_v1", "template_key": "keyword_match_v1",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "差旅住宿费标准", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -119,15 +119,55 @@
"未申请" "未申请"
], ],
"condition_summary": "差旅金额达到大额阈值且缺少有效出差申请时触发。", "condition_summary": "差旅金额达到大额阈值且缺少有效出差申请时触发。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "差旅住宿费标准", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"travel" "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": { "outcomes": {
"pass": { "pass": {
@@ -149,17 +189,97 @@
"risk_score": 82, "risk_score": 82,
"risk_level": "high", "risk_level": "high",
"rule_title": "大额差旅未申请", "rule_title": "大额差旅未申请",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "差旅住宿费标准", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"travel" "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", "severity": "high",
"risk_score": 82, "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"
}
]
} }

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_city_mismatch", "ontology_signal": "travel_city_mismatch",
"evaluator": "template_rule", "evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -105,7 +105,31 @@
"项目现场" "项目现场"
], ],
"condition_summary": "票据城市未覆盖申报目的地,或路线出现无法由本次票据起终点和申报目的地解释的额外城市且无合理说明。", "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": { "outcomes": {
"pass": { "pass": {
@@ -121,14 +145,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 90, "risk_score": 90,
"risk_level": "high", "risk_level": "high",
"rule_title": "差旅目的地与票据城市不一致高风险", "rule_title": "差旅目的地与票据城市不一致高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -146,7 +170,29 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "high",
"risk_score": 90, "risk_score": 90,
@@ -160,5 +206,27 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_date_outside_trip_window", "ontology_signal": "travel_date_outside_trip_window",
"evaluator": "template_rule", "evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -102,7 +102,37 @@
], ],
"hit_logic": "ticket_date_outside_trip", "hit_logic": "ticket_date_outside_trip",
"condition_summary": "任一票据/明细日期早于出差开始日前 1 天或晚于结束日后 1 天。", "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": { "outcomes": {
"pass": { "pass": {
@@ -118,14 +148,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 88, "risk_score": 88,
"risk_level": "high", "risk_level": "high",
"rule_title": "票据日期超出差旅行程高风险", "rule_title": "票据日期超出差旅行程高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -143,7 +173,35 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "high",
"risk_score": 88, "risk_score": 88,
@@ -157,5 +215,33 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_personal_purpose", "ontology_signal": "travel_personal_purpose",
"evaluator": "template_rule", "evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -76,7 +76,37 @@
], ],
"condition_summary": "差旅事由或票据文本命中个人旅游/私人目的关键词。", "condition_summary": "差旅事由或票据文本命中个人旅游/私人目的关键词。",
"message_template": "识别到个人旅游或非公务目的表达,请确认是否属于公司差旅范围。", "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": { "outcomes": {
"pass": { "pass": {
@@ -92,14 +122,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 86, "risk_score": 86,
"risk_level": "high", "risk_level": "high",
"rule_title": "个人旅游或非公务目的高风险", "rule_title": "个人旅游或非公务目的高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -117,7 +147,35 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "high",
"risk_score": 86, "risk_score": 86,
@@ -131,5 +189,33 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-申请审批", "risk_category": "差旅费-申请审批",
"ontology_signal": "travel_preapproval_absent", "ontology_signal": "travel_preapproval_absent",
"evaluator": "template_rule", "evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -76,7 +76,19 @@
], ],
"condition_summary": "差旅申请/报销文本命中未申请、未审批或事后补申请关键词。", "condition_summary": "差旅申请/报销文本命中未申请、未审批或事后补申请关键词。",
"message_template": "识别到差旅未事前申请或事后补申请迹象,请补齐已审批的差旅申请后再提交。", "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": { "outcomes": {
"pass": { "pass": {
@@ -92,14 +104,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:费用申请审批规则",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 92, "risk_score": 92,
"risk_level": "high", "risk_level": "high",
"rule_title": "差旅未申请或事后补申请高风险", "rule_title": "差旅未申请或事后补申请高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -117,7 +129,17 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "reason": "按差旅费报销高/中/低风险分层手工设定。"
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
} }
]
}, },
"severity": "high", "severity": "high",
"risk_score": 92, "risk_score": 92,
@@ -131,5 +153,15 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "reason": "按差旅费报销高/中/低风险分层手工设定。"
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
} }
]
} }

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-申请信息", "risk_category": "差旅费-申请信息",
"ontology_signal": "travel_application_fields_missing", "ontology_signal": "travel_application_fields_missing",
"evaluator": "template_rule", "evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"expense_application" "expense_application"
], ],
@@ -80,7 +80,49 @@
], ],
"condition_summary": "差旅申请缺少事由、地点、起止时间或预计金额。", "condition_summary": "差旅申请缺少事由、地点、起止时间或预计金额。",
"message_template": "差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。", "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": { "outcomes": {
"pass": { "pass": {
@@ -96,14 +138,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:费用申请审批规则、差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、交通费用预估表、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 42, "risk_score": 42,
"risk_level": "low", "risk_level": "low",
"rule_title": "差旅申请基础信息不完整低风险", "rule_title": "差旅申请基础信息不完整低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"expense_application" "expense_application"
], ],
@@ -120,7 +162,47 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "low",
"risk_score": 42, "risk_score": 42,
@@ -134,5 +216,45 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_attachment_ocr_missing", "ontology_signal": "travel_attachment_ocr_missing",
"evaluator": "template_rule", "evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -50,7 +50,31 @@
], ],
"condition_summary": "差旅附件缺少可读取 OCR 文本。", "condition_summary": "差旅附件缺少可读取 OCR 文本。",
"message_template": "差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。", "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": { "outcomes": {
"pass": { "pass": {
@@ -66,14 +90,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 38, "risk_score": 38,
"risk_level": "low", "risk_level": "low",
"rule_title": "差旅附件无法识别低风险", "rule_title": "差旅附件无法识别低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -91,7 +115,29 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "low",
"risk_score": 38, "risk_score": 38,
@@ -105,5 +151,27 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-市内交通", "risk_category": "差旅费-市内交通",
"ontology_signal": "travel_local_transport_detail_missing", "ontology_signal": "travel_local_transport_detail_missing",
"evaluator": "template_rule", "evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_transport_class",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "交通工具等级标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -102,7 +102,19 @@
] ]
}, },
"condition_summary": "存在市内交通关键词,但文本中缺少起点、终点或路线说明。", "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": { "outcomes": {
"pass": { "pass": {
@@ -118,14 +130,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 36, "risk_score": 36,
"risk_level": "low", "risk_level": "low",
"rule_title": "市内交通路线说明不足低风险", "rule_title": "市内交通路线说明不足低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_transport_class",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "交通工具等级标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -143,7 +155,17 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "low",
"risk_score": 36, "risk_score": 36,
@@ -157,5 +179,15 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_vague_ticket_content", "ontology_signal": "travel_vague_ticket_content",
"evaluator": "vague_goods_description", "evaluator": "vague_goods_description",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -49,7 +49,31 @@
}, },
"params": { "params": {
"condition_summary": "票据未识别为明确的酒店、交通等差旅票据,且商品或服务名称过于笼统,无法直接对应差旅事项。", "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": { "outcomes": {
"pass": { "pass": {
@@ -65,14 +89,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 34, "risk_score": 34,
"risk_level": "low", "risk_level": "low",
"rule_title": "差旅票据服务内容笼统低风险", "rule_title": "差旅票据服务内容笼统低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -90,7 +114,29 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "low",
"risk_score": 34, "risk_score": 34,
@@ -103,5 +149,27 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_duplicate_ticket", "ontology_signal": "travel_duplicate_ticket",
"evaluator": "duplicate_invoice", "evaluator": "duplicate_invoice",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -49,7 +49,31 @@
}, },
"params": { "params": {
"condition_summary": "票据号码在当前单据或历史报销中重复出现。", "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": { "outcomes": {
"pass": { "pass": {
@@ -65,14 +89,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 75, "risk_score": 75,
"risk_level": "medium", "risk_level": "medium",
"rule_title": "差旅票据重复中风险", "rule_title": "差旅票据重复中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -90,7 +114,29 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "medium",
"risk_score": 75, "risk_score": 75,
@@ -103,5 +149,27 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_multi_city_without_reason", "ontology_signal": "travel_multi_city_without_reason",
"evaluator": "multi_city_reason_required", "evaluator": "multi_city_reason_required",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -67,7 +67,31 @@
}, },
"params": { "params": {
"condition_summary": "差旅行程涉及 3 个及以上城市,且事由未包含中转、多地、改签、绕行等说明。", "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": { "outcomes": {
"pass": { "pass": {
@@ -83,14 +107,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 72, "risk_score": 72,
"risk_level": "medium", "risk_level": "medium",
"rule_title": "多城市行程缺少说明中风险", "rule_title": "多城市行程缺少说明中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -108,7 +132,29 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "medium",
"risk_score": 72, "risk_score": 72,
@@ -121,5 +167,27 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-事由完整性", "risk_category": "差旅费-事由完整性",
"ontology_signal": "travel_reason_too_brief", "ontology_signal": "travel_reason_too_brief",
"evaluator": "reason_too_brief", "evaluator": "reason_too_brief",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -50,7 +50,19 @@
"params": { "params": {
"min_reason_length": 10, "min_reason_length": 10,
"condition_summary": "合并申请/报销事由后有效字符少于 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": { "outcomes": {
"pass": { "pass": {
@@ -66,14 +78,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:费用申请审批规则",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 68, "risk_score": 68,
"risk_level": "medium", "risk_level": "medium",
"rule_title": "差旅事由过短中风险", "rule_title": "差旅事由过短中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "费用申请审批规则",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -91,7 +103,17 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "reason": "按差旅费报销高/中/低风险分层手工设定。"
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
} }
]
}, },
"severity": "medium", "severity": "medium",
"risk_score": 68, "risk_score": 68,
@@ -104,5 +126,15 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "reason": "按差旅费报销高/中/低风险分层手工设定。"
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
} }
]
} }

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_invoice_title_mismatch", "ontology_signal": "travel_invoice_title_mismatch",
"evaluator": "identity_consistency", "evaluator": "identity_consistency",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -60,7 +60,31 @@
"远光软件" "远光软件"
], ],
"condition_summary": "票据抬头/购买方不包含报销人姓名,也不包含公司抬头关键词。", "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": { "outcomes": {
"pass": { "pass": {
@@ -76,14 +100,14 @@
"metadata": { "metadata": {
"owner": "admin", "owner": "admin",
"stability": "admin_configured", "stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置", "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00", "created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin", "created_by": "admin",
"risk_score": 64, "risk_score": 64,
"risk_level": "medium", "risk_level": "medium",
"rule_title": "差旅票据抬头不一致中风险", "rule_title": "差旅票据抬头不一致中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则", "finance_rule_sheet": "差旅住宿费标准",
"business_stage": [ "business_stage": [
"expense_application", "expense_application",
"reimbursement" "reimbursement"
@@ -101,7 +125,29 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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", "severity": "medium",
"risk_score": 64, "risk_score": 64,
@@ -114,5 +160,27 @@
"model": "risk_score_v3", "model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog", "source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。" "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"
} }
]
} }

View File

@@ -20,7 +20,9 @@ from app.schemas.steward import (
StewardSlotDecisionResponse, StewardSlotDecisionResponse,
StewardThinkingEvent, StewardThinkingEvent,
) )
from app.services.agent_conversations import AgentConversationService
from app.services.runtime_chat import RuntimeChatService 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_intent_agent import StewardIntentAgent
from app.services.steward_planner import StewardPlannerService from app.services.steward_planner import StewardPlannerService
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
@@ -44,7 +46,8 @@ DbSession = Annotated[Session, Depends(get_db)]
) )
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse: def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
try: try:
return _build_steward_planner(db).build_plan(payload) plan = _build_steward_planner(db).build_plan(payload)
return _attach_conversation_state(db, payload, plan)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -72,7 +75,9 @@ def create_steward_runtime_decision(
payload: StewardRuntimeDecisionRequest, payload: StewardRuntimeDecisionRequest,
db: DbSession, db: DbSession,
) -> StewardRuntimeDecisionResponse: ) -> StewardRuntimeDecisionResponse:
return StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(payload) 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( @router.post(
@@ -82,7 +87,7 @@ def create_steward_runtime_decision(
) )
async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse: async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse:
return StreamingResponse( return StreamingResponse(
_iter_steward_plan_events(payload, _build_steward_planner(db)), _iter_steward_plan_events(payload, _build_steward_planner(db), db),
media_type="application/x-ndjson", media_type="application/x-ndjson",
) )
@@ -90,6 +95,7 @@ async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> Str
async def _iter_steward_plan_events( async def _iter_steward_plan_events(
payload: StewardPlanRequest, payload: StewardPlanRequest,
planner: StewardPlannerService, planner: StewardPlannerService,
db: Session,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
yield _encode_stream_event( yield _encode_stream_event(
"thinking", "thinking",
@@ -105,6 +111,7 @@ async def _iter_steward_plan_events(
try: try:
plan = planner.build_plan(payload) plan = planner.build_plan(payload)
plan = _attach_conversation_state(db, payload, plan)
except ValueError as exc: except ValueError as exc:
yield _encode_stream_event("error", {"message": str(exc)}) yield _encode_stream_event("error", {"message": str(exc)})
return return
@@ -124,3 +131,131 @@ def _build_steward_planner(db: Session) -> StewardPlannerService:
return StewardPlannerService( return StewardPlannerService(
intent_agent=StewardIntentAgent(RuntimeChatService(db)), intent_agent=StewardIntentAgent(RuntimeChatService(db)),
) )
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 {}

View File

@@ -198,6 +198,9 @@ class TravelReimbursementCalculatorRequest(BaseModel):
days: int = Field(ge=1, le=365) days: int = Field(ge=1, le=365)
location: str = Field(min_length=1, max_length=120) location: str = Field(min_length=1, max_length=120)
grade: str | None = Field(default=None, max_length=30) 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): class TravelReimbursementCalculatorResponse(BaseModel):
@@ -215,6 +218,17 @@ class TravelReimbursementCalculatorResponse(BaseModel):
basic_allowance_rate: Decimal basic_allowance_rate: Decimal
total_allowance_rate: Decimal total_allowance_rate: Decimal
allowance_amount: 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 total_amount: Decimal
rule_name: str rule_name: str
rule_version: str rule_version: str

View File

@@ -8,11 +8,13 @@ from pydantic import BaseModel, Field
StewardTaskType = Literal["expense_application", "reimbursement"] StewardTaskType = Literal["expense_application", "reimbursement"]
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"] StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"] StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"] StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
StewardSlotNextAction = Literal["ask_user", "render_preview"] StewardSlotNextAction = Literal["ask_user", "render_preview"]
StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"] StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"]
StewardRuntimeNextAction = Literal[ StewardRuntimeNextAction = Literal[
"plan_new_tasks", "plan_new_tasks",
"continue_selected_flow",
"submit_current_application", "submit_current_application",
"continue_next_task", "continue_next_task",
"fill_current_slot", "fill_current_slot",
@@ -29,6 +31,8 @@ StewardTaskStatus = Literal[
"blocked", "blocked",
] ]
StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"] StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"]
StewardFlowId = Literal["travel_application", "travel_reimbursement"]
StewardPendingFlowStatus = Literal["none", "pending", "confirmed", "rejected"]
class StewardAttachmentInput(BaseModel): class StewardAttachmentInput(BaseModel):
@@ -90,15 +94,39 @@ class StewardConfirmationAction(BaseModel):
payload: dict[str, Any] = Field(default_factory=dict, 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): class StewardPlanResponse(BaseModel):
plan_id: str = Field(description="小财管家计划 ID。") plan_id: str = Field(description="小财管家计划 ID。")
plan_status: str = Field(default="needs_confirmation", description="计划状态。") plan_status: str = Field(default="needs_confirmation", description="计划状态。")
planning_source: StewardPlanningSource = Field(default="rule_fallback", 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="计划摘要。") summary: str = Field(description="计划摘要。")
thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。") thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。")
tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。") tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。")
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。") attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
confirmation_groups: list[StewardConfirmationAction] = 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="模型工具调用轨迹。") model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
@@ -146,4 +174,18 @@ class StewardRuntimeDecisionResponse(BaseModel):
question: str = Field(default="", description="需要追问用户时展示的问题。") question: str = Field(default="", description="需要追问用户时展示的问题。")
response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。") response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。")
rationale: 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="模型工具调用轨迹。") 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="字段来源证据。")

View 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],
)

View File

@@ -14,6 +14,16 @@ from zipfile import ZIP_DEFLATED, ZipFile
from openpyxl import load_workbook from openpyxl import load_workbook
from app.core.config import SERVER_DIR, get_settings 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( RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
r"```rule-spreadsheet\s*(\{.*?\})\s*```", r"```rule-spreadsheet\s*(\{.*?\})\s*```",
@@ -21,11 +31,29 @@ RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
) )
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement" 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_CODE = "rule.expense.company_communication_expense_reimbursement"
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx" COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement" COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement"
COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx" 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" FINANCE_RULES_LIBRARY = "finance-rules"
RISK_RULES_LIBRARY = "risk-rules" RISK_RULES_LIBRARY = "risk-rules"
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY} RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
@@ -284,65 +312,79 @@ class AgentAssetSpreadsheetManager:
@staticmethod @staticmethod
def build_company_travel_rule_template() -> bytes: def build_company_travel_rule_template() -> bytes:
standard_rows = [ 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, ""],
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", ["其他地区", "其他地区", 320, 320, 320, 320, 380, 380, 380, 450, 450, "未单列城市按其他地区执行"],
"超标需总监审批",
"协议酒店优先",
],
[
"市内交通",
"出租车、网约车、地铁、公交",
"发票或电子行程单",
"150/天",
"超限需补充说明",
"夜间或无公共交通场景可豁免",
],
[
"餐补",
"出差期间日常补助",
"无需票据",
"120/天",
"系统自动核定",
"当天往返默认不享受",
],
[
"招待餐费",
"客户接待或项目宴请",
"餐饮发票、参与人清单",
"300/人",
"需业务负责人审批",
"需关联客户或项目",
],
]
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 @staticmethod
def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes: def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
@@ -350,7 +392,17 @@ class AgentAssetSpreadsheetManager:
@staticmethod @staticmethod
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes: def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
return _build_xlsx_bytes([(sheet_name, [[""]])]) return _build_xlsx_bytes(
[
(
sheet_name,
[
["规则项", "适用条件", "标准/阈值", "所需材料", "审批要求", "备注"],
["", "", "", "", "", ""],
],
)
]
)
@staticmethod @staticmethod
def rebuild_from_uploaded_content(content: bytes) -> bytes: def rebuild_from_uploaded_content(content: bytes) -> bytes:
@@ -360,23 +412,20 @@ class AgentAssetSpreadsheetManager:
try: try:
workbook = load_workbook( workbook = load_workbook(
filename=BytesIO(content), filename=BytesIO(content),
read_only=True, read_only=False,
data_only=False, data_only=False,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise ValueError("无法解析上传的 Excel 表格。") from exc raise ValueError("无法解析上传的 Excel 表格。") from exc
sheets: list[tuple[str, list[list[object]]]] = [] try:
for worksheet in workbook.worksheets: if not 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 表格中没有可导入的工作表。") raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
return _build_xlsx_bytes(sheets) 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: def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
@@ -544,7 +593,7 @@ def _build_styles_xml() -> str:
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' '<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>' '<fills count="2"><fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="gray125"/></fill></fills>' '<fill><patternFill patternType="gray125"/></fill></fills>'
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
@@ -562,6 +611,14 @@ def _build_styles_xml() -> str:
def _build_sheet_xml(rows: list[list[object]]) -> str: def _build_sheet_xml(rows: list[list[object]]) -> str:
normalized_rows = rows or [[""]] normalized_rows = rows or [[""]]
max_column_count = max((len(row) for row in normalized_rows), default=1) 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] = [] worksheet_rows: list[str] = []
for row_index, row in enumerate(normalized_rows, start=1): for row_index, row in enumerate(normalized_rows, start=1):
@@ -573,15 +630,18 @@ def _build_sheet_xml(rows: list[list[object]]) -> str:
cells.append( cells.append(
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>' 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)}" dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<dimension ref="{dimension}"/>' f'<dimension ref="{dimension}"/>'
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>" '<sheetViews><sheetView workbookViewId="0" zoomScale="120" zoomScaleNormal="120"/></sheetViews>'
"<sheetFormatPr defaultRowHeight=\"18\"/>" "<sheetFormatPr defaultRowHeight=\"25\" customHeight=\"1\"/>"
f"<cols>{column_xml}</cols>"
f"<sheetData>{''.join(worksheet_rows)}</sheetData>" f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
"</worksheet>" "</worksheet>"
) )
@@ -596,6 +656,31 @@ def _column_letter(index: int) -> str:
return result 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]]: def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
normalized_rows = [list(row) for row in rows] normalized_rows = [list(row) for row in rows]
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]): while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):

View File

@@ -13,8 +13,18 @@ from app.schemas.agent_asset import (
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, 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, FINANCE_RULES_LIBRARY,
RULE_LIBRARY_NAMES, RULE_LIBRARY_NAMES,
SPREADSHEET_MIME_TYPE, SPREADSHEET_MIME_TYPE,
@@ -133,7 +143,7 @@ class AgentAssetSpreadsheetHelperMixin:
} }
if config_json.get("rule_document") != expected_document: if config_json.get("rule_document") != expected_document:
config_json["detail_mode"] = "spreadsheet" 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_library"] = library
config_json["rule_document"] = expected_document config_json["rule_document"] = expected_document
asset.config_json = config_json asset.config_json = config_json
@@ -160,7 +170,7 @@ class AgentAssetSpreadsheetHelperMixin:
) )
config_json = dict(asset.config_json or {}) config_json = dict(asset.config_json or {})
config_json["detail_mode"] = "spreadsheet" 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_library"] = library
config_json["rule_document"] = { config_json["rule_document"] = {
**self.spreadsheet_manager.build_rule_document_config( **self.spreadsheet_manager.build_rule_document_config(
@@ -187,6 +197,16 @@ class AgentAssetSpreadsheetHelperMixin:
return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE: if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE:
return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME 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 fallback = Path(str(asset.name or "规则表").strip()).name
return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx" return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx"

View 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

View File

@@ -74,7 +74,7 @@ class AgentAssetService(
) -> list[AgentAssetListItem]: ) -> list[AgentAssetListItem]:
self._ensure_ready() self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}: 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( assets = self.repository.list(
asset_type=asset_type, status=status, domain=domain, keyword=keyword asset_type=asset_type, status=status, domain=domain, keyword=keyword
) )
@@ -94,7 +94,7 @@ class AgentAssetService(
) -> PageResult[AgentAssetListItem]: ) -> PageResult[AgentAssetListItem]:
self._ensure_ready() self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}: 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( assets = self.repository.list(
asset_type=asset_type, asset_type=asset_type,
status=status, status=status,
@@ -552,6 +552,13 @@ class AgentAssetService(
self.db.commit() self.db.commit()
return manifest_count return manifest_count
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 _validate_version_payload( def _validate_version_payload(
self, asset: AgentAsset, payload: AgentAssetVersionCreate self, asset: AgentAsset, payload: AgentAssetVersionCreate
) -> None: ) -> None:

View File

@@ -19,6 +19,7 @@ STATEFUL_CONTEXT_KEYS = (
"ocr_summary", "ocr_summary",
"ocr_documents", "ocr_documents",
"review_form_values", "review_form_values",
"steward_state",
"business_time_context", "business_time_context",
) )
REVIEW_FLOW_CONTEXT_KEYS = { REVIEW_FLOW_CONTEXT_KEYS = {

View File

@@ -270,7 +270,7 @@ class AgentFoundationAssetSeedMixin:
config_json={ config_json={
"severity": "medium", "severity": "medium",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "基础规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -296,7 +296,7 @@ class AgentFoundationAssetSeedMixin:
config_json={ config_json={
"severity": "medium", "severity": "medium",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "基础规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -320,7 +320,7 @@ class AgentFoundationAssetSeedMixin:
config_json={ config_json={
"severity": "high", "severity": "high",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "申请规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
@@ -729,7 +729,7 @@ class AgentFoundationAssetSeedMixin:
version=COMPANY_TRAVEL_RULE_VERSION, version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇", reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value, review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。", review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
), ),
AgentAssetReview( AgentAssetReview(
@@ -737,7 +737,7 @@ class AgentFoundationAssetSeedMixin:
version=COMPANY_COMMUNICATION_RULE_VERSION, version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇", reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value, review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。", review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
), ),
] ]

View File

@@ -368,7 +368,7 @@ class AgentFoundationAssetTopUpMixin:
config_json={ config_json={
"severity": "medium", "severity": "medium",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "基础规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], "ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -391,7 +391,7 @@ class AgentFoundationAssetTopUpMixin:
config_json={ config_json={
"severity": "medium", "severity": "medium",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "基础规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], "ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -415,7 +415,7 @@ class AgentFoundationAssetTopUpMixin:
config_json={ config_json={
"severity": "high", "severity": "high",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "申请规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
@@ -453,7 +453,7 @@ class AgentFoundationAssetTopUpMixin:
**(company_travel_rule.config_json or {}), **(company_travel_rule.config_json or {}),
"severity": "medium", "severity": "medium",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "基础规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -489,7 +489,7 @@ class AgentFoundationAssetTopUpMixin:
version=COMPANY_TRAVEL_RULE_VERSION, version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇", reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value, review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。", review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
) )
@@ -523,7 +523,7 @@ class AgentFoundationAssetTopUpMixin:
**(company_communication_rule.config_json or {}), **(company_communication_rule.config_json or {}),
"severity": "medium", "severity": "medium",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "基础规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -569,7 +569,7 @@ class AgentFoundationAssetTopUpMixin:
version=COMPANY_COMMUNICATION_RULE_VERSION, version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇", reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value, review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。", review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
) )
@@ -591,7 +591,7 @@ class AgentFoundationAssetTopUpMixin:
**(company_preapproval_rule.config_json or {}), **(company_preapproval_rule.config_json or {}),
"severity": "high", "severity": "high",
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "申请规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
@@ -640,7 +640,7 @@ class AgentFoundationAssetTopUpMixin:
version=COMPANY_PREAPPROVAL_RULE_VERSION, version=COMPANY_PREAPPROVAL_RULE_VERSION,
reviewer="顾承宣", reviewer="顾承宣",
review_status=AgentReviewStatus.APPROVED.value, review_status=AgentReviewStatus.APPROVED.value,
review_note="首版费用申请审批规则表已确认,可作为财务规则使用。", review_note="首版费用申请审批规则表已确认,可作为申请规则使用。",
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
) )

View File

@@ -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 进行数值比较"],
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
["审批要求", "说明申请单进入审批链后的最低审批要求"],
["风险动作", "说明报销阶段未满足规则时的系统处理"],
],
),
]

View File

@@ -5,6 +5,10 @@ from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from app.core.agent_enums import ( from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetType,
AgentReviewStatus,
AgentAssetStatus, AgentAssetStatus,
) )
from app.core.logging import get_logger from app.core.logging import get_logger
@@ -12,22 +16,38 @@ from app.models.agent_asset import AgentAsset
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE, COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME, COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, 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, FINANCE_RULES_LIBRARY,
AgentAssetSpreadsheetManager, AgentAssetSpreadsheetManager,
) )
from app.services.agent_foundation_constants import ( from app.services.agent_foundation_constants import (
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON, COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
COMPANY_PREAPPROVAL_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
) )
from app.services.finance_rule_catalog import ( from app.services.finance_rule_catalog import (
DEPRECATED_FINANCE_RULE_CODES, DEPRECATED_FINANCE_RULE_CODES,
DEPRECATED_FINANCE_RULE_REPLACEMENTS, DEPRECATED_FINANCE_RULE_REPLACEMENTS,
) )
from app.services.agent_foundation_preapproval_spreadsheet import (
build_preapproval_rule_workbook_sheets,
)
logger = get_logger("app.services.agent_foundation") logger = get_logger("app.services.agent_foundation")
@@ -44,25 +64,131 @@ class AgentFoundationSpreadsheetMixin:
synced_count += int( synced_count += int(
self._ensure_core_finance_rule_asset( self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="差旅住宿报销标准",
description="按地区和职级维护差旅住宿费报销上限。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="差旅住宿费标准", 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( synced_count += int(
self._ensure_core_finance_rule_asset( self._ensure_core_finance_rule_asset(
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
finance_rule_sheet="通信费报销标准", finance_rule_sheet="通信费报销标准",
expense_types=["communication"], 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( synced_count += int(
self._ensure_core_finance_rule_asset( self._ensure_core_finance_rule_asset(
code=COMPANY_PREAPPROVAL_RULE_CODE, code=COMPANY_PREAPPROVAL_RULE_CODE,
name="公司费用申请审批规则",
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="费用申请审批规则", finance_rule_sheet="费用申请审批规则",
expense_types=["meal", "entertainment", "office", "all"], 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 return synced_count
@@ -71,18 +197,42 @@ class AgentFoundationSpreadsheetMixin:
self, self,
*, *,
code: str, code: str,
name: str,
description: str,
scenario_category: str, scenario_category: str,
finance_rule_sheet: str, finance_rule_sheet: str,
expense_types: list[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: ) -> bool:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code)) asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
created_asset = asset is None
if asset is None: if asset is None:
return False asset = self._create_seed_asset(
asset.scenario_json = [scenario_category] asset_type=AgentAssetType.RULE.value,
asset.config_json = { code=code,
**(asset.config_json or {}), 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, "enabled": True,
"tag": "财务规则", "tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": scenario_category, "scenario_category": scenario_category,
@@ -92,9 +242,138 @@ class AgentFoundationSpreadsheetMixin:
"expense_types": expense_types, "expense_types": expense_types,
"business_stage": ["expense_application", "reimbursement"], "business_stage": ["expense_application", "reimbursement"],
"budget_required": True, "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]
config_json = {
**(asset.config_json or {}),
"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": 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 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: def _hide_deprecated_finance_rule_assets(self) -> None:
for code in DEPRECATED_FINANCE_RULE_CODES: for code in DEPRECATED_FINANCE_RULE_CODES:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code)) asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
@@ -105,16 +384,16 @@ class AgentFoundationSpreadsheetMixin:
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code) replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE: if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
deprecated_reason = ( deprecated_reason = (
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。" "交通/住宿细分并入公司差旅费报销规则,不再作为独立基础规则展示。"
) )
elif replacement == COMPANY_PREAPPROVAL_RULE_CODE: elif replacement == COMPANY_PREAPPROVAL_RULE_CODE:
deprecated_reason = ( deprecated_reason = (
"申请审批阈值已并入公司费用申请审批规则,不再作为独立财务规则展示。" "申请审批阈值已并入公司费用申请审批规则,不再作为独立基础规则展示。"
) )
else: else:
deprecated_reason = ( deprecated_reason = (
"该费用类型没有独立职务金额分档,额度控制转入预算中心," "该费用类型没有独立职务金额分档,额度控制转入预算中心,"
"不再作为独立财务规则表展示。" "不再作为独立基础规则表展示。"
) )
asset.config_json = { asset.config_json = {
**(asset.config_json or {}), **(asset.config_json or {}),
@@ -196,7 +475,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"tag": "财务规则", "tag": "基础规则",
"rule_tag": "基础规则",
"tags": ["基础规则"],
"rule_tags": ["基础规则"],
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
@@ -224,7 +506,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"tag": "财务规则", "tag": "基础规则",
"rule_tag": "基础规则",
"tags": ["基础规则"],
"rule_tags": ["基础规则"],
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
@@ -299,65 +584,9 @@ class AgentFoundationSpreadsheetMixin:
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME, file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
fallback_sheet_name="费用申请审批规则", fallback_sheet_name="费用申请审批规则",
tag="申请规则",
workbook_sheets=[ workbook_sheets=build_preapproval_rule_workbook_sheets(),
(
"费用申请审批规则",
[
[
"费用类型代码",
"费用类型",
"触发条件",
"阈值金额",
"前置要求",
"审批要求",
"风险动作",
"备注",
],
[
"meal/entertainment",
"业务招待费",
"单次费用金额大于 500 元",
500,
"必须先提交费用申请单,并说明客户、参与人和招待事由",
"申请单需按审批链完成审批后方可报销",
"报销阶段未关联已通过申请单时标记高风险",
"适配 meal 与 entertainment 两个本体费用类型",
],
[
"office",
"办公用品费",
"单次或批量采购金额大于 2000 元",
2000,
"必须先提交办公采购或费用申请单",
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
"报销阶段未关联已通过申请单时标记高风险",
"覆盖办公用品、办公耗材、低值易耗品等场景",
],
[
"all",
"通用大额费用",
"任意费用金额大于 2000 元",
2000,
"必须进入费用申请和审批流程",
"至少完成直属领导审批;按预算和财务规则继续流转",
"报销阶段未关联已通过申请单时标记高风险",
"差旅、通信等已有专项规则时可同时适用专项规则",
],
],
),
(
"字段说明",
[
["字段", "说明"],
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
["审批要求", "说明申请单进入审批链后的最低审批要求"],
["风险动作", "说明报销阶段未满足规则时的系统处理"],
],
),
],
) )
@@ -385,7 +614,7 @@ class AgentFoundationSpreadsheetMixin:
return live_path.read_bytes() return live_path.read_bytes()
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则") return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
def _ensure_finance_rule_spreadsheet_seed( def _ensure_finance_rule_spreadsheet_seed(
@@ -404,6 +633,7 @@ class AgentFoundationSpreadsheetMixin:
fallback_sheet_name: str, fallback_sheet_name: str,
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None, workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
tag: str = "基础规则",
): ):
@@ -473,7 +703,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"tag": "财务规则", "tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
@@ -501,7 +734,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"tag": "财务规则", "tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,

View File

@@ -11,6 +11,33 @@ from app.services.expense_claim_risk_stage import (
) )
def _normalize_basic_rule_refs(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
refs: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for item in value:
if not isinstance(item, dict):
continue
code = str(item.get("code") or item.get("rule_code") or "").strip()
sheet = str(item.get("sheet") or item.get("rule_sheet") or "").strip()
if not code and not sheet:
continue
key = (code, sheet)
if key in seen:
continue
seen.add(key)
refs.append(
{
"code": code,
"sheet": sheet,
"name": str(item.get("name") or "").strip(),
"component": str(item.get("component") or "").strip(),
}
)
return refs
def build_platform_risk_flag( def build_platform_risk_flag(
manifest: dict[str, Any], manifest: dict[str, Any],
*, *,
@@ -55,6 +82,42 @@ def build_platform_risk_flag(
metadata.get("actionability") or manifest.get("actionability"), metadata.get("actionability") or manifest.get("actionability"),
default_actionability, default_actionability,
) )
finance_rule_code = str(
manifest.get("finance_rule_code")
or metadata.get("finance_rule_code")
or manifest.get("basic_rule_code")
or metadata.get("basic_rule_code")
or ""
).strip()
finance_rule_sheet = str(
manifest.get("finance_rule_sheet")
or metadata.get("finance_rule_sheet")
or manifest.get("basic_rule_sheet")
or metadata.get("basic_rule_sheet")
or ""
).strip()
basic_rule_code = str(
manifest.get("basic_rule_code")
or metadata.get("basic_rule_code")
or finance_rule_code
).strip()
basic_rule_sheet = str(
manifest.get("basic_rule_sheet")
or metadata.get("basic_rule_sheet")
or finance_rule_sheet
).strip()
basic_rule_refs = _normalize_basic_rule_refs(
manifest.get("basic_rule_refs") or metadata.get("basic_rule_refs")
)
if not basic_rule_refs and (basic_rule_code or basic_rule_sheet):
basic_rule_refs = [
{
"code": basic_rule_code,
"sheet": basic_rule_sheet,
"name": "",
"component": "",
}
]
return with_risk_business_stage( return with_risk_business_stage(
{ {
@@ -63,8 +126,11 @@ def build_platform_risk_flag(
"rule_type": "risk", "rule_type": "risk",
"rule_code": str(manifest.get("rule_code") or "").strip(), "rule_code": str(manifest.get("rule_code") or "").strip(),
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(), "rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
"finance_rule_code": str(manifest.get("finance_rule_code") or "").strip(), "basic_rule_code": basic_rule_code,
"finance_rule_sheet": str(manifest.get("finance_rule_sheet") or "").strip(), "basic_rule_sheet": basic_rule_sheet,
"basic_rule_refs": basic_rule_refs,
"finance_rule_code": finance_rule_code,
"finance_rule_sheet": finance_rule_sheet,
"severity": severity, "severity": severity,
"action": action, "action": action,
"label": label, "label": label,

View File

@@ -14,7 +14,9 @@ from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetT
from app.models.agent_asset import AgentAsset, AgentAssetVersion from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
AgentAssetSpreadsheetManager, AgentAssetSpreadsheetManager,
TRAVEL_SPREADSHEET_RULE_CODES,
) )
from app.services.expense_rule_runtime_defaults import ( from app.services.expense_rule_runtime_defaults import (
DEFAULT_SCENE_MATRIX_CONFIG, DEFAULT_SCENE_MATRIX_CONFIG,
@@ -39,6 +41,14 @@ from app.services.expense_rule_runtime_standards import (
build_scene_submission_standard_markdown, build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown, build_travel_risk_control_standard_markdown,
) )
from app.services.expense_rule_runtime_spreadsheet_extractors import (
extract_hotel_season_limits,
extract_normalized_transport_class_limits,
extract_normalized_travel_allowance_limits,
map_transport_grade_row_to_bands,
transport_class_level_for_text,
)
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
class ExpenseRuleRuntimeService: class ExpenseRuleRuntimeService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
@@ -59,15 +69,17 @@ class ExpenseRuleRuntimeService:
assets = [] assets = []
asset_ids = {asset.id for asset in assets} asset_ids = {asset.id for asset in assets}
travel_spreadsheet_asset = self.db.scalar( travel_spreadsheet_assets = list(
self.db.scalars(
select(AgentAsset) select(AgentAsset)
.where(AgentAsset.asset_type == AgentAssetType.RULE.value) .where(AgentAsset.asset_type == AgentAssetType.RULE.value)
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value) .where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) .where(AgentAsset.code.in_(TRAVEL_SPREADSHEET_RULE_CODES))
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc()) .order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
.limit(1) ).all()
) )
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids: for travel_spreadsheet_asset in travel_spreadsheet_assets:
if travel_spreadsheet_asset.id not in asset_ids:
assets.append(travel_spreadsheet_asset) assets.append(travel_spreadsheet_asset)
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = [] spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
@@ -76,7 +88,7 @@ class ExpenseRuleRuntimeService:
if version is None: if version is None:
continue continue
is_travel_spreadsheet_asset = ( is_travel_spreadsheet_asset = (
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE str(asset.code or "").strip() in TRAVEL_SPREADSHEET_RULE_CODES
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet" and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
) )
runtime_payload = self._extract_runtime_payload( runtime_payload = self._extract_runtime_payload(
@@ -173,7 +185,7 @@ class ExpenseRuleRuntimeService:
asset: AgentAsset, asset: AgentAsset,
version: AgentAssetVersion, version: AgentAssetVersion,
) -> None: ) -> None:
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE: if str(asset.code or "").strip() not in TRAVEL_SPREADSHEET_RULE_CODES:
return return
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet": if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
return return
@@ -183,7 +195,9 @@ class ExpenseRuleRuntimeService:
rule_document = (asset.config_json or {}).get("rule_document") rule_document = (asset.config_json or {}).get("rule_document")
if not isinstance(rule_document, dict): if not isinstance(rule_document, dict):
rule_document = {} rule_document = {}
storage_key = str(metadata.storage_key if metadata is not None else "").strip() storage_key = str(rule_document.get("storage_key") or "").strip()
if not storage_key and metadata is not None:
storage_key = str(metadata.storage_key or "").strip()
if storage_key: if storage_key:
try: try:
workbook_path = manager.resolve_storage_path(storage_key) workbook_path = manager.resolve_storage_path(storage_key)
@@ -217,24 +231,48 @@ class ExpenseRuleRuntimeService:
try: try:
standards = self._extract_travel_amount_standards_from_workbook(workbook) standards = self._extract_travel_amount_standards_from_workbook(workbook)
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook) hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
hotel_season_limits = self._extract_hotel_season_limits_from_workbook(workbook)
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook) allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
transport_limits = self._extract_transport_class_limits_from_workbook(workbook) transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
transport_estimates = self._extract_transport_estimates_from_workbook(workbook)
finally: finally:
workbook.close() workbook.close()
standard_rule_version = str( standard_rule_version = str(
rule_document.get("asset_version") or asset.current_version or version.version rule_document.get("asset_version") or asset.current_version or version.version
).strip() ).strip()
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None: if (
hotel_city_limits
or hotel_season_limits.get("hotel_peak_periods")
or hotel_season_limits.get("hotel_peak_city_limits")
or allowance_limits
or transport_limits
or transport_estimates
) and catalog.travel_policy is not None:
payload = catalog.travel_policy.model_dump() payload = catalog.travel_policy.model_dump()
if str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
payload["standard_rule_code"] = asset.code payload["standard_rule_code"] = asset.code
payload["standard_rule_name"] = asset.name payload["standard_rule_name"] = asset.name
payload["standard_rule_version"] = standard_rule_version payload["standard_rule_version"] = standard_rule_version
if str(asset.code or "").strip() == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE:
payload["transport_estimate_rule_code"] = asset.code
payload["transport_estimate_rule_name"] = asset.name
payload["transport_estimate_rule_version"] = standard_rule_version
if hotel_city_limits: if hotel_city_limits:
payload["hotel_city_limits"] = { payload["hotel_city_limits"] = {
**payload.get("hotel_city_limits", {}), **payload.get("hotel_city_limits", {}),
**hotel_city_limits, **hotel_city_limits,
} }
if hotel_season_limits.get("hotel_peak_periods"):
payload["hotel_peak_periods"] = {
**payload.get("hotel_peak_periods", {}),
**hotel_season_limits["hotel_peak_periods"],
}
if hotel_season_limits.get("hotel_peak_city_limits"):
payload["hotel_peak_city_limits"] = {
**payload.get("hotel_peak_city_limits", {}),
**hotel_season_limits["hotel_peak_city_limits"],
}
if allowance_limits: if allowance_limits:
payload["allowance_limits"] = { payload["allowance_limits"] = {
**payload.get("allowance_limits", {}), **payload.get("allowance_limits", {}),
@@ -245,6 +283,12 @@ class ExpenseRuleRuntimeService:
**payload.get("transport_limits", {}), **payload.get("transport_limits", {}),
**transport_limits, **transport_limits,
} }
if transport_estimates:
existing_estimates = list(payload.get("transport_estimates") or [])
payload["transport_estimates"] = [
*existing_estimates,
*transport_estimates,
]
catalog.travel_policy = RuntimeTravelPolicy(**payload) catalog.travel_policy = RuntimeTravelPolicy(**payload)
for expense_type, amount in standards.items(): for expense_type, amount in standards.items():
@@ -317,6 +361,10 @@ class ExpenseRuleRuntimeService:
continue continue
for column_index, header in enumerate(values): for column_index, header in enumerate(values):
compact = re.sub(r"\s+", "", header) compact = re.sub(r"\s+", "", header)
grade_key = ExpenseRuleRuntimeService._extract_exact_grade_header(compact)
if grade_key:
band_indexes[grade_key] = column_index
continue
if any(keyword in compact for keyword in ("P1-P3", "其他员工")): if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
band_indexes["junior"] = column_index band_indexes["junior"] = column_index
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")): if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
@@ -347,6 +395,17 @@ class ExpenseRuleRuntimeService:
city_entry[band] = amount city_entry[band] = amount
return city_limits return city_limits
@staticmethod
def _extract_exact_grade_header(value: str) -> str:
compact = re.sub(r"\s+", "", str(value or "").upper())
if not compact or any(token in compact for token in ("-", "+", "及以上")):
return ""
match = re.match(r"^(P[0-8])(?:级|董事会)?$", compact)
if match is None:
return ""
grade_key = match.group(1)
return grade_key if grade_key in TRAVEL_GRADE_KEYS else ""
@staticmethod @staticmethod
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]: def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
allowance_limits: dict[str, dict[str, Decimal]] = {} allowance_limits: dict[str, dict[str, Decimal]] = {}
@@ -355,6 +414,12 @@ class ExpenseRuleRuntimeService:
if not rows: if not rows:
continue continue
normalized_limits = extract_normalized_travel_allowance_limits(rows)
if normalized_limits:
for allowance_key, region_amounts in normalized_limits.items():
allowance_limits.setdefault(allowance_key, {}).update(region_amounts)
continue
header_index = -1 header_index = -1
type_index = -1 type_index = -1
region_indexes: dict[str, int] = {} region_indexes: dict[str, int] = {}
@@ -393,6 +458,19 @@ class ExpenseRuleRuntimeService:
allowance_limits[allowance_key] = entry allowance_limits[allowance_key] = entry
return allowance_limits return allowance_limits
@staticmethod
def _extract_hotel_season_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Any]]:
peak_periods: dict[str, str] = {}
peak_limits: dict[str, Decimal] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
limits = extract_hotel_season_limits(rows)
peak_periods.update(limits.get("hotel_peak_periods") or {})
peak_limits.update(limits.get("hotel_peak_city_limits") or {})
return {"hotel_peak_periods": peak_periods, "hotel_peak_city_limits": peak_limits}
@staticmethod @staticmethod
def _map_allowance_type_to_key(value: str) -> str: def _map_allowance_type_to_key(value: str) -> str:
normalized = re.sub(r"\s+", "", str(value or "")) normalized = re.sub(r"\s+", "", str(value or ""))
@@ -412,6 +490,12 @@ class ExpenseRuleRuntimeService:
if not rows: if not rows:
continue continue
normalized_limits = extract_normalized_transport_class_limits(rows)
if normalized_limits:
for grade_band, transport_levels in normalized_limits.items():
limits.setdefault(grade_band, {}).update(transport_levels)
continue
employee_index = -1 employee_index = -1
flight_index = -1 flight_index = -1
train_index = -1 train_index = -1
@@ -434,11 +518,11 @@ class ExpenseRuleRuntimeService:
for row in rows: for row in rows:
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else "" employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text) bands = map_transport_grade_row_to_bands(employee_text)
if not bands: if not bands:
continue continue
flight_level = ( flight_level = (
ExpenseRuleRuntimeService._transport_class_level_for_text( transport_class_level_for_text(
row[flight_index] if len(row) > flight_index else None, row[flight_index] if len(row) > flight_index else None,
kind="flight", kind="flight",
) )
@@ -446,7 +530,7 @@ class ExpenseRuleRuntimeService:
else None else None
) )
train_level = ( train_level = (
ExpenseRuleRuntimeService._transport_class_level_for_text( transport_class_level_for_text(
row[train_index] if len(row) > train_index else None, row[train_index] if len(row) > train_index else None,
kind="train", kind="train",
) )
@@ -462,39 +546,121 @@ class ExpenseRuleRuntimeService:
return limits return limits
@staticmethod @staticmethod
def _map_transport_grade_row_to_bands(value: str) -> list[str]: def _extract_transport_estimates_from_workbook(workbook: Any) -> list[dict[str, object]]:
normalized = re.sub(r"\s+", "", str(value or "").upper()) estimates: list[dict[str, object]] = []
if not normalized or normalized.startswith(""): for sheet in workbook.worksheets:
return [] rows = list(sheet.iter_rows(values_only=True))
bands: list[str] = [] if not rows:
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")): continue
bands.extend(["junior", "mid"])
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")): header_index = -1
bands.extend(["mid", "senior", "manager", "executive"]) indexes: dict[str, int] = {}
return list(dict.fromkeys(bands)) header_aliases = {
"origin_city": ("出发城市", "出发地", "起点城市"),
"destination_city": ("目的地", "到达城市", "目的城市"),
"location_band": ("目的地范围", "城市范围", "地区范围"),
"transport_mode": ("交通方式", "出行方式", "交通工具"),
"one_way_amount": ("单程预估金额", "单程金额", "单程费用"),
"round_trip_amount": ("往返预估金额", "往返金额", "往返费用", "预算占用金额"),
"confidence": ("置信度", "匹配级别"),
"basis": ("预算占用口径", "预估依据", "口径说明"),
}
for index, row in enumerate(rows[:10]):
values = [str(value or "").strip() for value in row]
if "交通方式" not in values and "出行方式" not in values:
continue
for key, aliases in header_aliases.items():
for alias in aliases:
if alias in values:
indexes[key] = values.index(alias)
break
if "transport_mode" in indexes and (
"round_trip_amount" in indexes or "one_way_amount" in indexes
):
header_index = index
break
if header_index < 0:
continue
for row in rows[header_index + 1 :]:
mode = ExpenseRuleRuntimeService._map_transport_mode_text(
ExpenseRuleRuntimeService._row_text(row, indexes.get("transport_mode", -1))
)
if not mode:
continue
one_way_amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
row[indexes["one_way_amount"]]
if "one_way_amount" in indexes and len(row) > indexes["one_way_amount"]
else None
)
round_trip_amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
row[indexes["round_trip_amount"]]
if "round_trip_amount" in indexes and len(row) > indexes["round_trip_amount"]
else None
)
if round_trip_amount is None and one_way_amount is not None:
round_trip_amount = (one_way_amount * Decimal("2")).quantize(Decimal("0.01"))
if round_trip_amount is None or round_trip_amount <= Decimal("0.00"):
continue
estimates.append(
{
"origin_city": ExpenseRuleRuntimeService._row_text(
row, indexes.get("origin_city", -1)
),
"destination_city": ExpenseRuleRuntimeService._row_text(
row, indexes.get("destination_city", -1)
),
"location_band": ExpenseRuleRuntimeService._map_location_band_text(
ExpenseRuleRuntimeService._row_text(
row, indexes.get("location_band", -1)
)
),
"transport_mode": mode,
"one_way_amount": one_way_amount or Decimal("0.00"),
"round_trip_amount": round_trip_amount,
"confidence": ExpenseRuleRuntimeService._row_text(
row, indexes.get("confidence", -1)
)
or "basic_rule",
"basis": ExpenseRuleRuntimeService._row_text(
row, indexes.get("basis", -1)
),
}
)
return estimates
@staticmethod @staticmethod
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None: def _row_text(row: Any, index: int) -> str:
if index < 0 or len(row) <= index:
return ""
return str(row[index] or "").strip()
@staticmethod
def _map_transport_mode_text(value: str) -> str:
normalized = re.sub(r"\s+", "", str(value or ""))
if any(keyword in normalized for keyword in ("飞机", "机票", "航班", "经济舱")):
return "飞机"
if any(keyword in normalized for keyword in ("火车", "高铁", "动车", "铁路", "二等座", "硬卧")):
return "火车"
if any(keyword in normalized for keyword in ("轮船", "船票", "客轮", "渡轮", "邮轮")):
return "轮船"
return normalized if normalized in {"飞机", "火车", "轮船"} else ""
@staticmethod
def _map_location_band_text(value: str) -> str:
normalized = re.sub(r"\s+", "", str(value or "")) normalized = re.sub(r"\s+", "", str(value or ""))
if not normalized: if not normalized:
return None return ""
if kind == "flight": if any(keyword in normalized for keyword in ("高频", "一线", "核心", "重点")):
if any(keyword in normalized for keyword in ("头等舱",)): return "premium"
return 4 if any(keyword in normalized for keyword in ("远途", "偏远", "新疆", "西藏", "海南", "港澳台", "海外")):
if any(keyword in normalized for keyword in ("公务舱", "商务舱")): return "remote"
return 3 if any(keyword in normalized for keyword in ("沿海", "海滨", "港口")):
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")): return "coastal"
return 2 if any(keyword in normalized for keyword in ("普通", "默认", "其他")):
if "经济舱" in normalized: return "default"
return 1 return normalized
if kind == "train":
if "商务座" in normalized:
return 3
if any(keyword in normalized for keyword in ("一等座", "软卧")):
return 2
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
return 1
return None
@staticmethod @staticmethod
def _extract_city_names_from_cell(value: str) -> list[str]: def _extract_city_names_from_cell(value: str) -> list[str]:

View File

@@ -230,11 +230,15 @@ DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = {
"晚到店", "晚到店",
], ],
"band_labels": { "band_labels": {
"junior": "P1-P3", "P0": "P0 实习/见习",
"mid": "P4-P5", "P1": "P1 基础员工",
"senior": "P6-P7", "P2": "P2 初级员工",
"manager": "M1-M2", "P3": "P3 普通员工",
"executive": "M3及以上 / D序列", "P4": "P4 资深员工/主管",
"P5": "P5 基层经理",
"P6": "P6 中层经理",
"P7": "P7 高层经理",
"P8": "P8 董事会",
}, },
"city_tiers": { "city_tiers": {
"北京": "tier_1", "北京": "tier_1",
@@ -267,18 +271,26 @@ DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = {
"佛山": "tier_2", "佛山": "tier_2",
}, },
"hotel_limits": { "hotel_limits": {
"junior": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"}, "P0": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"mid": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"}, "P1": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"senior": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"}, "P2": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"manager": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"}, "P3": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"executive": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"}, "P4": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
"P5": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
"P6": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"},
"P7": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"},
"P8": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"},
}, },
"transport_limits": { "transport_limits": {
"junior": {"flight": 1, "train": 1}, "P0": {"flight": 1, "train": 1},
"mid": {"flight": 1, "train": 1}, "P1": {"flight": 1, "train": 1},
"senior": {"flight": 2, "train": 2}, "P2": {"flight": 1, "train": 1},
"manager": {"flight": 3, "train": 3}, "P3": {"flight": 1, "train": 1},
"executive": {"flight": 4, "train": 3}, "P4": {"flight": 1, "train": 1},
"P5": {"flight": 1, "train": 1},
"P6": {"flight": 2, "train": 2},
"P7": {"flight": 3, "train": 3},
"P8": {"flight": 4, "train": 3},
}, },
"flight_classes": [ "flight_classes": [
{"keyword": "头等舱", "level": 4}, {"keyword": "头等舱", "level": 4},

View File

@@ -46,6 +46,17 @@ class TravelClassConfig(BaseModel):
level: int level: int
class TravelTransportEstimateConfig(BaseModel):
origin_city: str = ""
destination_city: str = ""
location_band: str = ""
transport_mode: str
one_way_amount: Decimal = Decimal("0")
round_trip_amount: Decimal = Decimal("0")
confidence: str = "basic_rule"
basis: str = ""
class TravelPolicyConfig(BaseModel): class TravelPolicyConfig(BaseModel):
kind: Literal["travel_policy"] kind: Literal["travel_policy"]
version: int = 1 version: int = 1
@@ -57,11 +68,17 @@ class TravelPolicyConfig(BaseModel):
city_tiers: dict[str, str] = Field(default_factory=dict) city_tiers: dict[str, str] = Field(default_factory=dict)
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict) hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict) hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
hotel_peak_periods: dict[str, str] = Field(default_factory=dict)
hotel_peak_city_limits: dict[str, Decimal] = Field(default_factory=dict)
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict) allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
standard_rule_code: str = "" standard_rule_code: str = ""
standard_rule_name: str = "" standard_rule_name: str = ""
standard_rule_version: str = "" standard_rule_version: str = ""
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict) transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
transport_estimates: list[TravelTransportEstimateConfig] = Field(default_factory=list)
transport_estimate_rule_code: str = ""
transport_estimate_rule_name: str = ""
transport_estimate_rule_version: str = ""
flight_classes: list[TravelClassConfig] = Field(default_factory=list) flight_classes: list[TravelClassConfig] = Field(default_factory=list)
train_classes: list[TravelClassConfig] = Field(default_factory=list) train_classes: list[TravelClassConfig] = Field(default_factory=list)

View File

@@ -0,0 +1,241 @@
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
def extract_normalized_travel_allowance_limits(
rows: list[tuple[Any, ...]],
) -> dict[str, dict[str, Decimal]]:
header_aliases = {
"region": ("补助区域", "适用区域", "地区", "区域"),
"meal": ("伙食补助/天", "伙食补助", "餐补", "餐费补助"),
"basic": ("基本补助/天", "基本出差补贴", "基本补助"),
"total": ("补助合计/天", "补助合计", "合计", "总计"),
}
indexes = _find_header_indexes(rows, header_aliases)
header_index = indexes.pop("__header_index__", -1)
if header_index < 0 or "region" not in indexes:
return {}
allowance_limits: dict[str, dict[str, Decimal]] = {}
for row in rows[header_index + 1 :]:
region = _row_text(row, indexes["region"])
if not region:
continue
for allowance_key in ("meal", "basic", "total"):
column_index = indexes.get(allowance_key)
if column_index is None:
continue
amount = _coerce_decimal_cell(row[column_index] if len(row) > column_index else None)
if amount is not None:
allowance_limits.setdefault(allowance_key, {})[region] = amount
return allowance_limits
def extract_normalized_transport_class_limits(
rows: list[tuple[Any, ...]],
) -> dict[str, dict[str, int]]:
header_aliases = {
"employee": ("职级范围", "员工职级", "职级"),
"flight": ("飞机标准", "飞机", "航班标准"),
"train": ("火车标准", "火车", "铁路标准"),
}
indexes = _find_header_indexes(rows, header_aliases)
header_index = indexes.pop("__header_index__", -1)
if header_index < 0 or "employee" not in indexes:
return {}
limits: dict[str, dict[str, int]] = {}
for row in rows[header_index + 1 :]:
employee_text = _row_text(row, indexes["employee"])
bands = map_transport_grade_row_to_bands(employee_text)
if not bands:
continue
flight_level = (
transport_class_level_for_text(
row[indexes["flight"]] if len(row) > indexes["flight"] else None,
kind="flight",
)
if "flight" in indexes
else None
)
train_level = (
transport_class_level_for_text(
row[indexes["train"]] if len(row) > indexes["train"] else None,
kind="train",
)
if "train" in indexes
else None
)
for band in bands:
entry = limits.setdefault(band, {})
if flight_level is not None:
entry["flight"] = flight_level
if train_level is not None:
entry["train"] = train_level
return limits
def extract_hotel_season_limits(rows: list[tuple[Any, ...]]) -> dict[str, dict[str, Any]]:
header_aliases = {
"city": ("地区(城市)", "城市", "地区"),
"peak_period": ("旺季期间(月)", "旺季期间", "旺季月份"),
"peak_limit": ("旺季超标限额", "旺季住宿上限", "旺季限额"),
}
indexes = _find_header_indexes_for_aliases(rows, header_aliases, required=("city", "peak_period", "peak_limit"))
header_index = indexes.pop("__header_index__", -1)
if header_index < 0:
return {"hotel_peak_periods": {}, "hotel_peak_city_limits": {}}
peak_periods: dict[str, str] = {}
peak_limits: dict[str, Decimal] = {}
for row in rows[header_index + 1 :]:
period = _normalize_peak_period_text(_row_text(row, indexes["peak_period"]))
peak_limit = _coerce_decimal_cell(row[indexes["peak_limit"]] if len(row) > indexes["peak_limit"] else None)
if not period or peak_limit is None:
continue
for city in _split_city_cell(_row_text(row, indexes["city"])):
peak_periods[city] = period
peak_limits[city] = peak_limit
return {"hotel_peak_periods": peak_periods, "hotel_peak_city_limits": peak_limits}
def map_transport_grade_row_to_bands(value: str) -> list[str]:
normalized = re.sub(r"\s+", "", str(value or "").upper())
if not normalized or normalized.startswith(""):
return []
if "-" not in normalized and "+" not in normalized and "及以上" not in normalized:
exact_match = re.search(r"P\s*([0-8])", normalized)
if exact_match:
grade_key = f"P{int(exact_match.group(1))}"
return [grade_key] if grade_key in TRAVEL_GRADE_KEYS else []
bands: list[str] = []
if any(
keyword in normalized
for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")
):
bands.extend(["junior", "mid"])
if any(
keyword in normalized
for keyword in (
"P5",
"P6",
"P7",
"P5及以上",
"中层经理",
"高层经理",
"公司级",
"M3",
"外聘专家",
)
):
bands.extend(["mid", "senior", "manager", "executive"])
return list(dict.fromkeys(bands))
def transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
normalized = re.sub(r"\s+", "", str(value or ""))
if not normalized:
return None
if kind == "flight":
if any(keyword in normalized for keyword in ("头等舱",)):
return 4
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
return 3
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
return 2
if "经济舱" in normalized:
return 1
if kind == "train":
if "商务座" in normalized:
return 3
if any(keyword in normalized for keyword in ("一等座", "软卧")):
return 2
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
return 1
return None
def _find_header_indexes(
rows: list[tuple[Any, ...]],
header_aliases: dict[str, tuple[str, ...]],
) -> dict[str, int]:
for index, row in enumerate(rows[:10]):
indexes: dict[str, int] = {}
values = [str(value or "").strip() for value in row]
for key, aliases in header_aliases.items():
for alias in aliases:
if alias in values:
indexes[key] = values.index(alias)
break
if _has_required_normalized_header(indexes):
indexes["__header_index__"] = index
return indexes
return {}
def _find_header_indexes_for_aliases(
rows: list[tuple[Any, ...]],
header_aliases: dict[str, tuple[str, ...]],
*,
required: tuple[str, ...],
) -> dict[str, int]:
for index, row in enumerate(rows[:10]):
indexes: dict[str, int] = {}
values = [str(value or "").strip() for value in row]
for key, aliases in header_aliases.items():
for alias in aliases:
if alias in values:
indexes[key] = values.index(alias)
break
if all(key in indexes for key in required):
indexes["__header_index__"] = index
return indexes
return {}
def _has_required_normalized_header(indexes: dict[str, int]) -> bool:
return (
"region" in indexes
and any(key in indexes for key in ("meal", "basic", "total"))
) or ("employee" in indexes and any(key in indexes for key in ("flight", "train")))
def _row_text(row: tuple[Any, ...], index: int) -> str:
if index < 0 or len(row) <= index:
return ""
return str(row[index] or "").strip()
def _coerce_decimal_cell(value: Any) -> Decimal | None:
if value is None:
return None
try:
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
except (ArithmeticError, ValueError):
return None
def _split_city_cell(value: str) -> list[str]:
normalized = re.sub(r"[;,、/]+", "", str(value or "").strip())
if not normalized:
return []
names: list[str] = []
for part in normalized.split(""):
cleaned = re.sub(r"\s+", "", part)
cleaned = re.sub(r"[(].*?[)]", "", cleaned)
if cleaned and len(cleaned) <= 12:
names.append(cleaned.removesuffix(""))
return list(dict.fromkeys(names))
def _normalize_peak_period_text(value: str) -> str:
text = re.sub(r"\s+", "", str(value or ""))
text = re.sub(r"(月|上旬|中旬|下旬)", "", text)
text = re.sub(r"[、,;;]+", ",", text)
text = re.sub(r"[^0-9,\-]", "", text)
return re.sub(r",{2,}", ",", text).strip(",")

View File

@@ -34,6 +34,57 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"applicationAmount", "applicationAmount",
"application_amount_label", "application_amount_label",
"applicationAmountLabel", "applicationAmountLabel",
"application_budget_occupied_amount",
"applicationBudgetOccupiedAmount",
"application_policy_total_amount",
"applicationPolicyTotalAmount",
),
"transport_estimated_amount": (
"application_transport_estimated_amount",
"applicationTransportEstimatedAmount",
"transportEstimatedAmount",
"transport_estimate_amount",
"transportEstimateAmount",
),
"train_estimated_amount": (
"application_train_estimated_amount",
"applicationTrainEstimatedAmount",
"trainEstimatedAmount",
),
"flight_estimated_amount": (
"application_flight_estimated_amount",
"applicationFlightEstimatedAmount",
"flightEstimatedAmount",
),
"hotel_amount": (
"application_hotel_amount",
"applicationHotelAmount",
"lodging_amount",
"lodgingAmount",
"hotelAmount",
),
"allowance_amount": (
"application_allowance_amount",
"applicationAllowanceAmount",
"subsidy_amount",
"subsidyAmount",
"allowanceAmount",
),
"policy_total_amount": (
"application_policy_total_amount",
"applicationPolicyTotalAmount",
"application_budget_occupied_amount",
"applicationBudgetOccupiedAmount",
"policyTotalAmount",
"budget_occupied_amount",
"budgetOccupiedAmount",
),
"reimbursement_amount": (
"application_reimbursement_amount",
"applicationReimbursementAmount",
"actual_reimbursement_amount",
"actualReimbursementAmount",
"reimbursementAmount",
), ),
"transport_mode": ( "transport_mode": (
"transport_type", "transport_type",
@@ -42,6 +93,15 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"application_transport_mode", "application_transport_mode",
"applicationTransportMode", "applicationTransportMode",
), ),
"document_type": ("documentType", "ocr_document_type", "ocrDocumentType"),
"invoice_no": ("invoiceNo", "invoice_number", "invoiceNumber", "ocr_invoice_no", "ocrInvoiceNo"),
"invoice_date": ("invoiceDate", "issue_date", "issueDate", "ocr_invoice_date", "ocrInvoiceDate"),
"ticket_no": ("ticketNo", "ticket_number", "ticketNumber", "ocr_ticket_no", "ocrTicketNo"),
"ticket_type": ("ticketType", "ocr_ticket_type", "ocrTicketType"),
"origin_location": ("originLocation", "departure_location", "departureLocation", "from_city", "fromCity"),
"destination_location": ("destinationLocation", "arrival_location", "arrivalLocation", "to_city", "toCity"),
"hotel_name": ("hotelName", "ocr_hotel_name", "ocrHotelName"),
"hotel_nights": ("hotelNights", "stay_nights", "stayNights"),
"attachments": ("attachment_names", "attachmentNames"), "attachments": ("attachment_names", "attachmentNames"),
"customer_name": ("customerName",), "customer_name": ("customerName",),
"merchant_name": ("merchantName",), "merchant_name": ("merchantName",),
@@ -54,6 +114,10 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"manager_name": ("managerName", "direct_manager_name", "directManagerName"), "manager_name": ("managerName", "direct_manager_name", "directManagerName"),
"finance_owner_name": ("financeOwnerName",), "finance_owner_name": ("financeOwnerName",),
"finance_approver_name": ("financeApproverName",), "finance_approver_name": ("financeApproverName",),
"basic_rule_code": ("basicRuleCode", "finance_rule_code", "financeRuleCode"),
"basic_rule_sheet": ("basicRuleSheet", "finance_rule_sheet", "financeRuleSheet"),
"basic_rule_name": ("basicRuleName", "finance_rule_name", "financeRuleName"),
"basic_rule_version": ("basicRuleVersion", "finance_rule_version", "financeRuleVersion"),
} }
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset( CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
@@ -69,14 +133,36 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"employee_location", "employee_location",
"employee_risk_profile", "employee_risk_profile",
"document_id", "document_id",
"invoice_no",
"invoice_date",
"ticket_no",
"ticket_type",
"origin_location",
"destination_location",
"hotel_name",
"hotel_nights",
"application_claim_id", "application_claim_id",
"application_claim_no", "application_claim_no",
"application_status",
"application_amount",
"application_approved_amount",
"application_budget_occupied_amount",
"application_reimbursement_amount",
"application_expense_type",
"application_days", "application_days",
"application_date", "application_date",
"application_required",
"preapproval_required",
"application_lodging_daily_cap", "application_lodging_daily_cap",
"application_subsidy_daily_cap", "application_subsidy_daily_cap",
"application_transport_policy", "application_transport_policy",
"application_policy_estimate", "application_policy_estimate",
"application_transport_estimated_amount",
"application_train_estimated_amount",
"application_flight_estimated_amount",
"application_hotel_amount",
"application_allowance_amount",
"application_policy_total_amount",
"application_rule_name", "application_rule_name",
"application_rule_version", "application_rule_version",
"original_amount", "original_amount",

View File

@@ -2,6 +2,18 @@ from __future__ import annotations
from app.schemas.ontology import OntologyParseResult from app.schemas.ontology import OntologyParseResult
PREAPPROVAL_RULE_CODES = [
"risk.application.meal_high_value_without_preapproval",
"risk.application.office_bulk_without_purchase",
"risk.application.large_expense_without_preapproval",
]
APPLICATION_REQUIRED_RULE_CODES = [
*PREAPPROVAL_RULE_CODES,
"risk.application.travel_large_without_preapproval",
"risk.application.marketing_without_campaign",
]
RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = { RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
"location_mismatch": ["risk.travel.destination_receipt_location"], "location_mismatch": ["risk.travel.destination_receipt_location"],
"base_location_overlap": ["risk.travel.base_location_overlap"], "base_location_overlap": ["risk.travel.base_location_overlap"],
@@ -18,6 +30,9 @@ RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
"meal_as_travel": ["risk.expense.meal_localized_as_travel"], "meal_as_travel": ["risk.expense.meal_localized_as_travel"],
"consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"], "consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"],
"reason_too_brief": ["risk.expense.reason_too_brief"], "reason_too_brief": ["risk.expense.reason_too_brief"],
"application_required": APPLICATION_REQUIRED_RULE_CODES,
"application_absent": APPLICATION_REQUIRED_RULE_CODES,
"preapproval_absent": PREAPPROVAL_RULE_CODES,
} }
TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = { TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
@@ -32,6 +47,29 @@ TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
"meal_as_travel": ("餐费", "差旅餐", "本地餐"), "meal_as_travel": ("餐费", "差旅餐", "本地餐"),
"consecutive_transport_receipts": ("连续交通", "多张车票", "打车"), "consecutive_transport_receipts": ("连续交通", "多张车票", "打车"),
"reason_too_brief": ("事由", "说明太短", "理由不足"), "reason_too_brief": ("事由", "说明太短", "理由不足"),
"application_required": (
"前置申请",
"事前申请",
"事前审批",
"费用申请",
"申请审批",
"无申请",
"未申请",
"缺少申请",
"没有申请",
"未审批",
),
"preapproval_absent": (
"无前置申请",
"未做申请",
"未提交申请",
"未走审批",
"大额费用",
"业务招待",
"招待费",
"办公采购",
"办公用品",
),
} }

View File

@@ -0,0 +1,378 @@
from __future__ import annotations
from typing import Any
from app.schemas.steward import (
StewardCandidateFlow,
StewardFlowStatePatch,
StewardPendingFlowConfirmation,
StewardPlanResponse,
StewardTask,
)
from app.services.ontology_field_registry import (
CANONICAL_ONTOLOGY_FIELDS,
normalize_ontology_form_values,
)
class StewardFlowStateService:
"""维护小财管家跨轮对话的本体业务状态。"""
EVENT_LIMIT = 80
def merge_state(
self,
current_state: dict[str, Any] | None,
patch: StewardFlowStatePatch,
) -> dict[str, Any]:
state = self._normalize_state(current_state)
flow = dict(state["flows"].get(patch.flow_id) or {})
existing_fields = dict(flow.get("fields") or {})
next_fields = {
**existing_fields,
**self._normalize_fields(patch.fields),
}
flow.update(
{
"flow_id": patch.flow_id,
"status": str(patch.status or "collecting").strip() or "collecting",
"intent": str(patch.intent or flow.get("intent") or "").strip(),
"fields": next_fields,
"missing_fields": self._normalize_missing_fields(patch.missing_fields),
}
)
if patch.application_claim_id:
flow["application_claim_id"] = patch.application_claim_id
if patch.linked_application_claim_id:
flow["linked_application_claim_id"] = patch.linked_application_claim_id
if patch.attachments:
flow["attachments"] = self._merge_attachments(flow.get("attachments"), patch.attachments)
state["active_flow"] = patch.active_flow
state["flows"][patch.flow_id] = flow
state["events"] = self._append_event(state.get("events"), patch, flow)
return state
def merge_plan(
self,
current_state: dict[str, Any] | None,
plan: StewardPlanResponse,
) -> dict[str, Any]:
state = self._normalize_state(current_state)
if plan.pending_flow_confirmation.status == "pending":
state = self._merge_pending_flow_confirmation(
state,
plan.pending_flow_confirmation,
next_action=plan.next_action,
)
for task in plan.tasks:
state = self.merge_state(
state,
self._build_patch_from_task(
task,
linked_application_claim_id=self._resolve_application_claim_id(state),
attachments=self._resolve_task_attachments(plan, task.task_id),
),
)
return state
@staticmethod
def _normalize_state(current_state: dict[str, Any] | None) -> dict[str, Any]:
source = current_state if isinstance(current_state, dict) else {}
flows = source.get("flows") if isinstance(source.get("flows"), dict) else {}
events = source.get("events") if isinstance(source.get("events"), list) else []
return {
"version": str(source.get("version") or "steward.flow_state.v2"),
"active_flow": str(source.get("active_flow") or "").strip(),
"flows": dict(flows),
"pending_flow_confirmation": (
dict(source.get("pending_flow_confirmation"))
if isinstance(source.get("pending_flow_confirmation"), dict)
else {
"status": "none",
"source_message": "",
"reason": "",
"candidate_flows": [],
}
),
"next_action": str(source.get("next_action") or "").strip(),
"events": list(events),
}
def confirm_flow(
self,
current_state: dict[str, Any],
flow_id: str,
) -> dict[str, Any]:
state = self._normalize_state(current_state)
if flow_id not in {"travel_application", "travel_reimbursement"}:
return state
flows = state["flows"] if isinstance(state.get("flows"), dict) else {}
flow = dict(flows.get(flow_id) or {})
flow["flow_id"] = flow_id
flow["status"] = "collecting" if flow.get("missing_fields") else "ready_for_confirmation"
flows[flow_id] = flow
state["flows"] = flows
state["active_flow"] = flow_id
state["next_action"] = "continue_selected_flow"
pending = dict(state.get("pending_flow_confirmation") or {})
pending["status"] = "confirmed"
pending["confirmed_flow_id"] = flow_id
state["pending_flow_confirmation"] = pending
state["events"] = self._append_flow_confirmation_event(state.get("events"), flow_id)
return state
def _merge_pending_flow_confirmation(
self,
state: dict[str, Any],
pending: StewardPendingFlowConfirmation,
*,
next_action: str,
) -> dict[str, Any]:
candidate_flows = [
self._serialize_candidate_flow(candidate)
for candidate in pending.candidate_flows
]
state["version"] = "steward.flow_state.v2"
state["active_flow"] = ""
state["next_action"] = str(next_action or "confirm_flow")
state["pending_flow_confirmation"] = {
"status": pending.status,
"source_message": pending.source_message,
"reason": pending.reason,
"candidate_flows": candidate_flows,
}
flows = state["flows"] if isinstance(state.get("flows"), dict) else {}
for candidate in pending.candidate_flows:
flow = dict(flows.get(candidate.flow_id) or {})
flow.update(
{
"flow_id": candidate.flow_id,
"intent": self._resolve_candidate_intent(candidate.flow_id),
"status": "pending_flow_confirmation",
"fields": self._normalize_fields(candidate.ontology_fields),
"missing_fields": self._normalize_missing_fields(candidate.missing_fields),
"confidence": candidate.confidence,
"evidence": [
{
"source": "pending_flow_confirmation",
"field": key,
"text": candidate.reason or pending.reason,
}
for key in candidate.ontology_fields
],
}
)
flows[candidate.flow_id] = flow
state["flows"] = flows
state["events"] = self._append_pending_flow_event(state.get("events"), pending)
return state
@staticmethod
def _serialize_candidate_flow(candidate: StewardCandidateFlow) -> dict[str, Any]:
return {
"flow_id": candidate.flow_id,
"label": candidate.label,
"confidence": candidate.confidence,
"reason": candidate.reason,
"ontology_fields": dict(candidate.ontology_fields),
"missing_fields": list(candidate.missing_fields),
}
@staticmethod
def _resolve_candidate_intent(flow_id: str) -> str:
return (
"travel_application_create"
if flow_id == "travel_application"
else "travel_reimbursement_draft"
)
@staticmethod
def _build_patch_from_task(
task: StewardTask,
*,
linked_application_claim_id: str = "",
attachments: list[dict[str, Any]] | None = None,
) -> StewardFlowStatePatch:
if task.task_type == "expense_application":
flow_id = "travel_application"
intent = "travel_application_create"
link_id = ""
else:
flow_id = "travel_reimbursement"
intent = "travel_reimbursement_draft"
link_id = linked_application_claim_id
return StewardFlowStatePatch(
active_flow=flow_id,
flow_id=flow_id,
intent=intent,
status="collecting" if task.missing_fields else "ready_for_confirmation",
fields=task.ontology_fields,
missing_fields=task.missing_fields,
linked_application_claim_id=link_id,
attachments=attachments or [],
evidence=[
{
"source": "steward_plan",
"field": key,
"text": task.summary,
}
for key in task.ontology_fields
],
)
@staticmethod
def _resolve_application_claim_id(state: dict[str, Any]) -> str:
flows = state.get("flows") if isinstance(state.get("flows"), dict) else {}
application_flow = flows.get("travel_application") if isinstance(flows, dict) else {}
if not isinstance(application_flow, dict):
return ""
return str(application_flow.get("application_claim_id") or "").strip()
@staticmethod
def _resolve_task_attachments(
plan: StewardPlanResponse,
task_id: str,
) -> list[dict[str, Any]]:
attachments: list[dict[str, Any]] = []
for group in plan.attachment_groups:
if group.target_task_id != task_id:
continue
for name in group.attachment_names:
normalized = str(name or "").strip()
if normalized:
attachments.append({"name": normalized, "source": "steward_attachment_group"})
return attachments
@staticmethod
def _normalize_fields(fields: dict[str, Any]) -> dict[str, str]:
normalized = normalize_ontology_form_values(fields)
return {
key: value
for key, value in normalized.items()
if key in CANONICAL_ONTOLOGY_FIELDS and str(value or "").strip()
}
@staticmethod
def _normalize_missing_fields(fields: list[str]) -> list[str]:
normalized: list[str] = []
for field in fields:
key = str(field or "").strip()
if key in CANONICAL_ONTOLOGY_FIELDS and key not in normalized:
normalized.append(key)
return normalized
@staticmethod
def _merge_attachments(
current_attachments: Any,
incoming_attachments: list[dict[str, Any]],
) -> list[dict[str, Any]]:
attachments = [
dict(item)
for item in current_attachments
if isinstance(item, dict)
] if isinstance(current_attachments, list) else []
seen = {
str(item.get("file_id") or item.get("name") or "").strip()
for item in attachments
if str(item.get("file_id") or item.get("name") or "").strip()
}
for item in incoming_attachments:
if not isinstance(item, dict):
continue
key = str(item.get("file_id") or item.get("name") or "").strip()
if key and key in seen:
continue
attachments.append(dict(item))
if key:
seen.add(key)
return attachments
def _append_event(
self,
current_events: Any,
patch: StewardFlowStatePatch,
flow: dict[str, Any],
) -> list[dict[str, Any]]:
events = [
dict(item)
for item in current_events
if isinstance(item, dict)
] if isinstance(current_events, list) else []
events.append(
{
"sequence": len(events) + 1,
"flow_id": patch.flow_id,
"active_flow": patch.active_flow,
"intent": str(patch.intent or flow.get("intent") or "").strip(),
"status": str(flow.get("status") or "").strip(),
"fields": self._normalize_fields(patch.fields),
"missing_fields": list(flow.get("missing_fields") or []),
"evidence": [
dict(item)
for item in patch.evidence
if isinstance(item, dict)
],
}
)
return events[-self.EVENT_LIMIT :]
def _append_pending_flow_event(
self,
current_events: Any,
pending: StewardPendingFlowConfirmation,
) -> list[dict[str, Any]]:
events = [
dict(item)
for item in current_events
if isinstance(item, dict)
] if isinstance(current_events, list) else []
events.append(
{
"sequence": len(events) + 1,
"flow_id": "",
"active_flow": "",
"intent": "pending_flow_confirmation",
"status": pending.status,
"fields": {},
"missing_fields": [],
"candidate_flows": [
self._serialize_candidate_flow(candidate)
for candidate in pending.candidate_flows
],
"evidence": [
{
"source": "steward_plan",
"text": pending.source_message,
}
],
}
)
return events[-self.EVENT_LIMIT :]
def _append_flow_confirmation_event(
self,
current_events: Any,
flow_id: str,
) -> list[dict[str, Any]]:
events = [
dict(item)
for item in current_events
if isinstance(item, dict)
] if isinstance(current_events, list) else []
events.append(
{
"sequence": len(events) + 1,
"flow_id": flow_id,
"active_flow": flow_id,
"intent": "flow_confirmed",
"status": "confirmed",
"fields": {},
"missing_fields": [],
"evidence": [{"source": "runtime_user_selection", "text": flow_id}],
}
)
return events[-self.EVENT_LIMIT :]

View File

@@ -108,6 +108,9 @@ class StewardIntentAgent:
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时," "用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。" "即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。" "用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。"
"如果用户只描述出差时间、地点和事由,但没有明确申请、报销、提交、保存草稿等动作,"
"且无法从上下文判断流程方向,必须返回 pending_flow_confirmation.status=pending"
"candidate_flows 同时给出 travel_application 和 travel_reimbursementtasks 保持空数组。"
"所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields" "所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields"
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。" "如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
"相对日期必须以 base_date 为准转换为明确日期。" "相对日期必须以 base_date 为准转换为明确日期。"
@@ -180,6 +183,56 @@ class StewardIntentAgent:
], ],
}, },
}, },
"pending_flow_confirmation": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["none", "pending"],
},
"source_message": {"type": "string"},
"reason": {"type": "string"},
"candidate_flows": {
"type": "array",
"items": {
"type": "object",
"properties": {
"flow_id": {
"type": "string",
"enum": ["travel_application", "travel_reimbursement"],
},
"label": {"type": "string"},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
},
"reason": {"type": "string"},
"ontology_fields": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"missing_fields": {
"type": "array",
"items": {
"type": "string",
"enum": canonical_fields,
},
},
},
"required": [
"flow_id",
"label",
"confidence",
"reason",
"ontology_fields",
"missing_fields",
],
},
},
},
"required": ["status", "source_message", "reason", "candidate_flows"],
},
"attachment_groups": { "attachment_groups": {
"type": "array", "type": "array",
"items": { "items": {

View File

@@ -8,6 +8,8 @@ from typing import Any
from app.schemas.steward import ( from app.schemas.steward import (
StewardAttachmentGroup, StewardAttachmentGroup,
StewardAttachmentInput, StewardAttachmentInput,
StewardCandidateFlow,
StewardPendingFlowConfirmation,
StewardPlanRequest, StewardPlanRequest,
StewardPlanResponse, StewardPlanResponse,
StewardTask, StewardTask,
@@ -31,7 +33,18 @@ class StewardModelPlanBuilder:
request: StewardPlanRequest, request: StewardPlanRequest,
base_date: date, base_date: date,
) -> StewardPlanResponse | None: ) -> StewardPlanResponse | None:
pending_flow_confirmation = self._build_pending_flow_confirmation(
intent_result.payload,
request=request,
base_date=base_date,
)
tasks = self._build_tasks_from_model_payload(intent_result.payload, request, base_date) tasks = self._build_tasks_from_model_payload(intent_result.payload, request, base_date)
if not tasks and pending_flow_confirmation.status == "pending":
return self._build_pending_flow_plan(
pending_flow_confirmation,
intent_result,
request=request,
)
if not tasks: if not tasks:
return None return None
@@ -54,11 +67,33 @@ class StewardModelPlanBuilder:
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}", plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate", plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
planning_source="llm_function_call", planning_source="llm_function_call",
next_action="confirm_task" if confirmation_groups else "delegate_task",
summary=self.planner._build_summary(tasks, attachment_groups), summary=self.planner._build_summary(tasks, attachment_groups),
thinking_events=thinking_events, thinking_events=thinking_events,
tasks=tasks, tasks=tasks,
attachment_groups=attachment_groups, attachment_groups=attachment_groups,
confirmation_groups=confirmation_groups, confirmation_groups=confirmation_groups,
pending_flow_confirmation=pending_flow_confirmation,
candidate_flows=pending_flow_confirmation.candidate_flows,
model_call_traces=intent_result.model_call_traces,
)
def _build_pending_flow_plan(
self,
pending_flow_confirmation: StewardPendingFlowConfirmation,
intent_result: StewardIntentAgentResult,
*,
request: StewardPlanRequest,
) -> StewardPlanResponse:
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="needs_flow_confirmation",
planning_source="llm_function_call",
next_action="confirm_flow",
summary=self._build_pending_flow_summary(pending_flow_confirmation),
thinking_events=self._build_pending_flow_thinking_events(intent_result.payload, request),
pending_flow_confirmation=pending_flow_confirmation,
candidate_flows=pending_flow_confirmation.candidate_flows,
model_call_traces=intent_result.model_call_traces, model_call_traces=intent_result.model_call_traces,
) )
@@ -144,6 +179,134 @@ class StewardModelPlanBuilder:
return tasks return tasks
def _build_pending_flow_confirmation(
self,
payload: dict[str, Any],
*,
request: StewardPlanRequest,
base_date: date,
) -> StewardPendingFlowConfirmation:
raw_pending = payload.get("pending_flow_confirmation")
raw_candidates = payload.get("candidate_flows")
if isinstance(raw_pending, dict):
raw_candidates = raw_pending.get("candidate_flows", raw_candidates)
status = self.planner._clean_text(raw_pending.get("status")) or "pending"
source_message = self.planner._clean_text(raw_pending.get("source_message")) or request.message
reason = self.planner._clean_text(raw_pending.get("reason"))
else:
status = "pending" if isinstance(raw_candidates, list) and raw_candidates else "none"
source_message = request.message
reason = ""
candidates = self._build_candidate_flows(raw_candidates, request=request, base_date=base_date)
if status != "pending" or not candidates:
return StewardPendingFlowConfirmation()
return StewardPendingFlowConfirmation(
status="pending",
source_message=source_message,
reason=reason or "当前话术同时可能进入申请或报销流程,需要先请用户确认。",
candidate_flows=candidates,
)
def _build_candidate_flows(
self,
raw_candidates: Any,
*,
request: StewardPlanRequest,
base_date: date,
) -> list[StewardCandidateFlow]:
if not isinstance(raw_candidates, list):
return []
candidates: list[StewardCandidateFlow] = []
for raw_candidate in raw_candidates:
if not isinstance(raw_candidate, dict):
continue
flow_id = self.planner._clean_text(raw_candidate.get("flow_id"))
if flow_id not in {"travel_application", "travel_reimbursement"}:
continue
task_type = "expense_application" if flow_id == "travel_application" else "reimbursement"
fields = self._sanitize_model_ontology_fields(
raw_candidate.get("ontology_fields"),
request=request,
base_date=base_date,
)
if not fields:
fields = self.planner._extract_ontology_fields(
request.message,
task_type,
base_date,
request,
)
missing_fields = self._sanitize_model_missing_fields(
raw_candidate.get("missing_fields"),
task_type=task_type,
fields=fields,
)
label = self.planner._clean_text(raw_candidate.get("label")) or (
"补办出差申请" if flow_id == "travel_application" else "发起费用报销"
)
candidates.append(
StewardCandidateFlow(
flow_id=flow_id, # type: ignore[arg-type]
label=label,
confidence=self._clamp_confidence(raw_candidate.get("confidence"), default=0.5),
reason=self.planner._clean_text(raw_candidate.get("reason")),
ontology_fields=fields,
missing_fields=missing_fields,
)
)
return candidates[:2]
def _build_pending_flow_thinking_events(
self,
payload: dict[str, Any],
request: StewardPlanRequest,
) -> list[StewardThinkingEvent]:
events = [
StewardThinkingEvent(
event_id="intent_agent_function_call",
stage="llm_function_call",
title="识别财务事项",
content="我识别到这句话包含出差事项,但还需要确认你要进入申请流程还是报销流程。",
)
]
raw_events = payload.get("thinking_events")
if isinstance(raw_events, list):
for raw_event in raw_events[:4]:
if not isinstance(raw_event, dict):
continue
title = self.planner._clean_text(raw_event.get("title"))
content = self.planner._clean_text(raw_event.get("content"))
if not title or not content:
continue
events.append(
StewardThinkingEvent(
event_id=f"intent_agent_model_{len(events):03d}",
stage=self.planner._clean_text(raw_event.get("stage")) or "flow_confirmation",
title=title,
content=content,
)
)
if len(events) == 1:
events.append(
StewardThinkingEvent(
event_id="intent_agent_pending_flow",
stage="flow_confirmation",
title="等待确认流程方向",
content=f"当前输入“{request.message}”缺少明确动作词,需要先由你选择补办出差申请或发起费用报销。",
)
)
return events
@staticmethod
def _build_pending_flow_summary(pending_flow_confirmation: StewardPendingFlowConfirmation) -> str:
candidate_labels = [item.label for item in pending_flow_confirmation.candidate_flows if item.label]
if len(candidate_labels) >= 2:
return (
f"我识别到这是一次财务事项,但还不能确定你要做的是"
f"**{candidate_labels[0]}**还是**{candidate_labels[1]}**。请先选择一个方向。"
)
return "我识别到这是一次财务事项,但还需要先确认具体流程方向。"
def _sanitize_model_ontology_fields( def _sanitize_model_ontology_fields(
self, self,
raw_fields: Any, raw_fields: Any,

View File

@@ -9,7 +9,9 @@ from typing import Any
from app.schemas.steward import ( from app.schemas.steward import (
StewardAttachmentGroup, StewardAttachmentGroup,
StewardAttachmentInput, StewardAttachmentInput,
StewardCandidateFlow,
StewardConfirmationAction, StewardConfirmationAction,
StewardPendingFlowConfirmation,
StewardPlanRequest, StewardPlanRequest,
StewardPlanResponse, StewardPlanResponse,
StewardTask, StewardTask,
@@ -107,7 +109,7 @@ class StewardPlannerService:
base_date = self._resolve_base_date(request.client_now_iso, request.context_json) base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
model_call_traces: list[dict[str, Any]] = [] model_call_traces: list[dict[str, Any]] = []
fallback_reason = "" fallback_reason = ""
if self.intent_agent is not None: if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
try: try:
intent_result = self.intent_agent.detect( intent_result = self.intent_agent.detect(
request, request,
@@ -122,6 +124,17 @@ class StewardPlannerService:
base_date=base_date, base_date=base_date,
) )
if llm_plan is not None: if llm_plan is not None:
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return self._build_pending_flow_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=(
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
"服务端已改为候选流程确认,避免误入申请流程。"
),
planning_source="llm_function_call",
)
return llm_plan return llm_plan
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。" fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
@@ -136,6 +149,16 @@ class StewardPlannerService:
fallback_reason=fallback_reason, fallback_reason=fallback_reason,
) )
def _should_use_model_intent_recognition(
self,
message: str,
base_date: date,
request: StewardPlanRequest,
) -> bool:
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return False
return self._has_multiple_financial_demands(message)
def _build_rule_fallback_plan( def _build_rule_fallback_plan(
self, self,
request: StewardPlanRequest, request: StewardPlanRequest,
@@ -145,6 +168,13 @@ class StewardPlannerService:
fallback_reason: str = "", fallback_reason: str = "",
) -> StewardPlanResponse: ) -> StewardPlanResponse:
message = self._clean_text(request.message) message = self._clean_text(request.message)
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return self._build_pending_flow_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=fallback_reason,
)
task_drafts = self._extract_task_drafts(message) task_drafts = self._extract_task_drafts(message)
tasks = [self._build_task(draft, base_date, request) for draft in task_drafts] tasks = [self._build_task(draft, base_date, request) for draft in task_drafts]
if not tasks: if not tasks:
@@ -169,6 +199,7 @@ class StewardPlannerService:
plan_id=plan_id, plan_id=plan_id,
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate", plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
planning_source="rule_fallback", planning_source="rule_fallback",
next_action="confirm_task" if confirmation_groups else "delegate_task",
summary=self._build_summary(tasks, attachment_groups), summary=self._build_summary(tasks, attachment_groups),
thinking_events=thinking_events, thinking_events=thinking_events,
tasks=tasks, tasks=tasks,
@@ -177,6 +208,91 @@ class StewardPlannerService:
model_call_traces=model_call_traces or [], model_call_traces=model_call_traces or [],
) )
def _build_pending_flow_fallback_plan(
self,
request: StewardPlanRequest,
*,
base_date: date,
model_call_traces: list[dict[str, Any]] | None = None,
fallback_reason: str = "",
planning_source: str = "rule_fallback",
) -> StewardPlanResponse:
candidates = self._build_rule_candidate_flows(request, base_date)
pending = StewardPendingFlowConfirmation(
status="pending",
source_message=request.message,
reason="当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。",
candidate_flows=candidates,
)
thinking_events = []
if fallback_reason:
thinking_events.append(
StewardThinkingEvent(
event_id="intent_agent_rule_fallback",
stage="rule_fallback",
title="意图识别智能体进入兜底模式",
content=fallback_reason,
)
)
thinking_events.append(
StewardThinkingEvent(
event_id="intent_pending_flow_confirmation",
stage="flow_confirmation",
title="需要确认流程方向",
content="我识别到时间、地点和出差事由,但没有识别到明确的申请或报销动作,需要先请你选择流程方向。",
)
)
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="needs_flow_confirmation",
planning_source=planning_source, # type: ignore[arg-type]
next_action="confirm_flow",
summary=(
"我识别到这是一次出差事项,但还不能确定你要做的是"
"**补办出差申请**还是**发起费用报销**。请先选择一个方向。"
),
thinking_events=thinking_events,
pending_flow_confirmation=pending,
candidate_flows=candidates,
model_call_traces=model_call_traces or [],
)
def _build_rule_candidate_flows(
self,
request: StewardPlanRequest,
base_date: date,
) -> list[StewardCandidateFlow]:
application_fields = self._extract_ontology_fields(
request.message,
"expense_application",
base_date,
request,
)
reimbursement_fields = self._extract_ontology_fields(
request.message,
"reimbursement",
base_date,
request,
)
return [
StewardCandidateFlow(
flow_id="travel_application",
label="补办出差申请",
confidence=0.52,
reason="用户描述了出差时间、地点和事由,但没有明确说要报销。",
ontology_fields=application_fields,
missing_fields=self._resolve_missing_fields("expense_application", application_fields),
),
StewardCandidateFlow(
flow_id="travel_reimbursement",
label="发起费用报销",
confidence=0.48,
reason="用户描述的也可能是已发生出差事项,需要进入报销材料整理。",
ontology_fields=reimbursement_fields,
missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields),
),
]
def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]: def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]:
drafts: list[PlannedTaskDraft] = [] drafts: list[PlannedTaskDraft] = []
first_reimbursement = self._find_first_reimbursement_index(message) first_reimbursement = self._find_first_reimbursement_index(message)
@@ -202,6 +318,24 @@ class StewardPlannerService:
return drafts return drafts
def _has_multiple_financial_demands(self, message: str) -> bool:
task_drafts = self._extract_task_drafts(message)
if len(task_drafts) > 1:
return True
compact = re.sub(r"\s+", "", message)
if not compact:
return False
application_signal = self._looks_like_application(compact) or self._looks_like_future_travel_application(compact)
reimbursement_signal = self._find_first_reimbursement_index(compact) >= 0
if application_signal and reimbursement_signal:
return True
connector_signal = re.search(r"并且|同时|另外|还有|还要|以及|再", compact)
repeated_reimbursement_signal = len(list(REIMBURSEMENT_PATTERN.finditer(compact))) > 1
return bool(connector_signal and repeated_reimbursement_signal)
@staticmethod @staticmethod
def _find_first_reimbursement_index(message: str) -> int: def _find_first_reimbursement_index(message: str) -> int:
candidates = [message.find(item) for item in ("我要报销", "还需要报销", "需要报销", "报销")] candidates = [message.find(item) for item in ("我要报销", "还需要报销", "需要报销", "报销")]
@@ -238,6 +372,35 @@ class StewardPlannerService:
) )
return bool((business_signal or route_signal) and (time_signal or planned_route_signal)) return bool((business_signal or route_signal) and (time_signal or planned_route_signal))
def _looks_like_ambiguous_travel_flow(
self,
text: str,
base_date: date,
request: StewardPlanRequest,
) -> bool:
compact = re.sub(r"\s+", "", text)
if not compact or request.attachments:
return False
if re.search(r"申请|报销|草稿|提交|审批|保存|发起|创建", compact):
return False
if not re.search(r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", compact):
return False
if not self._extract_time_range(compact, base_date):
return False
if not self._extract_location(compact):
return False
return not self._is_future_or_current_time_range(compact, base_date)
def _is_future_or_current_time_range(self, segment: str, base_date: date) -> bool:
normalized = self._extract_time_range(segment, base_date)
if not normalized:
return False
try:
parsed = date.fromisoformat(normalized)
except ValueError:
return False
return parsed >= base_date
def _build_task( def _build_task(
self, self,
draft: PlannedTaskDraft, draft: PlannedTaskDraft,

View File

@@ -1,19 +1,23 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from typing import Any from typing import Any
from app.schemas.steward import ( from app.schemas.steward import (
StewardFlowStatePatch,
StewardRuntimeDecisionRequest, StewardRuntimeDecisionRequest,
StewardRuntimeDecisionResponse, StewardRuntimeDecisionResponse,
) )
from app.services.runtime_chat import RuntimeChatService from app.services.runtime_chat import RuntimeChatService
from app.services.steward_flow_state import StewardFlowStateService
STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision" STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision"
RUNTIME_NEXT_ACTIONS = { RUNTIME_NEXT_ACTIONS = {
"plan_new_tasks", "plan_new_tasks",
"continue_selected_flow",
"submit_current_application", "submit_current_application",
"continue_next_task", "continue_next_task",
"fill_current_slot", "fill_current_slot",
@@ -22,6 +26,16 @@ RUNTIME_NEXT_ACTIONS = {
"no_op", "no_op",
} }
FIELD_LABELS = {
"transport_mode": "出行方式",
"expense_type": "费用类型",
"time_range": "时间",
"location": "地点",
"reason": "事由",
"amount": "金额",
"attachments": "附件",
}
class StewardRuntimeDecisionAgent: class StewardRuntimeDecisionAgent:
"""用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。""" """用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。"""
@@ -31,6 +45,9 @@ class StewardRuntimeDecisionAgent:
def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse: def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse:
normalized_request = self._normalize_request(request) normalized_request = self._normalize_request(request)
selected_flow_decision = self._build_selected_flow_decision(normalized_request, [])
if selected_flow_decision is not None:
return selected_flow_decision
result = self.runtime_chat_service.complete_with_tool_call( result = self.runtime_chat_service.complete_with_tool_call(
self._build_messages(normalized_request), self._build_messages(normalized_request),
tools=[self._build_tool_schema()], tools=[self._build_tool_schema()],
@@ -47,18 +64,104 @@ class StewardRuntimeDecisionAgent:
if result.tool_call is not None and result.tool_call.name == STEWARD_RUNTIME_DECISION_FUNCTION_NAME: if result.tool_call is not None and result.tool_call.name == STEWARD_RUNTIME_DECISION_FUNCTION_NAME:
response = self._build_response_from_model_payload(result.tool_call.arguments, normalized_request, traces) response = self._build_response_from_model_payload(result.tool_call.arguments, normalized_request, traces)
if response is not None: if response is not None:
return response return self._attach_updated_steward_state(response, normalized_request)
return self._build_rule_fallback(normalized_request, traces) return self._attach_updated_steward_state(
self._build_rule_fallback(normalized_request, traces),
normalized_request,
)
def _build_selected_flow_decision(
self,
request: StewardRuntimeDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardRuntimeDecisionResponse | None:
selected_flow_id = self._resolve_selected_pending_flow_id(
request.runtime_state,
request.user_message,
)
if not selected_flow_id:
return None
next_state = StewardFlowStateService().confirm_flow(
request.runtime_state.get("steward_state") if isinstance(request.runtime_state.get("steward_state"), dict) else {},
selected_flow_id,
)
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="continue_selected_flow",
target_task_id=selected_flow_id,
response_text=self._build_selected_flow_response_text(selected_flow_id),
rationale="已按你选择的候选流程继续处理。",
steward_state=next_state,
model_call_traces=traces,
)
@staticmethod @staticmethod
def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest: def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest:
context_json = request.context_json if isinstance(request.context_json, dict) else {}
runtime_state = request.runtime_state if isinstance(request.runtime_state, dict) else {}
return StewardRuntimeDecisionRequest( return StewardRuntimeDecisionRequest(
user_message=str(request.user_message or "").strip(), user_message=str(request.user_message or "").strip(),
session_type=str(request.session_type or "steward").strip() or "steward", session_type=str(request.session_type or "steward").strip() or "steward",
runtime_state=request.runtime_state if isinstance(request.runtime_state, dict) else {}, runtime_state=StewardRuntimeDecisionAgent._hydrate_runtime_state(runtime_state, context_json),
context_json=request.context_json if isinstance(request.context_json, dict) else {}, context_json=context_json,
) )
@staticmethod
def _hydrate_runtime_state(
runtime_state: dict[str, Any],
context_json: dict[str, Any],
) -> dict[str, Any]:
hydrated = dict(runtime_state or {})
steward_state = StewardRuntimeDecisionAgent._resolve_steward_state(context_json)
if steward_state:
hydrated.setdefault("steward_state", steward_state)
if StewardRuntimeDecisionAgent._has_runtime_anchor(hydrated) or not steward_state:
return hydrated
active_flow = str(steward_state.get("active_flow") or "").strip()
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
flow = flows.get(active_flow) if isinstance(flows, dict) else None
if not isinstance(flow, dict):
return hydrated
missing_fields = [
str(item or "").strip()
for item in list(flow.get("missing_fields") or [])
if str(item or "").strip()
]
hydrated["current_task"] = {
"task_id": active_flow,
"task_type": "expense_application" if active_flow == "travel_application" else "reimbursement",
"ontology_fields": dict(flow.get("fields") or {}),
"missing_fields": missing_fields,
}
if missing_fields:
hydrated["waiting_for"] = "steward_flow_field_completion"
else:
hydrated["waiting_for"] = "steward_flow_confirmation"
return hydrated
@staticmethod
def _resolve_steward_state(context_json: dict[str, Any]) -> dict[str, Any]:
direct_state = context_json.get("steward_state") or context_json.get("stewardState")
if isinstance(direct_state, dict) and direct_state:
return direct_state
conversation_state = context_json.get("conversation_state")
if isinstance(conversation_state, dict):
nested_state = conversation_state.get("steward_state") or conversation_state.get("stewardState")
if isinstance(nested_state, dict) and nested_state:
return nested_state
return {}
@staticmethod
def _has_runtime_anchor(runtime_state: dict[str, Any]) -> bool:
if str(runtime_state.get("waiting_for") or "").strip():
return True
for key in ("pending_application", "pending_steward_action", "pending_slot_action", "current_task"):
if isinstance(runtime_state.get(key), dict) and runtime_state[key]:
return True
return bool(runtime_state.get("remaining_tasks") or runtime_state.get("completed_tasks"))
@staticmethod @staticmethod
def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]: def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]:
payload = { payload = {
@@ -177,6 +280,34 @@ class StewardRuntimeDecisionAgent:
rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。", rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。",
model_call_traces=traces, model_call_traces=traces,
) )
if waiting_for == "steward_flow_field_completion":
current_task = state.get("current_task") if isinstance(state.get("current_task"), dict) else {}
missing_fields = [
str(item or "").strip()
for item in list(current_task.get("missing_fields") or [])
if str(item or "").strip()
]
field_key = missing_fields[0] if missing_fields else ""
if field_key and request.user_message:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="fill_current_slot",
target_task_id=str(current_task.get("task_id") or ""),
field_key=field_key,
field_value=request.user_message,
rationale="模型运行时决策暂不可用,我先把你的补充写入当前小财管家流程字段。",
model_call_traces=traces,
)
if field_key:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="ask_user",
target_task_id=str(current_task.get("task_id") or ""),
field_key=field_key,
question=f"请补充{FIELD_LABELS.get(field_key, field_key)}",
rationale="当前小财管家流程仍缺少必要字段。",
model_call_traces=traces,
)
if waiting_for: if waiting_for:
return StewardRuntimeDecisionResponse( return StewardRuntimeDecisionResponse(
decision_source="rule_fallback", decision_source="rule_fallback",
@@ -192,6 +323,104 @@ class StewardRuntimeDecisionAgent:
model_call_traces=traces, model_call_traces=traces,
) )
@staticmethod
def _resolve_selected_pending_flow_id(runtime_state: dict[str, Any], user_message: str) -> str:
steward_state = runtime_state.get("steward_state")
if not isinstance(steward_state, dict):
return ""
pending = steward_state.get("pending_flow_confirmation")
if not isinstance(pending, dict) or pending.get("status") != "pending":
return ""
message = re.sub(r"\s+", "", str(user_message or ""))
if not message:
return ""
candidates = pending.get("candidate_flows") if isinstance(pending.get("candidate_flows"), list) else []
for candidate in candidates:
if not isinstance(candidate, dict):
continue
flow_id = str(candidate.get("flow_id") or "").strip()
label = re.sub(r"\s+", "", str(candidate.get("label") or ""))
if flow_id == "travel_application" and (
message in {"补办出差申请", "出差申请", "申请", "补申请"}
or (label and message == label)
):
return flow_id
if flow_id == "travel_reimbursement" and (
message in {"发起费用报销", "费用报销", "报销", "发起报销"}
or (label and message == label)
):
return flow_id
return ""
@staticmethod
def _build_selected_flow_response_text(flow_id: str) -> str:
if flow_id == "travel_application":
return "已确认按 **补办出差申请** 继续,我会基于当前出差信息整理申请材料。"
return "已确认按 **发起费用报销** 继续,我会基于当前出差信息整理报销材料。"
@staticmethod @staticmethod
def _clean_text(value: Any) -> str: def _clean_text(value: Any) -> str:
return str(value or "").strip() return str(value or "").strip()
def _attach_updated_steward_state(
self,
response: StewardRuntimeDecisionResponse,
request: StewardRuntimeDecisionRequest,
) -> StewardRuntimeDecisionResponse:
steward_state = request.runtime_state.get("steward_state")
if not isinstance(steward_state, dict) or not steward_state:
return response
if response.next_action == "continue_selected_flow":
flow_id = self._resolve_target_flow_id(response, steward_state)
if flow_id:
next_state = StewardFlowStateService().confirm_flow(steward_state, flow_id)
return response.model_copy(update={"steward_state": next_state})
return response.model_copy(update={"steward_state": steward_state})
if response.next_action != "fill_current_slot" or not response.field_key:
return response.model_copy(update={"steward_state": steward_state})
flow_id = self._resolve_target_flow_id(response, steward_state)
if not flow_id:
return response.model_copy(update={"steward_state": steward_state})
current_flow = self._resolve_flow(steward_state, flow_id)
remaining_missing_fields = [
key
for key in list(current_flow.get("missing_fields") or [])
if str(key or "").strip() and str(key or "").strip() != response.field_key
]
next_state = StewardFlowStateService().merge_state(
steward_state,
StewardFlowStatePatch(
active_flow=flow_id, # type: ignore[arg-type]
flow_id=flow_id, # type: ignore[arg-type]
intent=str(current_flow.get("intent") or "").strip(),
status="collecting" if remaining_missing_fields else "ready_for_confirmation",
fields={response.field_key: response.field_value},
missing_fields=remaining_missing_fields,
evidence=[
{
"source": "runtime_user_message",
"field": response.field_key,
"text": request.user_message,
}
],
),
)
return response.model_copy(update={"steward_state": next_state})
@staticmethod
def _resolve_target_flow_id(
response: StewardRuntimeDecisionResponse,
steward_state: dict[str, Any],
) -> str:
target = str(response.target_task_id or "").strip()
if target in {"travel_application", "travel_reimbursement"}:
return target
active_flow = str(steward_state.get("active_flow") or "").strip()
return active_flow if active_flow in {"travel_application", "travel_reimbursement"} else ""
@staticmethod
def _resolve_flow(steward_state: dict[str, Any], flow_id: str) -> dict[str, Any]:
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
flow = flows.get(flow_id) if isinstance(flows, dict) else {}
return dict(flow) if isinstance(flow, dict) else {}

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import re
TRAVEL_GRADE_KEYS = tuple(f"P{level}" for level in range(9))
def resolve_travel_policy_grade_key(grade: str | None) -> str | None:
normalized = str(grade or "").strip().upper()
if not normalized:
return None
if "董事会" in normalized:
return "P8"
p_match = re.search(r"P\s*([0-8])", normalized)
if p_match:
return f"P{int(p_match.group(1))}"
level_match = re.search(r"(?<!\d)([0-8])\s*级", normalized)
if level_match:
return f"P{int(level_match.group(1))}"
m_match = re.search(r"M\s*(\d+)", normalized)
if m_match:
level = int(m_match.group(1))
if level <= 1:
return "P5"
if level <= 2:
return "P6"
return "P7"
if normalized.startswith("D"):
return "P8"
if any(keyword in normalized for keyword in ("外聘专家", "专家")):
return "P6"
if "高层经理" in normalized or "公司级" in normalized:
return "P7"
if "中层经理" in normalized:
return "P6"
if "基层经理" in normalized:
return "P5"
if "主管" in normalized or "资深" in normalized:
return "P4"
return None
def travel_policy_grade_key_candidates(grade_key: str | None) -> list[str]:
raw_key = str(grade_key or "").strip()
normalized = raw_key.upper()
if not normalized:
return []
candidates = [normalized]
legacy_key = raw_key.lower()
if legacy_key in {"junior", "mid", "senior", "manager", "executive"}:
candidates.append(legacy_key)
legacy = _legacy_grade_band(normalized)
if legacy and legacy not in candidates:
candidates.append(legacy)
return candidates
def _legacy_grade_band(grade_key: str) -> str:
match = re.fullmatch(r"P([0-8])", grade_key)
if not match:
return ""
level = int(match.group(1))
if level <= 3:
return "junior"
if level <= 5:
return "mid"
if level <= 7:
return "senior"
return "executive"

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import date
from decimal import Decimal from decimal import Decimal
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@@ -16,326 +17,12 @@ from app.schemas.reimbursement import (
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService
from app.services.travel_policy_grades import travel_policy_grade_key_candidates
OTHER_REGION_LOCATION_KEYWORDS = { from app.services.travel_reimbursement_regions import (
"河北", AMBIGUOUS_PROVINCE_CITY_NAMES,
"石家庄", OTHER_REGION_LOCATION_KEYWORDS,
"唐山", OTHER_REGION_PROVINCE_KEYWORDS,
"秦皇岛", )
"邯郸",
"邢台",
"保定",
"张家口",
"承德",
"沧州",
"廊坊",
"衡水",
"山西",
"太原",
"大同",
"长治",
"晋城",
"晋中",
"运城",
"临汾",
"吕梁",
"内蒙古",
"呼和浩特",
"包头",
"赤峰",
"通辽",
"鄂尔多斯",
"辽宁",
"鞍山",
"抚顺",
"本溪",
"丹东",
"锦州",
"营口",
"盘锦",
"吉林",
"长春",
"吉林市",
"四平",
"通化",
"白山",
"松原",
"延边",
"黑龙江",
"哈尔滨",
"齐齐哈尔",
"牡丹江",
"佳木斯",
"大庆",
"江苏",
"常州",
"南通",
"连云港",
"淮安",
"盐城",
"扬州",
"镇江",
"泰州",
"宿迁",
"浙江",
"温州",
"嘉兴",
"湖州",
"绍兴",
"金华",
"衢州",
"舟山",
"台州",
"丽水",
"安徽",
"芜湖",
"蚌埠",
"淮南",
"马鞍山",
"淮北",
"铜陵",
"安庆",
"黄山",
"滁州",
"阜阳",
"宿州",
"六安",
"亳州",
"池州",
"宣城",
"福建",
"泉州",
"漳州",
"莆田",
"三明",
"南平",
"龙岩",
"宁德",
"江西",
"南昌",
"景德镇",
"萍乡",
"九江",
"新余",
"鹰潭",
"赣州",
"吉安",
"宜春",
"抚州",
"上饶",
"山东",
"淄博",
"枣庄",
"东营",
"烟台",
"潍坊",
"济宁",
"泰安",
"威海",
"日照",
"临沂",
"德州",
"聊城",
"滨州",
"菏泽",
"河南",
"洛阳",
"开封",
"平顶山",
"安阳",
"鹤壁",
"新乡",
"焦作",
"濮阳",
"许昌",
"漯河",
"三门峡",
"南阳",
"商丘",
"信阳",
"周口",
"驻马店",
"湖北",
"黄石",
"十堰",
"宜昌",
"襄阳",
"鄂州",
"荆门",
"孝感",
"荆州",
"黄冈",
"咸宁",
"随州",
"恩施",
"湖南",
"株洲",
"湘潭",
"衡阳",
"邵阳",
"岳阳",
"常德",
"张家界",
"益阳",
"郴州",
"永州",
"怀化",
"娄底",
"湘西",
"广东",
"惠州",
"江门",
"湛江",
"茂名",
"肇庆",
"梅州",
"汕尾",
"河源",
"阳江",
"清远",
"潮州",
"揭阳",
"云浮",
"广西",
"南宁",
"柳州",
"桂林",
"梧州",
"北海",
"防城港",
"钦州",
"贵港",
"玉林",
"百色",
"贺州",
"河池",
"来宾",
"崇左",
"海南",
"儋州",
"四川",
"自贡",
"攀枝花",
"泸州",
"德阳",
"绵阳",
"广元",
"遂宁",
"内江",
"乐山",
"南充",
"眉山",
"宜宾",
"广安",
"达州",
"雅安",
"巴中",
"资阳",
"阿坝",
"甘孜",
"凉山",
"贵州",
"贵阳",
"遵义",
"六盘水",
"安顺",
"毕节",
"铜仁",
"黔东南",
"黔南",
"黔西南",
"云南",
"曲靖",
"玉溪",
"保山",
"昭通",
"丽江",
"普洱",
"临沧",
"楚雄",
"红河",
"文山",
"西双版纳",
"大理",
"德宏",
"怒江",
"迪庆",
"陕西",
"宝鸡",
"咸阳",
"铜川",
"渭南",
"延安",
"汉中",
"榆林",
"安康",
"商洛",
"甘肃",
"兰州",
"嘉峪关",
"金昌",
"白银",
"天水",
"武威",
"张掖",
"平凉",
"酒泉",
"庆阳",
"定西",
"陇南",
"临夏",
"甘南",
"青海",
"西宁",
"海东",
"海北",
"黄南",
"海南州",
"果洛",
"玉树",
"海西",
"宁夏",
"银川",
"石嘴山",
"吴忠",
"固原",
"中卫",
}
OTHER_REGION_PROVINCE_KEYWORDS = {
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"四川",
"贵州",
"云南",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"西藏",
"台湾",
"香港",
"澳门",
}
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}
class TravelReimbursementCalculatorService: class TravelReimbursementCalculatorService:
@@ -359,41 +46,77 @@ class TravelReimbursementCalculatorService:
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade) grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
if not grade_band: if not grade_band:
raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位") raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销职级")
matched_city = self._resolve_city(location, policy) matched_city = self._resolve_city(location, policy)
matched_other_region = "" if matched_city else self._resolve_other_region(location) matched_other_region = "" if matched_city else self._resolve_other_region(location)
if not matched_city and not matched_other_region: if not matched_city and not matched_other_region:
raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。") raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。")
city_tier = policy.city_tiers.get(matched_city, "tier_3") if matched_city else "tier_3" city_tier = policy.city_tiers.get(matched_city, "tier_3") if matched_city else "tier_3"
hotel_rate = self._resolve_hotel_rate(policy, grade_band, matched_city, city_tier) hotel_rate = self._resolve_hotel_rate(
policy,
grade_band,
matched_city,
city_tier,
payload.travel_date,
)
allowance_region = self._resolve_allowance_region(location, matched_city or matched_other_region) allowance_region = self._resolve_allowance_region(location, matched_city or matched_other_region)
meal_rate = self._resolve_allowance_rate(policy, "meal", allowance_region) meal_rate = self._resolve_allowance_rate(policy, "meal", allowance_region)
basic_rate = self._resolve_allowance_rate(policy, "basic", allowance_region) basic_rate = self._resolve_allowance_rate(policy, "basic", allowance_region)
total_allowance_rate = self._resolve_total_allowance_rate(policy, allowance_region, meal_rate, basic_rate) total_allowance_rate = self._resolve_total_allowance_rate(policy, allowance_region, meal_rate, basic_rate)
origin_city = self._resolve_origin_city(payload, current_user, policy)
transport_mode = self._normalize_transport_mode(payload.transport_mode)
transport_estimate = self._resolve_transport_estimate(
policy,
origin_city=origin_city,
destination_city=matched_city or matched_other_region,
destination_text=location,
transport_mode=transport_mode,
)
transport_estimated_amount = Decimal(
transport_estimate.get("amount") or Decimal("0.00")
).quantize(Decimal("0.01"))
hotel_amount = hotel_rate * Decimal(days) hotel_amount = hotel_rate * Decimal(days)
allowance_amount = total_allowance_rate * Decimal(days) allowance_amount = total_allowance_rate * Decimal(days)
total_amount = hotel_amount + allowance_amount total_amount = hotel_amount + allowance_amount + transport_estimated_amount
band_label = policy.band_labels.get(grade_band, grade_band) band_label = policy.band_labels.get(grade_band, grade_band)
rule_name = policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则" rule_name = policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则"
rule_version = policy.standard_rule_version or policy.rule_version or "" rule_version = policy.standard_rule_version or policy.rule_version or ""
display_city = matched_city or self._format_other_region_display(matched_other_region) display_city = matched_city or self._format_other_region_display(matched_other_region)
if transport_estimated_amount > Decimal("0.00"):
formula_text = (
f"交通 {self._format_money(transport_estimated_amount)} + "
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
f"{self._format_money(total_amount)}"
)
summary_tail = (
f"交通费用按“{transport_estimate.get('basis') or '交通费用预估表'}"
f"预估 {self._format_money(transport_estimated_amount)} 元。"
f"{days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
f"补贴合计 {self._format_money(allowance_amount)} 元,"
f"申请预算占用参考总金额为 {self._format_money(total_amount)} 元。"
)
else:
formula_text = ( formula_text = (
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + " f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = " f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
f"{self._format_money(total_amount)}" f"{self._format_money(total_amount)}"
) )
summary_text = ( summary_tail = (
f"按《{rule_name}{f'{rule_version}' if rule_version else ''}测算:"
f"当前职级 {grade} 对应 {band_label} 档,出差地点“{location}”匹配为“{display_city}”,"
f"住宿标准 {self._format_money(hotel_rate)} 元/天,补贴区域为“{allowance_region}”,"
f"补贴标准 {self._format_money(total_allowance_rate)} 元/天"
f"(伙食 {self._format_money(meal_rate)} + 基本 {self._format_money(basic_rate)})。"
f"{days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元," f"{days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
f"补贴合计 {self._format_money(allowance_amount)} 元," f"补贴合计 {self._format_money(allowance_amount)} 元,"
f"参考可报销总金额为 {self._format_money(total_amount)} 元。" f"参考可报销总金额为 {self._format_money(total_amount)} 元。"
) )
summary_text = (
f"按《{rule_name}{f'{rule_version}' if rule_version else ''}测算:"
f"当前职级 {grade} 对应 {band_label},出差地点“{location}”匹配为“{display_city}”,"
f"住宿标准 {self._format_money(hotel_rate)} 元/天,补贴区域为“{allowance_region}”,"
f"补贴标准 {self._format_money(total_allowance_rate)} 元/天"
f"(伙食 {self._format_money(meal_rate)} + 基本 {self._format_money(basic_rate)})。"
f"{summary_tail}"
)
return TravelReimbursementCalculatorResponse( return TravelReimbursementCalculatorResponse(
days=days, days=days,
@@ -410,6 +133,19 @@ class TravelReimbursementCalculatorService:
basic_allowance_rate=basic_rate, basic_allowance_rate=basic_rate,
total_allowance_rate=total_allowance_rate, total_allowance_rate=total_allowance_rate,
allowance_amount=allowance_amount, allowance_amount=allowance_amount,
transport_mode=transport_mode or str(transport_estimate.get("transport_mode") or "").strip(),
transport_origin=str(transport_estimate.get("origin_city") or origin_city or "").strip(),
transport_destination=str(
transport_estimate.get("destination_city") or display_city or location
).strip(),
transport_estimated_amount=transport_estimated_amount,
transport_estimate_basis=str(transport_estimate.get("basis") or "").strip(),
transport_estimate_confidence=str(transport_estimate.get("confidence") or "").strip(),
transport_estimate_source=str(transport_estimate.get("source") or "").strip(),
transport_estimate_rule_code=str(policy.transport_estimate_rule_code or "").strip(),
transport_estimate_rule_name=str(policy.transport_estimate_rule_name or "").strip(),
transport_estimate_rule_version=str(policy.transport_estimate_rule_version or "").strip(),
travel_date=payload.travel_date,
total_amount=total_amount, total_amount=total_amount,
rule_name=rule_name, rule_name=rule_name,
rule_version=rule_version, rule_version=rule_version,
@@ -510,6 +246,152 @@ class TravelReimbursementCalculatorService:
return matches[0] return matches[0]
return None return None
def _resolve_origin_city(
self,
payload: TravelReimbursementCalculatorRequest,
current_user: CurrentUserContext,
policy: RuntimeTravelPolicy,
) -> str:
origin_location = str(payload.origin_location or "").strip()
if not origin_location:
employee = self._resolve_current_employee(current_user)
origin_location = str(employee.location or "").strip() if employee is not None else ""
if not origin_location:
origin_location = "武汉"
return (
self._resolve_city(origin_location, policy)
or self._resolve_other_region(origin_location)
or origin_location
)
@staticmethod
def _normalize_transport_mode(value: str | None) -> str:
normalized = re.sub(r"\s+", "", str(value or ""))
if any(keyword in normalized for keyword in ("飞机", "机票", "航班", "乘机", "坐飞机")):
return "飞机"
if any(keyword in normalized for keyword in ("火车", "高铁", "动车", "铁路", "列车")):
return "火车"
if any(keyword in normalized for keyword in ("轮船", "船票", "客轮", "渡轮", "邮轮", "坐船")):
return "轮船"
return normalized if normalized in {"飞机", "火车", "轮船"} else ""
def _resolve_transport_estimate(
self,
policy: RuntimeTravelPolicy,
*,
origin_city: str,
destination_city: str,
destination_text: str,
transport_mode: str,
) -> dict[str, object]:
if self._normalize_city_key(origin_city) == self._normalize_city_key(destination_city):
return {}
location_band = self._resolve_transport_location_band(
destination_city or destination_text
)
candidate_modes = [transport_mode] if transport_mode else ["火车", "飞机", "轮船"]
matched = None
matched_mode = ""
for candidate_mode in candidate_modes:
candidates: list[tuple[int, object]] = []
for estimate in policy.transport_estimates:
if str(estimate.transport_mode or "").strip() != candidate_mode:
continue
origin_score = self._transport_origin_match_score(
str(estimate.origin_city or ""), origin_city
)
if origin_score <= 0:
continue
destination_score = self._transport_destination_match_score(
str(estimate.destination_city or ""),
destination_city or destination_text,
str(estimate.location_band or ""),
location_band,
)
if destination_score <= 0:
continue
candidates.append((origin_score + destination_score, estimate))
if candidates:
_, matched = sorted(candidates, key=lambda item: item[0], reverse=True)[0]
matched_mode = candidate_mode
break
if matched is None:
return {}
amount = Decimal(matched.round_trip_amount or Decimal("0.00")).quantize(Decimal("0.01"))
if amount <= Decimal("0.00"):
return {}
origin_label = str(matched.origin_city or "").strip()
if origin_label in {"*", "默认", "通用"}:
origin_label = origin_city
destination_label = str(matched.destination_city or "").strip() or (
destination_city or self._transport_location_band_label(location_band)
)
basis = str(matched.basis or "").strip()
if not basis:
basis = f"{origin_label}-{destination_label}{matched_mode}往返预估"
return {
"amount": amount,
"origin_city": origin_label,
"destination_city": destination_label,
"transport_mode": matched_mode,
"basis": basis,
"confidence": str(matched.confidence or "basic_rule").strip(),
"source": "basic_rule_transport_estimate",
}
@staticmethod
def _normalize_city_key(value: str) -> str:
return re.sub(r"(省|市|区|县|自治州|特别行政区)$", "", str(value or "").strip())
def _transport_origin_match_score(self, configured_origin: str, origin_city: str) -> int:
normalized = self._normalize_city_key(configured_origin)
if not normalized or normalized in {"*", "默认", "通用"}:
return 10
origin_key = self._normalize_city_key(origin_city)
return 30 if normalized == origin_key or normalized in origin_key or origin_key in normalized else 0
def _transport_destination_match_score(
self,
configured_destination: str,
destination_city: str,
configured_band: str,
location_band: str,
) -> int:
destination_key = self._normalize_city_key(destination_city)
configured_key = self._normalize_city_key(configured_destination)
if configured_key and (
configured_key == destination_key
or configured_key in destination_key
or destination_key in configured_key
):
return 70
if configured_key:
return 0
if configured_band and configured_band == location_band:
return 40
return 0
@staticmethod
def _resolve_transport_location_band(location: str) -> str:
text = str(location or "").strip()
if any(keyword in text for keyword in ("新疆", "西藏", "青海", "甘肃", "宁夏", "内蒙古", "海南", "三亚", "海口", "香港", "澳门", "台湾", "海外", "国外")):
return "remote"
if any(keyword in text for keyword in ("北京", "上海", "广州", "深圳", "杭州", "南京", "苏州", "成都", "重庆", "天津")):
return "premium"
if any(keyword in text for keyword in ("厦门", "福州", "青岛", "大连", "宁波", "舟山")):
return "coastal"
return "default"
@staticmethod
def _transport_location_band_label(location_band: str) -> str:
return {
"premium": "高频城市",
"remote": "远途地区",
"coastal": "沿海城市",
"default": "普通城市",
}.get(str(location_band or "").strip(), "普通城市")
@staticmethod @staticmethod
def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str: def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str:
normalized = str(location or "").strip() normalized = str(location or "").strip()
@@ -536,17 +418,67 @@ class TravelReimbursementCalculatorService:
grade_band: str, grade_band: str,
matched_city: str, matched_city: str,
city_tier: str, city_tier: str,
travel_date: date | None = None,
) -> Decimal: ) -> Decimal:
city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {} city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {}
if city_limits.get(grade_band) is not None: base_rate = Decimal("0")
return Decimal(city_limits[grade_band]) for candidate in travel_policy_grade_key_candidates(grade_band):
if city_limits.get(candidate) is not None:
base_rate = Decimal(city_limits[candidate])
break
band_limits = policy.hotel_limits.get(grade_band, {}) if base_rate <= Decimal("0"):
for candidate in travel_policy_grade_key_candidates(grade_band):
band_limits = policy.hotel_limits.get(candidate, {})
if band_limits.get(city_tier) is not None: if band_limits.get(city_tier) is not None:
return Decimal(band_limits[city_tier]) base_rate = Decimal(band_limits[city_tier])
break
if band_limits.get("tier_3") is not None: if band_limits.get("tier_3") is not None:
return Decimal(band_limits["tier_3"]) base_rate = Decimal(band_limits["tier_3"])
break
peak_rate = TravelReimbursementCalculatorService._resolve_peak_hotel_rate(
policy,
matched_city,
travel_date,
)
return max(base_rate, peak_rate)
@staticmethod
def _resolve_peak_hotel_rate(
policy: RuntimeTravelPolicy,
matched_city: str,
travel_date: date | None,
) -> Decimal:
if not matched_city or travel_date is None:
return Decimal("0") return Decimal("0")
period = (getattr(policy, "hotel_peak_periods", {}) or {}).get(matched_city, "")
if not period or not TravelReimbursementCalculatorService._month_in_peak_period(travel_date.month, period):
return Decimal("0")
peak_rate = (getattr(policy, "hotel_peak_city_limits", {}) or {}).get(matched_city)
return Decimal(peak_rate or Decimal("0"))
@staticmethod
def _month_in_peak_period(month: int, period: str) -> bool:
for part in re.split(r"[,,、;]+", str(period or "")):
if not part:
continue
if "-" not in part:
try:
if int(part) == month:
return True
except ValueError:
continue
continue
start_text, end_text = part.split("-", 1)
try:
start, end = int(start_text), int(end_text)
except ValueError:
continue
if start <= end and start <= month <= end:
return True
if start > end and (month >= start or month <= end):
return True
return False
@staticmethod @staticmethod
def _resolve_allowance_region(location: str, matched_city: str) -> str: def _resolve_allowance_region(location: str, matched_city: str) -> str:

View File

@@ -0,0 +1,321 @@
from __future__ import annotations
OTHER_REGION_LOCATION_KEYWORDS = {
"河北",
"石家庄",
"唐山",
"秦皇岛",
"邯郸",
"邢台",
"保定",
"张家口",
"承德",
"沧州",
"廊坊",
"衡水",
"山西",
"太原",
"大同",
"长治",
"晋城",
"晋中",
"运城",
"临汾",
"吕梁",
"内蒙古",
"呼和浩特",
"包头",
"赤峰",
"通辽",
"鄂尔多斯",
"辽宁",
"鞍山",
"抚顺",
"本溪",
"丹东",
"锦州",
"营口",
"盘锦",
"吉林",
"长春",
"吉林市",
"四平",
"通化",
"白山",
"松原",
"延边",
"黑龙江",
"哈尔滨",
"齐齐哈尔",
"牡丹江",
"佳木斯",
"大庆",
"江苏",
"常州",
"南通",
"连云港",
"淮安",
"盐城",
"扬州",
"镇江",
"泰州",
"宿迁",
"浙江",
"温州",
"嘉兴",
"湖州",
"绍兴",
"金华",
"衢州",
"舟山",
"台州",
"丽水",
"安徽",
"芜湖",
"蚌埠",
"淮南",
"马鞍山",
"淮北",
"铜陵",
"安庆",
"黄山",
"滁州",
"阜阳",
"宿州",
"六安",
"亳州",
"池州",
"宣城",
"福建",
"泉州",
"漳州",
"莆田",
"三明",
"南平",
"龙岩",
"宁德",
"江西",
"南昌",
"景德镇",
"萍乡",
"九江",
"新余",
"鹰潭",
"赣州",
"吉安",
"宜春",
"抚州",
"上饶",
"山东",
"淄博",
"枣庄",
"东营",
"烟台",
"潍坊",
"济宁",
"泰安",
"威海",
"日照",
"临沂",
"德州",
"聊城",
"滨州",
"菏泽",
"河南",
"洛阳",
"开封",
"平顶山",
"安阳",
"鹤壁",
"新乡",
"焦作",
"濮阳",
"许昌",
"漯河",
"三门峡",
"南阳",
"商丘",
"信阳",
"周口",
"驻马店",
"湖北",
"黄石",
"十堰",
"宜昌",
"襄阳",
"鄂州",
"荆门",
"孝感",
"荆州",
"黄冈",
"咸宁",
"随州",
"恩施",
"湖南",
"株洲",
"湘潭",
"衡阳",
"邵阳",
"岳阳",
"常德",
"张家界",
"益阳",
"郴州",
"永州",
"怀化",
"娄底",
"湘西",
"广东",
"惠州",
"江门",
"湛江",
"茂名",
"肇庆",
"梅州",
"汕尾",
"河源",
"阳江",
"清远",
"潮州",
"揭阳",
"云浮",
"广西",
"南宁",
"柳州",
"桂林",
"梧州",
"北海",
"防城港",
"钦州",
"贵港",
"玉林",
"百色",
"贺州",
"河池",
"来宾",
"崇左",
"海南",
"儋州",
"四川",
"自贡",
"攀枝花",
"泸州",
"德阳",
"绵阳",
"广元",
"遂宁",
"内江",
"乐山",
"南充",
"眉山",
"宜宾",
"广安",
"达州",
"雅安",
"巴中",
"资阳",
"阿坝",
"甘孜",
"凉山",
"贵州",
"贵阳",
"遵义",
"六盘水",
"安顺",
"毕节",
"铜仁",
"黔东南",
"黔南",
"黔西南",
"云南",
"曲靖",
"玉溪",
"保山",
"昭通",
"丽江",
"普洱",
"临沧",
"楚雄",
"红河",
"文山",
"西双版纳",
"大理",
"德宏",
"怒江",
"迪庆",
"陕西",
"宝鸡",
"咸阳",
"铜川",
"渭南",
"延安",
"汉中",
"榆林",
"安康",
"商洛",
"甘肃",
"兰州",
"嘉峪关",
"金昌",
"白银",
"天水",
"武威",
"张掖",
"平凉",
"酒泉",
"庆阳",
"定西",
"陇南",
"临夏",
"甘南",
"青海",
"西宁",
"海东",
"海北",
"黄南",
"海南州",
"果洛",
"玉树",
"海西",
"宁夏",
"银川",
"石嘴山",
"吴忠",
"固原",
"中卫",
}
OTHER_REGION_PROVINCE_KEYWORDS = {
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"四川",
"贵州",
"云南",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"西藏",
"台湾",
"香港",
"澳门",
}
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}

View File

@@ -38,8 +38,14 @@ from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE, COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME, COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_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,
FINANCE_RULES_LIBRARY, FINANCE_RULES_LIBRARY,
) )
from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
@@ -64,6 +70,9 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY
for file_name in ( for file_name in (
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_FILENAME, COMPANY_PREAPPROVAL_RULE_FILENAME,
): ):
@@ -197,12 +206,36 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
assert communication_config["scenario_category"] == "通信费" assert communication_config["scenario_category"] == "通信费"
assert communication_config["ai_review_category"] == "通信费" assert communication_config["ai_review_category"] == "通信费"
assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON) assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
assert preapproval_config["tag"] == "财务规则" assert travel_config["tag"] == "基础规则"
assert communication_config["tag"] == "基础规则"
assert preapproval_config["tag"] == "申请规则"
assert preapproval_config["finance_rule_code"] == "expense.preapproval.policy" assert preapproval_config["finance_rule_code"] == "expense.preapproval.policy"
assert preapproval_config["finance_rule_sheet"] == "费用申请审批规则" assert preapproval_config["finance_rule_sheet"] == "费用申请审批规则"
assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"] assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"]
assert preapproval_config["rule_document"]["file_name"] == COMPANY_PREAPPROVAL_RULE_FILENAME assert preapproval_config["rule_document"]["file_name"] == COMPANY_PREAPPROVAL_RULE_FILENAME
grade_mapping_rule = next(
item for item in rules if item.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE
)
season_mapping_rule = next(
item for item in rules if item.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE
)
transport_estimate_rule = next(
item for item in rules if item.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE
)
assert grade_mapping_rule.config_json["tag"] == "基础规则"
assert grade_mapping_rule.config_json["rule_document"]["file_name"] == (
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
)
assert season_mapping_rule.config_json["tag"] == "基础规则"
assert season_mapping_rule.config_json["rule_document"]["file_name"] == (
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
)
assert transport_estimate_rule.config_json["tag"] == "基础规则"
assert transport_estimate_rule.config_json["rule_document"]["file_name"] == (
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
)
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None: def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
with build_session() as db: with build_session() as db:
@@ -743,15 +776,15 @@ def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -
assert catalog.travel_policy is not None assert catalog.travel_policy is not None
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则" assert catalog.travel_policy.standard_rule_name == "差旅住宿报销标准"
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450 assert catalog.travel_policy.hotel_city_limits["北京"]["P0"] == 450
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450 assert catalog.travel_policy.hotel_city_limits["北京"]["P4"] == 450
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500 assert catalog.travel_policy.hotel_city_limits["北京"]["P8"] == 500
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65 assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55 assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90 assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1 assert catalog.travel_policy.transport_limits["P7"]["flight"] == 1
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1 assert catalog.travel_policy.transport_limits["P8"]["train"] == 2
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None: def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
@@ -777,18 +810,23 @@ def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> N
), ),
) )
assert result.rule_name == "公司差旅费报销规则" assert result.rule_name == "差旅住宿报销标准"
assert result.grade == "P4" assert result.grade == "P4"
assert result.grade_band == "mid" assert result.grade_band == "P4"
assert result.matched_city == "北京" assert result.matched_city == "北京"
assert result.hotel_rate == 450 assert result.hotel_rate == 450
assert result.hotel_amount == 1350 assert result.hotel_amount == 1350
assert result.allowance_region == "直辖市/特区" assert result.allowance_region == "直辖市/特区"
assert result.total_allowance_rate == 100 assert result.total_allowance_rate == 100
assert result.allowance_amount == 300 assert result.allowance_amount == 300
assert result.total_amount == 1650 assert result.transport_estimated_amount == 1040
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text assert result.transport_estimate_source == "basic_rule_transport_estimate"
assert "参考可报销总金额为 1650.00 元" in result.summary_text assert result.total_amount == 2690
assert (
"交通 1040.00 + 住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 2690.00"
== result.formula_text
)
assert "申请预算占用参考总金额为 2690.00 元" in result.summary_text
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None: def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
@@ -821,7 +859,8 @@ def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_lo
assert result.allowance_region == "其他地区" assert result.allowance_region == "其他地区"
assert result.total_allowance_rate == 90 assert result.total_allowance_rate == 90
assert result.allowance_amount == 180 assert result.allowance_amount == 180
assert result.total_amount == 940 assert result.transport_estimated_amount == 720
assert result.total_amount == 1660
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None: def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:

View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from app.schemas.steward import (
StewardCandidateFlow,
StewardFlowStatePatch,
StewardPendingFlowConfirmation,
StewardPlanResponse,
)
from app.services.steward_flow_state import StewardFlowStateService
def test_state_merge_keeps_application_and_reimbursement_flows() -> None:
service = StewardFlowStateService()
state = service.merge_state(
{},
StewardFlowStatePatch(
active_flow="travel_application",
flow_id="travel_application",
intent="travel_application_create",
fields={"expense_type": "travel", "location": "上海", "reason": "客户现场支撑"},
missing_fields=["transport_mode"],
),
)
state = service.merge_state(
state,
StewardFlowStatePatch(
active_flow="travel_reimbursement",
flow_id="travel_reimbursement",
intent="travel_reimbursement_draft",
fields={"amount": "708.00", "invoice_no": "NO-1"},
linked_application_claim_id="claim-app-001",
),
)
assert state["active_flow"] == "travel_reimbursement"
assert state["flows"]["travel_application"]["fields"]["location"] == "上海"
assert state["flows"]["travel_application"]["missing_fields"] == ["transport_mode"]
assert state["flows"]["travel_reimbursement"]["fields"]["amount"] == "708.00"
assert state["flows"]["travel_reimbursement"]["linked_application_claim_id"] == "claim-app-001"
def test_state_merge_filters_non_ontology_fields() -> None:
service = StewardFlowStateService()
state = service.merge_state(
{},
StewardFlowStatePatch(
active_flow="travel_application",
flow_id="travel_application",
intent="travel_application_create",
fields={
"location": "上海",
"invented_field": "x",
"occurred_date": "2026-06-15",
},
),
)
assert state["flows"]["travel_application"]["fields"] == {
"location": "上海",
"time_range": "2026-06-15",
}
def test_state_merge_appends_traceable_events() -> None:
service = StewardFlowStateService()
state = service.merge_state(
{},
StewardFlowStatePatch(
active_flow="travel_application",
flow_id="travel_application",
intent="travel_application_create",
fields={"location": "北京"},
evidence=[{"source": "user_message", "field": "location", "text": "去北京出差"}],
),
)
assert len(state["events"]) == 1
assert state["events"][0]["flow_id"] == "travel_application"
assert state["events"][0]["intent"] == "travel_application_create"
assert state["events"][0]["fields"] == {"location": "北京"}
assert state["events"][0]["evidence"][0]["text"] == "去北京出差"
def test_state_merge_plan_keeps_pending_flow_confirmation() -> None:
service = StewardFlowStateService()
state = service.merge_plan(
{},
StewardPlanResponse(
plan_id="steward_plan_pending",
plan_status="needs_flow_confirmation",
planning_source="llm_function_call",
next_action="confirm_flow",
summary="需要先确认是申请还是报销。",
pending_flow_confirmation=StewardPendingFlowConfirmation(
status="pending",
source_message="2月20-23日去上海出差辅助国网仿生产环境部署",
reason="缺少申请或报销动作词。",
candidate_flows=[
StewardCandidateFlow(
flow_id="travel_application",
label="补办出差申请",
confidence=0.52,
reason="可能是补办申请。",
ontology_fields={
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
missing_fields=["transport_mode"],
),
StewardCandidateFlow(
flow_id="travel_reimbursement",
label="发起费用报销",
confidence=0.48,
reason="可能是发起报销。",
ontology_fields={
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
),
],
),
),
)
assert state["active_flow"] == ""
assert state["pending_flow_confirmation"]["status"] == "pending"
assert state["flows"]["travel_application"]["status"] == "pending_flow_confirmation"
assert state["flows"]["travel_application"]["fields"]["location"] == "上海"
assert state["flows"]["travel_application"]["missing_fields"] == ["transport_mode"]
assert state["flows"]["travel_reimbursement"]["fields"]["time_range"] == "2026-02-20"

View File

@@ -0,0 +1,30 @@
from app.services.steward_intent_agent import (
STEWARD_INTENT_FUNCTION_NAME,
StewardIntentAgent,
)
def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None:
schema = StewardIntentAgent._build_intent_tool_schema(
["expense_type", "time_range", "location", "reason", "transport_mode"]
)
function_schema = schema["function"]
assert function_schema["name"] == STEWARD_INTENT_FUNCTION_NAME
properties = function_schema["parameters"]["properties"]
pending_schema = properties["pending_flow_confirmation"]
candidate_schema = pending_schema["properties"]["candidate_flows"]["items"]
assert "pending_flow_confirmation" in properties
assert pending_schema["properties"]["status"]["enum"] == ["none", "pending"]
assert candidate_schema["properties"]["flow_id"]["enum"] == [
"travel_application",
"travel_reimbursement",
]
assert candidate_schema["properties"]["missing_fields"]["items"]["enum"] == [
"expense_type",
"time_range",
"location",
"reason",
"transport_mode",
]

View File

@@ -63,6 +63,24 @@ class FakeFunctionCallingIntentAgent:
) )
class CountingFunctionCallingIntentAgent(FakeFunctionCallingIntentAgent):
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return super().detect(request, base_date=base_date, canonical_fields=canonical_fields)
class CountingNoResultIntentAgent:
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return None
class EmptyFunctionCallingIntentAgent: class EmptyFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields): def detect(self, request, *, base_date, canonical_fields):
return None return None
@@ -125,9 +143,92 @@ class ApplicationFunctionCallingIntentAgent:
) )
class PendingFlowFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "flow_confirmation",
"title": "识别到出差事项但动作不明确",
"content": "用户提供了时间、地点和事由,但没有明确要补办申请还是发起报销。",
}
],
"pending_flow_confirmation": {
"status": "pending",
"source_message": request.message,
"reason": "缺少申请或报销动作词,需要用户确认流程方向。",
"candidate_flows": [
{
"flow_id": "travel_application",
"label": "补办出差申请",
"confidence": 0.52,
"reason": "这句话可以理解为补办出差申请。",
"ontology_fields": {
"time_range": "2月20日",
"location": "上海",
"expense_type": "差旅",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": ["transport_mode"],
},
{
"flow_id": "travel_reimbursement",
"label": "发起费用报销",
"confidence": 0.48,
"reason": "这句话也可能是在为已发生出差发起报销。",
"ontology_fields": {
"time_range": "2月20日",
"location": "上海",
"expense_type": "差旅",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": [],
},
],
},
"tasks": [],
"attachment_groups": [],
},
model_call_traces=[],
)
class AmbiguousApplicationFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "task_split",
"title": "模型直接判定为申请",
"content": "模型误把无动作词的历史出差描述直接判定为申请。",
}
],
"tasks": [
{
"task_type": "expense_application",
"title": "上海出差申请",
"summary": "2月20-23日去上海出差辅助国网仿生产环境部署。",
"confidence": 0.9,
"ontology_fields": {
"time_range": "2月20日",
"location": "上海",
"expense_type": "差旅",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": ["transport_mode"],
}
],
"attachment_groups": [],
},
model_call_traces=[{"status": "succeeded"}],
)
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None: def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费", message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u5ba2\u6237\u73b0\u573a\u6c9f\u901a\u7684\u4ea4\u901a\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00", client_now_iso="2026-06-04T09:30:00+08:00",
attachments=[ attachments=[
StewardAttachmentInput(name="出租车票.png"), StewardAttachmentInput(name="出租车票.png"),
@@ -157,7 +258,7 @@ def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None
def test_steward_planner_normalizes_llm_business_entertainment_expense_type() -> None: def test_steward_planner_normalizes_llm_business_entertainment_expense_type() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="报销昨天业务招待费", message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u62a5\u9500\u6628\u5929\u4e1a\u52a1\u62db\u5f85\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00", client_now_iso="2026-06-04T09:30:00+08:00",
) )
@@ -170,7 +271,7 @@ def test_steward_planner_normalizes_llm_business_entertainment_expense_type() ->
def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None: def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="明天出差北京3天支撑国网仿生产部署", message="\u6211\u60f3\u7533\u8bf7\u660e\u5929\u51fa\u5dee\u5317\u4eac\u0033\u5929\uff0c\u652f\u6491\u56fd\u7f51\u4eff\u751f\u4ea7\u90e8\u7f72\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00", client_now_iso="2026-06-04T09:30:00+08:00",
) )
@@ -184,19 +285,114 @@ def test_steward_planner_enforces_application_transport_gap_after_function_calli
assert "火车、飞机或轮船" in gap_events[0].content assert "火车、飞机或轮船" in gap_events[0].content
def test_steward_planner_returns_pending_flow_confirmation_from_llm() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产环境部署",
client_now_iso="2026-06-15T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=PendingFlowFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
assert result.pending_flow_confirmation.status == "pending"
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
assert result.candidate_flows[0].ontology_fields["time_range"] == "2026-02-20"
assert result.candidate_flows[0].ontology_fields["location"] == "上海"
assert "申请" in result.summary and "报销" in result.summary
def test_steward_planner_skips_llm_for_single_ambiguous_travel_flow() -> None:
payload = StewardPlanRequest(
message="\u0032\u6708\u0032\u0030-\u0032\u0033\u65e5\u53bb\u4e0a\u6d77\u51fa\u5dee\u8f85\u52a9\u56fd\u7f51\u4eff\u751f\u4ea7\u73af\u5883\u90e8\u7f72",
client_now_iso="2026-06-15T09:30:00+08:00",
)
intent_agent = CountingNoResultIntentAgent()
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
assert intent_agent.calls == 0
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
assert result.model_call_traces == []
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
def test_steward_planner_uses_llm_for_multi_financial_demands() -> None:
payload = StewardPlanRequest(
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00",
)
intent_agent = CountingFunctionCallingIntentAgent()
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
assert intent_agent.calls == 1
assert result.planning_source == "llm_function_call"
assert result.model_call_traces[0]["status"] == "succeeded"
def test_steward_planner_overrides_llm_direct_application_for_ambiguous_travel_flow() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产环境部署",
client_now_iso="2026-06-15T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=AmbiguousApplicationFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
assert result.tasks == []
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None: def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="我要报销昨天的交通费", message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00", client_now_iso="2026-06-04T09:30:00+08:00",
) )
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload) result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback" assert result.planning_source == "rule_fallback"
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03" assert [task.task_type for task in result.tasks] == ["expense_application", "reimbursement"]
assert result.tasks[0].ontology_fields["time_range"] == "2026-07-02"
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
assert result.thinking_events[0].stage == "rule_fallback" assert result.thinking_events[0].stage == "rule_fallback"
def test_steward_planner_rule_fallback_confirms_ambiguous_travel_flow() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产环境部署",
client_now_iso="2026-06-15T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.pending_flow_confirmation.status == "pending"
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
assert result.tasks == []
assert result.confirmation_groups == []
def test_steward_planner_splits_application_and_reimbursement_tasks() -> None: def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message=( message=(
@@ -326,3 +522,28 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
assert events[0]["data"]["stage"] == "stream_start" assert events[0]["data"]["stage"] == "stream_start"
assert events[-1]["event"] == "plan" assert events[-1]["event"] == "plan"
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03" assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"
def test_steward_plan_endpoint_persists_application_and_reimbursement_state() -> None:
client = TestClient(create_app())
response = client.post(
"/api/v1/steward/plans",
json={
"message": "我想申请7月2日去北京出差并且我要报销昨天的交通费",
"user_id": "u-steward-state",
"client_now_iso": "2026-06-04T09:30:00+08:00",
"context_json": {"session_type": "steward", "entry_source": "personal_workbench"},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["conversation_id"].startswith("conv_")
state = payload["steward_state"]
assert state["active_flow"] == "travel_reimbursement"
assert state["flows"]["travel_application"]["fields"]["location"] == "北京"
assert state["flows"]["travel_application"]["fields"]["time_range"] == "2026-07-02"
assert state["flows"]["travel_reimbursement"]["fields"]["time_range"] == "2026-06-03"
assert state["flows"]["travel_reimbursement"]["fields"]["expense_type"] == "transport"
assert all("invented_field" not in flow["fields"] for flow in state["flows"].values())

View File

@@ -94,3 +94,154 @@ def test_steward_runtime_decision_fallback_keeps_current_context():
assert result.next_action == "continue_next_task" assert result.next_action == "continue_next_task"
assert result.target_message_id == "msg-next-task" assert result.target_message_id == "msg-next-task"
assert result.target_task_id == "task-reimbursement-meal" assert result.target_task_id == "task-reimbursement-meal"
def test_steward_runtime_decision_fallback_reads_persisted_steward_state():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="我坐高铁",
runtime_state={},
context_json={
"conversation_state": {
"steward_state": {
"active_flow": "travel_application",
"flows": {
"travel_application": {
"flow_id": "travel_application",
"intent": "travel_application_create",
"fields": {
"expense_type": "travel",
"time_range": "2026-07-02",
"location": "北京",
"reason": "客户现场支撑",
},
"missing_fields": ["transport_mode"],
}
},
}
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "fill_current_slot"
assert result.target_task_id == "travel_application"
assert result.field_key == "transport_mode"
assert result.field_value == "我坐高铁"
assert result.steward_state["flows"]["travel_application"]["fields"]["transport_mode"] == "我坐高铁"
assert result.steward_state["flows"]["travel_application"]["missing_fields"] == []
def test_steward_runtime_decision_fallback_confirms_selected_flow():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="补办出差申请",
runtime_state={},
context_json={
"conversation_state": {
"steward_state": {
"version": "steward.flow_state.v2",
"active_flow": "",
"pending_flow_confirmation": {
"status": "pending",
"source_message": "2月20-23日去上海出差辅助国网仿生产环境部署",
"reason": "缺少申请或报销动作词。",
"candidate_flows": [
{
"flow_id": "travel_application",
"label": "补办出差申请",
"confidence": 0.52,
},
{
"flow_id": "travel_reimbursement",
"label": "发起费用报销",
"confidence": 0.48,
},
],
},
"flows": {
"travel_application": {
"flow_id": "travel_application",
"intent": "travel_application_create",
"status": "pending_flow_confirmation",
"fields": {
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": ["transport_mode"],
},
"travel_reimbursement": {
"flow_id": "travel_reimbursement",
"intent": "travel_reimbursement_draft",
"status": "pending_flow_confirmation",
"fields": {
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": [],
},
},
}
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_selected_flow"
assert result.target_task_id == "travel_application"
assert result.steward_state["active_flow"] == "travel_application"
assert result.steward_state["pending_flow_confirmation"]["status"] == "confirmed"
assert result.steward_state["flows"]["travel_application"]["status"] == "collecting"
def test_steward_runtime_decision_fallback_confirms_reimbursement_flow():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="发起费用报销",
runtime_state={
"steward_state": {
"version": "steward.flow_state.v2",
"active_flow": "",
"pending_flow_confirmation": {
"status": "pending",
"candidate_flows": [
{"flow_id": "travel_application", "label": "补办出差申请"},
{"flow_id": "travel_reimbursement", "label": "发起费用报销"},
],
},
"flows": {
"travel_reimbursement": {
"flow_id": "travel_reimbursement",
"intent": "travel_reimbursement_draft",
"status": "pending_flow_confirmation",
"fields": {
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": [],
}
},
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_selected_flow"
assert result.target_task_id == "travel_reimbursement"
assert result.steward_state["active_flow"] == "travel_reimbursement"
assert result.steward_state["pending_flow_confirmation"]["status"] == "confirmed"

View File

@@ -0,0 +1,78 @@
{
"sessions": {
"steward": {
"label": "小财管家",
"icon": "mdi mdi-account-tie-outline",
"scope": "多任务拆解、附件归集、申请助手和报销助手统一调度"
},
"application": {
"label": "申请助手",
"icon": "mdi mdi-file-plus-outline",
"scope": "费用申请、事前审批、申请材料清单、申请单状态查询"
},
"expense": {
"label": "报销助手",
"icon": "mdi mdi-receipt-text-plus-outline",
"scope": "发起报销、票据识别、草稿归集、报销单状态查询和报销信息核对"
},
"approval": {
"label": "审核助手",
"icon": "mdi mdi-clipboard-check-outline",
"scope": "待审单据查询、审核动作、风险解释和审核意见草稿"
},
"knowledge": {
"label": "财务知识助手",
"icon": "mdi mdi-book-open-page-variant-outline",
"scope": "财务制度、报销标准、票据要求、流程规则和政策口径解释"
}
},
"businessSignals": {
"budget": [
"预算",
"预算占用",
"预算余额",
"预算执行",
"预算超标",
"预算预警"
],
"accounts_receivable": [
"应收",
"回款",
"收款",
"客户欠款",
"账龄"
],
"accounts_payable": [
"应付",
"付款",
"供应商付款",
"待付款",
"账款"
]
},
"contextualFollowUps": [
"继续",
"下一步",
"确定",
"确认",
"无误",
"可以提交",
"提交",
"保存草稿",
"补充",
"重新规划"
],
"supportedBusinessScopes": [
"费用申请/事前审批",
"报销与票据识别",
"审批审核与风险解释",
"财务制度、报销标准和流程规则问答",
"预算、应收、应付等财务经营查询",
"小财管家多任务拆解和附件归集"
],
"unsupportedIntentMessage": {
"title": "此意图系统暂不支持。",
"body": "这条内容没有识别到当前系统支持的财务业务意图,暂时不能继续处理。",
"retryHint": "请重新描述你的财务业务需求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。"
}
}

View File

@@ -532,11 +532,10 @@
.message-answer-markdown :deep(table) { .message-answer-markdown :deep(table) {
width: 100%; width: 100%;
min-width: 560px; min-width: 460px;
border: 0; border: 0;
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
table-layout: fixed;
background: #ffffff; background: #ffffff;
font-size: inherit; font-size: inherit;
} }
@@ -548,25 +547,6 @@
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
white-space: normal; white-space: normal;
word-break: normal;
overflow-wrap: break-word;
}
.message-answer-markdown :deep(th:first-child),
.message-answer-markdown :deep(td:first-child) {
width: 88px;
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal;
}
.message-answer-markdown :deep(th:last-child),
.message-answer-markdown :deep(td:last-child) {
width: 112px;
text-align: right;
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal;
} }
.message-answer-markdown :deep(th) { .message-answer-markdown :deep(th) {
@@ -806,6 +786,30 @@
border-top: 1px solid #e6edf5; border-top: 1px solid #e6edf5;
} }
.structured-card-reveal-enter-active .application-preview-row {
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(2) {
animation-delay: 35ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(3) {
animation-delay: 70ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(4) {
animation-delay: 105ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(5) {
animation-delay: 140ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(n + 6) {
animation-delay: 165ms;
}
.application-preview-row.editable { .application-preview-row.editable {
cursor: pointer; cursor: pointer;
} }

View File

@@ -1,4 +1,4 @@
.approval-page { .approval-page {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@@ -861,6 +861,9 @@
} }
.detail-expense-table { .detail-expense-table {
--expense-editor-control-height: 34px;
--expense-editor-control-line-height: 16px;
--expense-editor-control-padding-y: calc((var(--expense-editor-control-height) - var(--expense-editor-control-line-height) - 2px) / 2);
min-width: 0; min-width: 0;
overflow-x: auto; overflow-x: auto;
} }
@@ -940,10 +943,10 @@
.detail-expense-table .col-time { width: 10%; } .detail-expense-table .col-time { width: 10%; }
.detail-expense-table .col-filled-at { width: 13%; } .detail-expense-table .col-filled-at { width: 13%; }
.detail-expense-table .col-type { width: 11%; } .detail-expense-table .col-type { width: 14%; }
.detail-expense-table .col-desc { width: 15%; } .detail-expense-table .col-desc { width: 15%; }
.detail-expense-table .col-amount { width: 9%; } .detail-expense-table .col-amount { width: 9%; }
.detail-expense-table .col-attachment { width: 18%; } .detail-expense-table .col-attachment { width: 15%; }
.detail-expense-table .col-risk-note { width: 15%; } .detail-expense-table .col-risk-note { width: 15%; }
.detail-expense-table .col-action { width: 9%; } .detail-expense-table .col-action { width: 9%; }
@@ -1000,61 +1003,162 @@
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.editor-input, .editor-control,
.editor-select, .editor-select {
.editor-textarea {
width: 100%; width: 100%;
min-height: 34px; min-width: 0;
padding: 0 10px; --el-component-size: var(--expense-editor-control-height) !important;
border: 1px solid #d7e0ea; --el-component-size-small: var(--expense-editor-control-height) !important;
border-radius: 4px; --el-input-height: var(--expense-editor-control-height) !important;
background: #fff; box-sizing: border-box !important;
}
.editor-select {
padding: 0;
border: 0;
background: transparent;
}
.editor-date-picker.el-date-editor {
--el-date-editor-width: 100%;
}
.editor-date-picker.editor-control :deep(.el-input__wrapper) {
gap: 4px;
padding: 0 7px !important;
}
.editor-date-picker.editor-control :deep(.el-input__prefix) {
flex: 0 0 14px;
width: 14px;
min-width: 14px;
margin: 0;
color: #94a3b8;
font-size: 13px;
}
.editor-date-picker.editor-control :deep(.el-input__prefix-inner) {
width: 14px;
font-size: 13px;
}
.editor-date-picker.editor-control :deep(.el-input__suffix) {
display: none !important;
}
.editor-control :deep(.el-input__wrapper),
.editor-control :deep(.el-select__wrapper),
.editor-select :deep(.el-select__wrapper),
.editor-date-picker :deep(.el-input__wrapper) {
box-sizing: border-box !important;
padding: 0 10px !important;
border-radius: 4px !important;
background: #fff !important;
box-shadow: 0 0 0 1px #d7e0ea inset !important;
display: flex !important;
align-items: center !important;
margin: 0 !important;
}
.editor-control:focus-within :deep(.el-input__wrapper),
.editor-control:focus-within :deep(.el-select__wrapper),
.editor-select:focus-within :deep(.el-select__wrapper),
.editor-date-picker:focus-within :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px var(--theme-primary) inset, 0 0 0 3px var(--theme-focus-ring);
}
.editor-control :deep(.el-input__inner),
.editor-select :deep(.el-select__selected-item),
.editor-select :deep(.el-select__placeholder),
.editor-date-picker :deep(.el-input__inner) {
height: var(--expense-editor-control-line-height) !important;
color: #0f172a; color: #0f172a;
font-size: 12px; font-size: 12px;
line-height: var(--expense-editor-control-line-height) !important;
} }
.editor-textarea { .editor-control :deep(.el-input__prefix),
min-height: 68px; .editor-control :deep(.el-input__suffix),
padding: 8px 10px; .editor-date-picker :deep(.el-input__prefix),
resize: vertical; .editor-date-picker :deep(.el-input__suffix) {
line-height: 1.45; display: inline-flex;
}
.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
overflow-y: auto;
resize: none;
}
.currency-editor {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center; align-items: center;
gap: 8px;
} }
.currency-editor span { .editor-select :deep(.el-select__wrapper) {
min-height: 34px; min-height: var(--expense-editor-control-height);
display: grid; height: var(--expense-editor-control-height);
place-items: center; }
border: 1px solid #d7e0ea;
border-radius: 4px; .editor-amount-input :deep(.el-input__prefix) {
background: #f8fafc; min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
color: #334155; color: #334155;
display: inline-flex;
align-items: center;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
} }
.editor-input:focus, .editor-amount-input :deep(.el-input__prefix-inner) {
.editor-select:focus, display: inline-flex;
.editor-textarea:focus { align-items: center;
border-color: var(--theme-primary); height: var(--expense-editor-control-line-height);
box-shadow: 0 0 0 3px var(--theme-focus-ring); line-height: var(--expense-editor-control-line-height);
outline: none;
} }
.cell-editor span { .editor-amount-input.editor-control {
display: flex;
align-items: center;
}
.risk-note-editor-input.editor-control {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
}
.risk-note-editor-input.el-textarea {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
}
.risk-note-editor-input :deep(.el-textarea__inner) {
display: block !important;
box-sizing: border-box !important;
min-height: var(--expense-editor-control-height) !important;
height: var(--expense-editor-control-height);
line-height: var(--expense-editor-control-line-height) !important;
max-height: calc(var(--expense-editor-control-height) + var(--expense-editor-control-line-height) * 2) !important;
padding: var(--expense-editor-control-padding-y) 10px !important;
border-radius: 4px !important;
color: #0f172a !important;
font-size: 12px !important;
resize: none !important;
overflow-y: hidden !important;
box-shadow: 0 0 0 1px #d7e0ea inset !important;
vertical-align: middle !important;
margin: 0 !important;
}
.risk-note-editor-input :deep(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px var(--theme-primary) inset, 0 0 0 3px var(--theme-focus-ring);
}
/* 隐藏金额输入框的原生微调箭头 */
.editor-amount-input :deep(input::-webkit-outer-spin-button),
.editor-amount-input :deep(input::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
.editor-amount-input :deep(input[type=number]) {
-moz-appearance: textfield;
}
.cell-editor > span {
display: block;
margin-top: 4px;
color: #64748b; color: #64748b;
font-size: 11px; font-size: 11px;
line-height: 1.45; line-height: 1.45;
@@ -1432,6 +1536,16 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
justify-content: center; justify-content: center;
align-items: center;
}
.detail-expense-table .row-action-group .inline-action {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
} }
.risk-inline-tag { .risk-inline-tag {

View File

@@ -207,6 +207,7 @@
<template v-else> <template v-else>
<span <span
class="application-preview-text" class="application-preview-text"
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
>{{ row.value }}</span> >{{ row.value }}</span>
<button <button
v-if="row.editable" v-if="row.editable"

View File

@@ -9,6 +9,7 @@ import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../servi
import { fetchOntologyParse } from '../services/ontology.js' import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { ASSISTANT_SCOPE_SESSION_STEWARD } from '../utils/assistantSessionScope.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js' import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js' import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { import {
@@ -371,6 +372,9 @@ export function useAppShell() {
if (!prompt) { if (!prompt) {
return fallbackSessionType return fallbackSessionType
} }
if (fallbackSessionType === ASSISTANT_SCOPE_SESSION_STEWARD) {
return fallbackSessionType
}
try { try {
const ontology = await fetchOntologyParse( const ontology = await fetchOntologyParse(

View File

@@ -52,6 +52,10 @@ const APPLICATION_FUTURE_OR_DURATION_PATTERN =
/明天|后天|下周|下月|近期|月底|预计|计划|安排|准备|将要|[0-9]+天|[一二两三四五六七八九十]+天/ /明天|后天|下周|下月|近期|月底|预计|计划|安排|准备|将要|[0-9]+天|[一二两三四五六七八九十]+天/
const APPLICATION_ROUTE_PATTERN = const APPLICATION_ROUTE_PATTERN =
/(?:去|到|赴|前往)[^,。;;?!\n]{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|(?:出差|差旅)[^,。;;?!\n]{0,24}(?:[0-9]+天|[一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)/ /(?:去|到|赴|前往)[^,。;;?!\n]{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|(?:出差|差旅)[^,。;;?!\n]{0,24}(?:[0-9]+天|[一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)/
const AMBIGUOUS_TRAVEL_DATE_RANGE_PATTERN =
/(?:\d{1,2}月)?\d{1,2}(?:日|号)?(?:-|—|~||至|到)\d{1,2}(?:日|号)?[^,。;;?!\n]{0,32}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)/
const EXPLICIT_APPLICATION_ACTION_PATTERN =
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|先申请|补办申请|补申请|补办出差申请|创建申请|提交申请/
const COMPLETED_EXPENSE_PATTERN = const COMPLETED_EXPENSE_PATTERN =
/已经|已|昨天|前天|上周|上月|去年|花了|花销|消费|垫付|支付|付了|买了|采购了|招待了|发生了/ /已经|已|昨天|前天|上周|上月|去年|花了|花销|消费|垫付|支付|付了|买了|采购了|招待了|发生了/
const EXPENSE_PATTERN = const EXPENSE_PATTERN =
@@ -141,6 +145,21 @@ export function hasExpenseApplicationIntentSignal(rawText) {
return hasBusinessSignal && planningScore + timingScore + routeScore >= 2 return hasBusinessSignal && planningScore + timingScore + routeScore >= 2
} }
export function hasAmbiguousTravelFlowIntent(rawText) {
const text = normalizeText(rawText)
if (!text) {
return false
}
if (
EXPLICIT_APPLICATION_ACTION_PATTERN.test(text) ||
EXPENSE_PATTERN.test(text) ||
KNOWLEDGE_PATTERN.test(text)
) {
return false
}
return AMBIGUOUS_TRAVEL_DATE_RANGE_PATTERN.test(text)
}
function resolveScopeConfig(sessionType) { function resolveScopeConfig(sessionType) {
return SESSION_SCOPE_CONFIG[normalizeSessionType(sessionType)] || SESSION_SCOPE_CONFIG[ASSISTANT_SCOPE_SESSION_EXPENSE] return SESSION_SCOPE_CONFIG[normalizeSessionType(sessionType)] || SESSION_SCOPE_CONFIG[ASSISTANT_SCOPE_SESSION_EXPENSE]
} }
@@ -151,6 +170,10 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
return '' return ''
} }
if (hasAmbiguousTravelFlowIntent(text)) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
const applicationMatched = hasExpenseApplicationIntentSignal(text) const applicationMatched = hasExpenseApplicationIntentSignal(text)
const expenseMatched = EXPENSE_PATTERN.test(text) const expenseMatched = EXPENSE_PATTERN.test(text)
const approvalMatched = APPROVAL_PATTERN.test(text) const approvalMatched = APPROVAL_PATTERN.test(text)

View File

@@ -3,6 +3,7 @@ import {
ASSISTANT_SCOPE_SESSION_EXPENSE, ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_KNOWLEDGE, ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
ASSISTANT_SCOPE_SESSION_STEWARD, ASSISTANT_SCOPE_SESSION_STEWARD,
hasAmbiguousTravelFlowIntent,
hasExpenseApplicationIntentSignal, hasExpenseApplicationIntentSignal,
hasReimbursementIntentSignal, hasReimbursementIntentSignal,
inferAssistantScopeTarget inferAssistantScopeTarget
@@ -64,6 +65,10 @@ export function resolveWorkbenchSessionTypeFromOntology(ontology, rawText, fallb
return fallback return fallback
} }
if (hasAmbiguousTravelFlowIntent(text)) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (applicationSignal && reimbursementSignal) { if (applicationSignal && reimbursementSignal) {
return ASSISTANT_SCOPE_SESSION_STEWARD return ASSISTANT_SCOPE_SESSION_STEWARD
} }

View File

@@ -228,7 +228,15 @@
<div class="expense-time-value"> <div class="expense-time-value">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor"> <div class="cell-editor">
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" /> <ElDatePicker
v-model="expenseEditor.itemDate"
class="editor-date-picker editor-control"
type="date"
value-format="YYYY-MM-DD"
format="YYYY/MM/DD"
popper-class="detail-editor-date-popper"
:clearable="false"
/>
<span>{{ item.dayLabel }}</span> <span>{{ item.dayLabel }}</span>
</div> </div>
</template> </template>
@@ -242,7 +250,7 @@
<td class="expense-type col-type"> <td class="expense-type col-type">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor"> <div class="cell-editor">
<EnterpriseSelect v-model="expenseEditor.itemType" class="editor-select" :options="expenseTypeOptions" size="small" /> <EnterpriseSelect v-model="expenseEditor.itemType" class="editor-select" :options="expenseTypeOptions" />
<span>编辑费用项目</span> <span>编辑费用项目</span>
</div> </div>
</template> </template>
@@ -254,10 +262,10 @@
<td class="expense-desc col-desc"> <td class="expense-desc col-desc">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor"> <div class="cell-editor">
<input <ElInput
v-model="expenseEditor.itemReason" v-model="expenseEditor.itemReason"
class="editor-input" class="editor-input-control editor-control"
type="text" clearable
:placeholder="resolveExpenseReasonPlaceholder(expenseEditor.itemType)" :placeholder="resolveExpenseReasonPlaceholder(expenseEditor.itemType)"
/> />
<span>{{ resolveExpenseReasonHelper(expenseEditor.itemType) }}</span> <span>{{ resolveExpenseReasonHelper(expenseEditor.itemType) }}</span>
@@ -271,18 +279,18 @@
<td class="expense-amount col-amount"> <td class="expense-amount col-amount">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor"> <div class="cell-editor">
<label class="currency-editor"> <ElInput
<span></span>
<input
v-model="expenseEditor.itemAmount" v-model="expenseEditor.itemAmount"
class="editor-input" class="editor-amount-input editor-control"
type="number" type="number"
inputmode="decimal"
min="0" min="0"
step="0.01" step="0.01"
placeholder="输入金额" placeholder="输入金额"
/> >
</label> <template #prefix></template>
<span>保存后自动格式化为人民币</span> </ElInput>
<span>自动格式化</span>
</div> </div>
</template> </template>
<template v-else> <template v-else>
@@ -389,15 +397,15 @@
<td class="expense-risk-note col-risk-note"> <td class="expense-risk-note col-risk-note">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor"> <div class="cell-editor">
<textarea <ElInput
v-model="expenseEditor.itemNote" v-model="expenseEditor.itemNote"
class="editor-textarea risk-note-editor-textarea" class="risk-note-editor-input editor-control"
rows="1" type="textarea"
:rows="1"
resize="none"
placeholder="如票据存在异常或风险,请补充原因" placeholder="如票据存在异常或风险,请补充原因"
@input="resizeExpenseNoteInput" />
@keydown.enter="resizeExpenseNoteInput" <span>非必填若有异常则说明</span>
></textarea>
<span>用于说明改签绕行超标票据异常等情况</span>
</div> </div>
</template> </template>
<template v-else> <template v-else>
@@ -886,3 +894,224 @@
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style> <style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style> <style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-responsive.css"></style> <style scoped src="../assets/styles/views/travel-request-detail-responsive.css"></style>
<style>
/* 强力锁定表格中输入框的高度,解决 scoped 模式下有前缀的 Element Plus 子组件无法被 :deep 成功匹配的局限性 */
.detail-expense-table .editor-control .el-input__wrapper,
.detail-expense-table .editor-control .el-select__wrapper,
.detail-expense-table .editor-select .el-select__wrapper,
.detail-expense-table .editor-date-picker .el-input__wrapper {
box-sizing: border-box !important;
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-control:not(.risk-note-editor-input),
.detail-expense-table .editor-date-picker.editor-control,
.detail-expense-table .editor-select {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-date-picker.editor-control {
display: flex !important;
align-items: center !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__wrapper {
gap: 4px !important;
padding-right: 7px !important;
padding-left: 7px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__inner,
.detail-expense-table .editor-input-control.editor-control .el-input__inner,
.detail-expense-table .editor-select .el-select__selected-item,
.detail-expense-table .editor-select .el-select__placeholder {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
font-size: 12px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix,
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
display: inline-flex !important;
align-items: center !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix {
flex: 0 0 14px !important;
width: 14px !important;
min-width: 14px !important;
margin: 0 !important;
color: #94a3b8 !important;
font-size: 13px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
display: none !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner {
width: 14px !important;
font-size: 13px !important;
}
.detail-expense-table .editor-amount-input.editor-control {
display: flex !important;
align-items: center !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__wrapper {
display: flex !important;
align-items: center !important;
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix,
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
display: inline-flex !important;
align-items: center !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-editor-date-popper.el-popper {
border: 1px solid rgba(148, 163, 184, .32) !important;
border-radius: 4px !important;
background: #ffffff !important;
box-shadow: 0 18px 42px rgba(15, 23, 42, .14) !important;
}
.detail-editor-date-popper .el-picker-panel {
border: 0 !important;
border-radius: 4px !important;
background: #ffffff !important;
color: #334155 !important;
}
.detail-editor-date-popper .el-date-picker__header {
height: 38px !important;
margin: 0 !important;
padding: 0 10px !important;
border-bottom: 1px solid #e2e8f0 !important;
display: flex !important;
align-items: center !important;
}
.detail-editor-date-popper .el-picker-panel__icon-btn {
appearance: none !important;
width: 24px !important;
height: 24px !important;
margin: 0 1px !important;
padding: 0 !important;
border: 0 !important;
border-radius: 4px !important;
background: transparent !important;
color: #64748b !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
transition: background-color 160ms var(--ease), color 160ms var(--ease) !important;
}
.detail-editor-date-popper .el-picker-panel__icon-btn:hover {
background: var(--theme-primary-soft) !important;
color: var(--theme-primary-active) !important;
}
.detail-editor-date-popper .el-date-picker__header-label {
color: #0f172a !important;
font-size: 13px !important;
font-weight: 800 !important;
}
.detail-editor-date-popper .el-picker-panel__content {
margin: 8px 10px 10px !important;
}
.detail-editor-date-popper .el-date-table th {
border-bottom: 1px solid #edf2f7 !important;
color: #64748b !important;
font-size: 11px !important;
font-weight: 800 !important;
}
.detail-editor-date-popper .el-date-table td {
width: 32px !important;
height: 30px !important;
padding: 2px !important;
}
.detail-editor-date-popper .el-date-table td .el-date-table-cell {
height: 28px !important;
padding: 0 !important;
}
.detail-editor-date-popper .el-date-table td .el-date-table-cell__text {
width: 26px !important;
height: 26px !important;
border-radius: 4px !important;
color: #334155 !important;
font-size: 12px !important;
line-height: 26px !important;
}
.detail-editor-date-popper .el-date-table td.available:hover .el-date-table-cell__text {
background: var(--theme-primary-soft) !important;
color: var(--theme-primary-active) !important;
}
.detail-editor-date-popper .el-date-table td.today .el-date-table-cell__text {
color: var(--theme-primary-active) !important;
font-weight: 850 !important;
}
.detail-editor-date-popper .el-date-table td.current .el-date-table-cell__text,
.detail-editor-date-popper .el-date-table td.selected .el-date-table-cell__text {
background: var(--theme-primary) !important;
color: #ffffff !important;
font-weight: 850 !important;
}
.detail-editor-date-popper .el-date-table td.prev-month .el-date-table-cell__text,
.detail-editor-date-popper .el-date-table td.next-month .el-date-table-cell__text {
color: #cbd5e1 !important;
}
.detail-editor-date-popper .el-date-table td.disabled .el-date-table-cell__text {
background: #f8fafc !important;
color: #cbd5e1 !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElDatePicker } from 'element-plus/es/components/date-picker/index.mjs'
import { ElInput } from 'element-plus/es/components/input/index.mjs'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
@@ -572,6 +574,8 @@ export default {
name: 'TravelRequestDetailView', name: 'TravelRequestDetailView',
components: { components: {
ConfirmDialog, ConfirmDialog,
ElDatePicker,
ElInput,
EnterpriseSelect, EnterpriseSelect,
StageRiskAdviceCard, StageRiskAdviceCard,
TravelRequestApprovalDialog, TravelRequestApprovalDialog,
@@ -1794,18 +1798,6 @@ export default {
riskOverrideDialogOpen.value = false riskOverrideDialogOpen.value = false
} }
function resizeExpenseNoteInput(event) {
const target = event?.target
if (!target || typeof window === 'undefined') {
return
}
const style = window.getComputedStyle(target)
const lineHeight = Number.parseFloat(style.lineHeight) || 18
const maxHeight = lineHeight * 3 + 18
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
}
function goToPreviousSubmitRisk() { function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) { if (!submitRiskWarnings.value.length) {
return return
@@ -1883,10 +1875,6 @@ export default {
} }
populateExpenseEditor(item) populateExpenseEditor(item)
void nextTick(() => {
const textarea = document.querySelector('.risk-note-editor-textarea')
resizeExpenseNoteInput({ target: textarea })
})
} }
function validateExpenseEditor() { function validateExpenseEditor() {
@@ -2677,7 +2665,7 @@ export default {
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta, hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle, resolveExpenseRiskIndicatorTitle,
resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard, resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,

View File

@@ -45,11 +45,11 @@ export const TAB_META = {
financialRules: { financialRules: {
assetType: 'rule', assetType: 'rule',
typeKey: 'rules', typeKey: 'rules',
label: '财务规则', label: '基础规则',
typeLabel: '财务规则', typeLabel: '基础规则',
createButtonLabel: '财务规则已接入', createButtonLabel: '基础规则已接入',
hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。', hintText: '仅展示 tag 为“基础规则”或“申请规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。',
searchPlaceholder: '搜索财务规则名称、编码或负责人', searchPlaceholder: '搜索基础规则名称、编码或负责人',
tableColumns: RULE_TABLE_COLUMNS, tableColumns: RULE_TABLE_COLUMNS,
showRuntimeColumn: false, showRuntimeColumn: false,
showStatusColumn: false, showStatusColumn: false,
@@ -203,7 +203,25 @@ export const RULE_TEMPLATE_LABELS = {
} }
export const RULE_TAB_TAG_ALIASES = { export const RULE_TAB_TAG_ALIASES = {
financialRules: new Set(['财务规则', '财务', 'financialrule', 'financialrules', 'financerule', 'financerules', 'financial', 'finance']), financialRules: new Set([
'基础规则',
'基本规则',
'申请规则',
'财务规则',
'财务',
'basicrule',
'basicrules',
'applicationrule',
'applicationrules',
'preapprovalrule',
'preapprovalrules',
'financialrule',
'financialrules',
'financerule',
'financerules',
'financial',
'finance'
]),
riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk']) riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk'])
} }

View File

@@ -254,7 +254,7 @@ export function createPreviewRuleDetailPayload() {
config_json: { config_json: {
severity: 'medium', severity: 'medium',
enabled: true, enabled: true,
tag: '财务规则', tag: '基础规则',
detail_mode: 'spreadsheet', detail_mode: 'spreadsheet',
runtime_kind: 'travel_policy', runtime_kind: 'travel_policy',
scenario_category: '差旅', scenario_category: '差旅',

View File

@@ -125,7 +125,7 @@ export function buildStewardFieldCompletionRawText({
'已识别信息:', '已识别信息:',
...knownLines, ...knownLines,
'', '',
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。', '处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。' '如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
].filter((line) => line !== '').join('\n') ].filter((line) => line !== '').join('\n')
} }

View File

@@ -98,8 +98,15 @@ const FIELD_VALUE_DISPLAY_CONFIG = {
} }
} }
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) { export function buildStewardPlanRequest({
rawText = '',
files = [],
currentUser = {},
conversationId = '',
stewardState = null
} = {}) {
const safeFiles = Array.isArray(files) ? files : [] const safeFiles = Array.isArray(files) ? files : []
const normalizedConversationId = String(conversationId || '').trim()
return { return {
message: String(rawText || '').trim(), message: String(rawText || '').trim(),
user_id: String(currentUser.username || currentUser.name || 'anonymous').trim() || 'anonymous', user_id: String(currentUser.username || currentUser.name || 'anonymous').trim() || 'anonymous',
@@ -111,6 +118,8 @@ export function buildStewardPlanRequest({ rawText = '', files = [], currentUser
context_json: { context_json: {
entry_source: 'workbench', entry_source: 'workbench',
session_type: 'steward', session_type: 'steward',
conversation_id: normalizedConversationId,
steward_state: stewardState && typeof stewardState === 'object' ? stewardState : null,
role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [], role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [],
username: currentUser.username || '', username: currentUser.username || '',
name: currentUser.name || currentUser.username || '', name: currentUser.name || currentUser.username || '',
@@ -124,9 +133,13 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
const visibleThinkingEventCount = Number.isFinite(options.visibleThinkingEventCount) const visibleThinkingEventCount = Number.isFinite(options.visibleThinkingEventCount)
? Number(options.visibleThinkingEventCount) ? Number(options.visibleThinkingEventCount)
: Number(rawPlan.visibleThinkingEventCount || rawPlan.visible_thinking_event_count || 0) : Number(rawPlan.visibleThinkingEventCount || rawPlan.visible_thinking_event_count || 0)
const pendingFlowConfirmation = normalizePendingFlowConfirmation(rawPlan)
return { return {
planId: String(rawPlan.plan_id || rawPlan.planId || ''), planId: String(rawPlan.plan_id || rawPlan.planId || ''),
planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''), planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''),
nextAction: String(rawPlan.next_action || rawPlan.nextAction || ''),
conversationId: String(rawPlan.conversation_id || rawPlan.conversationId || ''),
stewardState: rawPlan.steward_state || rawPlan.stewardState || null,
summary: String(rawPlan.summary || ''), summary: String(rawPlan.summary || ''),
visibleThinkingEventCount, visibleThinkingEventCount,
initialSummaryOnly: Boolean(rawPlan.initial_summary_only || rawPlan.initialSummaryOnly || options.initialSummaryOnly), initialSummaryOnly: Boolean(rawPlan.initial_summary_only || rawPlan.initialSummaryOnly || options.initialSummaryOnly),
@@ -185,12 +198,17 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
: [], : [],
confirmationGroups: Array.isArray(rawPlan.confirmation_groups) confirmationGroups: Array.isArray(rawPlan.confirmation_groups)
? rawPlan.confirmation_groups ? rawPlan.confirmation_groups
: [] : [],
pendingFlowConfirmation,
candidateFlows: pendingFlowConfirmation.candidateFlows
} }
} }
export function buildStewardPlanMessageText(plan) { export function buildStewardPlanMessageText(plan) {
const normalized = normalizeStewardPlan(plan) const normalized = normalizeStewardPlan(plan)
if (isPendingFlowConfirmationPlan(normalized)) {
return buildPendingFlowConfirmationMessageText(normalized)
}
const nextContext = resolveNextActionContext(normalized) const nextContext = resolveNextActionContext(normalized)
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task) const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
const taskLines = orderedTasks.map((task, index) => const taskLines = orderedTasks.map((task, index) =>
@@ -266,6 +284,28 @@ export function formatStewardOntologyFields(fields = {}, taskType = '') {
export function buildStewardSuggestedActions(plan) { export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan) const normalized = normalizeStewardPlan(plan)
if (isPendingFlowConfirmationPlan(normalized)) {
return normalized.candidateFlows.map((flow) => ({
label: flow.label,
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
icon: flow.flowId === 'travel_application'
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
steward_confirm_flow: true,
steward_plan_id: normalized.planId,
flow_id: flow.flowId,
session_type: flow.flowId === 'travel_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE,
selected_flow_label: flow.label,
carry_text: flow.label,
auto_submit: true,
steward_state: normalized.stewardState || null
}
}))
}
const nextContext = resolveNextActionContext(normalized) const nextContext = resolveNextActionContext(normalized)
if (!nextContext) { if (!nextContext) {
return [] return []
@@ -300,6 +340,70 @@ export function buildStewardSuggestedActions(plan) {
] ]
} }
function normalizePendingFlowConfirmation(rawPlan = {}) {
const rawPending = rawPlan.pending_flow_confirmation || rawPlan.pendingFlowConfirmation || {}
const rawCandidates = Array.isArray(rawPlan.candidate_flows || rawPlan.candidateFlows)
? rawPlan.candidate_flows || rawPlan.candidateFlows
: rawPending?.candidate_flows || rawPending?.candidateFlows || []
const candidateFlows = Array.isArray(rawCandidates)
? rawCandidates
.map((item) => normalizeCandidateFlow(item))
.filter((item) => item.flowId)
: []
return {
status: String(rawPending?.status || '').trim(),
sourceMessage: String(rawPending?.source_message || rawPending?.sourceMessage || '').trim(),
reason: String(rawPending?.reason || '').trim(),
candidateFlows
}
}
function normalizeCandidateFlow(item = {}) {
const flowId = String(item.flow_id || item.flowId || '').trim()
if (!['travel_application', 'travel_reimbursement'].includes(flowId)) {
return { flowId: '' }
}
return {
flowId,
label: String(item.label || (flowId === 'travel_application' ? '补办出差申请' : '发起费用报销')).trim(),
confidence: Number(item.confidence || 0),
reason: String(item.reason || '').trim(),
ontologyFields: item.ontology_fields || item.ontologyFields || {},
missingFields: Array.isArray(item.missing_fields || item.missingFields)
? item.missing_fields || item.missingFields
: []
}
}
function isPendingFlowConfirmationPlan(normalized) {
return (
String(normalized?.nextAction || '').trim() === 'confirm_flow' ||
String(normalized?.planStatus || '').trim() === 'needs_flow_confirmation' ||
String(normalized?.pendingFlowConfirmation?.status || '').trim() === 'pending'
) && Array.isArray(normalized?.candidateFlows) && normalized.candidateFlows.length > 0
}
function buildPendingFlowConfirmationMessageText(normalized) {
const fields = normalized.candidateFlows[0]?.ontologyFields || {}
const knownParts = formatStewardOntologyFields(fields, 'expense_application')
const candidateLines = normalized.candidateFlows.map((flow, index) =>
`${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}`
)
return [
'### 需要先确认流程方向',
'',
knownParts
? `我识别到这是一项财务事项,已提取到:**${knownParts}**。`
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
'',
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
'',
...candidateLines,
'',
'请先选择一个方向,我会继续整理对应材料。'
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
}
function resolveNextActionContext(normalized) { function resolveNextActionContext(normalized) {
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application') const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
const applicationAction = applicationTask const applicationAction = applicationTask
@@ -508,6 +612,13 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
group?.attachmentNames?.length ? `相关附件:${group.attachmentNames.join('、')}` : '', group?.attachmentNames?.length ? `相关附件:${group.attachmentNames.join('、')}` : '',
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '', group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
missingFields ? `还需要补充:${missingFields}` : '', missingFields ? `还需要补充:${missingFields}` : '',
actionType === 'confirm_create_application'
? missingFields
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: missingFields
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
] ]
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : '' const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
if (remainingTaskText) { if (remainingTaskText) {

View File

@@ -1,103 +0,0 @@
export const STEWARD_TYPEWRITER_TEXT_CHUNK_SIZE = 3
export const STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE = 2
export function resolveStewardTypewriterNextIndex(chars = [], index = 0) {
const total = chars.length
const safeIndex = Math.max(0, Math.min(Number(index) || 0, total))
const tableStart = resolveMarkdownTableStart(chars, safeIndex)
if (tableStart >= 0) {
return resolveMarkdownTableBlockEnd(chars, tableStart)
}
const chunkSize = resolveStewardTypewriterChunkSize(chars, safeIndex)
const nextIndex = Math.min(total, safeIndex + chunkSize)
const crossedTableStart = findMarkdownTableLineStart(chars, safeIndex, nextIndex)
return crossedTableStart >= 0 ? crossedTableStart : nextIndex
}
function resolveStewardTypewriterChunkSize(chars = [], index = 0) {
const line = resolveCurrentTypewriterLine(chars, index)
const trimmed = line.trim()
if (!trimmed) return STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE
if (/^(#{1,6}\s+|[-*]\s+|\d+\.\s+)/.test(trimmed)) {
return STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE
}
return STEWARD_TYPEWRITER_TEXT_CHUNK_SIZE
}
function resolveCurrentTypewriterLine(chars = [], index = 0) {
const safeIndex = Math.max(0, Math.min(Number(index) || 0, chars.length))
let start = safeIndex
while (start > 0 && chars[start - 1] !== '\n') {
start -= 1
}
let end = safeIndex
while (end < chars.length && chars[end] !== '\n') {
end += 1
}
return chars.slice(start, end).join('')
}
function resolveMarkdownTableStart(chars = [], index = 0) {
const currentLineStart = resolveCurrentLineStart(chars, index)
if (isMarkdownTableLine(resolveLine(chars, currentLineStart).trim())) {
return currentLineStart
}
if (chars[index] === '\n') {
const nextLineStart = index + 1
if (isMarkdownTableLine(resolveLine(chars, nextLineStart).trim())) {
return nextLineStart
}
}
return -1
}
function resolveMarkdownTableBlockEnd(chars = [], tableStart = 0) {
let cursor = tableStart
let blockEnd = tableStart
while (cursor < chars.length) {
const line = resolveLine(chars, cursor)
if (!isMarkdownTableLine(line.trim())) {
break
}
const lineEnd = cursor + line.length
blockEnd = chars[lineEnd] === '\n' ? lineEnd + 1 : lineEnd
cursor = blockEnd
}
return blockEnd
}
function findMarkdownTableLineStart(chars = [], start = 0, end = 0) {
const safeStart = Math.max(0, start)
const safeEnd = Math.min(chars.length, Math.max(safeStart, end))
for (let index = safeStart; index < safeEnd; index += 1) {
if (index !== 0 && chars[index - 1] !== '\n') continue
if (isMarkdownTableLine(resolveLine(chars, index).trim())) {
return index
}
}
return -1
}
function resolveCurrentLineStart(chars = [], index = 0) {
let start = Math.max(0, Math.min(Number(index) || 0, chars.length))
while (start > 0 && chars[start - 1] !== '\n') {
start -= 1
}
return start
}
function resolveLine(chars = [], start = 0) {
let end = Math.max(0, Math.min(Number(start) || 0, chars.length))
while (end < chars.length && chars[end] !== '\n') {
end += 1
}
return chars.slice(start, end).join('')
}
function isMarkdownTableLine(line = '') {
if (!line.includes('|')) return false
if (/^\|?[\s:|-]+\|[\s:|-]+/.test(line)) return true
return /^\|.+\|$/.test(line) || line.split('|').length >= 3
}

View File

@@ -1,255 +0,0 @@
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE
} from './travelReimbursementConversationModel.js'
import {
buildStewardFieldItems,
formatStewardMissingFieldList,
formatStewardOntologyFields
} from './stewardPlanModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
import { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 8
const STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5
export function buildStewardContinuationAfterAction({
createMessage,
message,
completedLabel = '当前动作已完成'
}) {
const continuation = message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
if (!remainingTasks.length) {
return null
}
const nextTask = remainingTasks[0]
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const targetSessionType = nextTaskType === 'expense_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
? '继续创建申请单'
: '继续填写报销单'
const restTasks = remainingTasks.slice(1)
return createMessage(
'assistant',
[
`**${completedLabel}。**`,
'',
'我会重新检查剩余任务队列。',
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}`,
'请回复“确定”,我再继续执行。'
].join('\n'),
[],
{
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '等待用户确认'],
suggestedActions: [
{
label: nextLabel,
description: '确认后小财管家继续调用对应助手完成下一步。',
icon: targetSessionType === SESSION_TYPE_APPLICATION
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
auto_submit: true,
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
steward_current_task: nextTask,
steward_remaining_tasks: restTasks
}
}
]
}
)
}
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
return {
planId: planId || `steward-followup-${Date.now()}`,
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function extractStewardCarryLine(text = '', label = '') {
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[:]([^\\n]+)`, 'u'))
return match ? match[1].trim() : ''
}
function extractStewardFollowupNextTitle(text = '') {
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[:]([^。\n]+)/u)
if (taskMatch?.[1]) {
return taskMatch[1].trim()
}
const nextMatch = String(text || '').match(/下一步[:]([^。\n]+)/u)
return nextMatch?.[1]?.trim() || '下一项财务任务'
}
function buildStewardFollowupThinkingEvents(finalMessage = null, actions = []) {
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const firstAction = Array.isArray(actions) ? actions[0] : null
const actionPayload = firstAction?.payload && typeof firstAction.payload === 'object' ? firstAction.payload : {}
const carryText = String(actionPayload.carry_text || '').trim()
const finalText = String(finalMessage?.text || '').trim()
const nextTitle = extractStewardFollowupNextTitle(carryText || finalText)
const nextSummary = extractStewardCarryLine(carryText, '任务摘要')
const nextMissing = extractStewardCarryLine(carryText, '还需要补充')
return [
{
eventId: `${eventPrefix}-review`,
title: '复盘结果',
content: finalText.includes('申请单已完成')
? '申请单已经完成,我把当前出差申请标记为已处理,不会重复创建。'
: '当前动作已经完成,我会把已完成事项从任务队列中移除。'
},
{
eventId: `${eventPrefix}-next`,
title: '读取剩余任务',
content: nextSummary
? `剩余队列里的下一项是“${nextTitle}”:${nextSummary}`
: `剩余队列里的下一项是“${nextTitle}”。`
},
{
eventId: `${eventPrefix}-gate`,
title: '判断下一步条件',
content: nextMissing
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
: '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
}
]
}
function waitStewardFollowupTick(intervalMs) {
return new Promise((resolve) => {
window.setTimeout(resolve, intervalMs)
})
}
export async function pushStewardContinuationMessage({
finalMessage,
messages,
nextTick,
persistSessionState,
scrollToBottom
}) {
if (!finalMessage) {
return
}
const finalText = String(finalMessage.text || '')
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const finalActions = Array.isArray(finalMessage.suggestedActions)
? finalMessage.suggestedActions
: []
finalMessage.text = ''
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
finalMessage.suggestedActions = []
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
messages.value.push(finalMessage)
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
for (const eventData of buildStewardFollowupThinkingEvents(finalMessage, finalActions)) {
const event = {
eventId: eventData.eventId,
stage: 'steward_followup',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(eventData.content)
for (let index = 0; index < chars.length;) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
index = Math.min(chars.length, index + STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE)
event.content = chars.slice(0, index).join('')
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
if (index % STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
event.content = eventData.content
event.status = 'completed'
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
}
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
const chars = Array.from(finalText)
for (let index = 0; index < chars.length;) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index)
finalMessage.text = chars.slice(0, index).join('')
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
nextTick(scrollToBottom)
}
finalMessage.text = finalText
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
finalMessage.suggestedActions = finalActions
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
}
export function buildStewardContinuationCarryText(task, restTasks = []) {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
const missingFields = formatStewardMissingFieldList(
task?.missing_fields || task?.missingFields || [],
taskType,
{ includeHints: false }
)
const lines = [
taskType === 'expense_application'
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}`
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields ? `已识别信息:${fields}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
missingFields
? '请先追问上述缺失信息,不要直接生成核对结果,也不要替用户默认填写。'
: '请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
]
if (restTasks.length) {
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
restTasks.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
})
}
return lines.filter(Boolean).join('\n')
}
export function resolveStewardMissingFieldItems(task) {
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
return task.missingFieldItems
}
const fields = task?.missingFields || task?.missing_fields || []
const taskType = String(task?.taskType || task?.task_type || '').trim()
return buildStewardFieldItems(fields, taskType)
}

View File

@@ -1,77 +0,0 @@
export const STEWARD_ASSISTANT_NAME = '小财管家'
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
const APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN = /^(确认|确定|确认提交|确定提交|提交|提交审批|确认审批|确认无误|核对无误|信息无误|无误|没问题|可以提交|确认进入审批|提交至审批流程|确认提交审批|同意提交)$/
const APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN = /^(不|否|否定|取消|暂不|先不|不确认|不提交|再检查|再看看|等等|等一下)/
const STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN = /^(继续|继续执行|下一步|继续下一步|开始下一步|处理下一项|继续处理|确认开始|确定开始|可以|好的|好|行)$/
const STEWARD_RUNTIME_CANCEL_TEXT_PATTERN = /^(取消|暂不|先不|不用|不要|不继续|不处理|先等等|等一下|停止|终止|算了)$/
const STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH = 12
const STEWARD_RUNTIME_BUSINESS_HINT_PATTERN = /(申请|报销|出差|差旅|招待|交通费|住宿费|餐费|发票|票据|费用|预算|借款|付款|审批|审核)/
const STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN = /(今天|明天|后天|昨天|前天|\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}|我要|帮我|需要|创建|填写|处理|去|前往)/
const STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN = /(当前|这个|这一步|上面|上述|申请单|核对表|出行方式|交通方式|火车|高铁|动车|飞机|轮船|提交|审批|确认)/
export function isApplicationSubmitConfirmationText(value = '') {
const normalized = String(value || '')
.replace(/\s+/g, '')
.replace(/[,。.!?;:]/g, '')
if (!normalized || APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN.test(normalized)) {
return false
}
return APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN.test(normalized)
}
export function normalizeStewardRuntimeInputText(value = '') {
return String(value || '')
.replace(/\s+/g, '')
.replace(/[,。.!?;:]/g, '')
.trim()
}
export function isStewardRuntimeContinueText(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
return Boolean(normalized && STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN.test(normalized))
}
export function isStewardRuntimeCancelText(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
return Boolean(normalized && STEWARD_RUNTIME_CANCEL_TEXT_PATTERN.test(normalized))
}
export function resolveStewardRuntimeTransportAlias(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
if (!normalized) {
return ''
}
const matchedModes = []
if (/火车|高铁|动车|列车|铁路/.test(normalized)) {
matchedModes.push('火车')
}
if (/飞机|机票|航班|航空/.test(normalized)) {
matchedModes.push('飞机')
}
if (/轮船|船票|客轮|渡轮|坐船/.test(normalized)) {
matchedModes.push('轮船')
}
return matchedModes.length === 1 ? matchedModes[0] : ''
}
export function shouldPlanNewStewardTasksLocally(rawText = '', runtimeState = {}) {
const text = String(rawText || '').trim()
const normalized = normalizeStewardRuntimeInputText(text)
if (
normalized.length < STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH ||
isApplicationSubmitConfirmationText(normalized) ||
isStewardRuntimeContinueText(normalized) ||
isStewardRuntimeCancelText(normalized)
) {
return false
}
if (!STEWARD_RUNTIME_BUSINESS_HINT_PATTERN.test(text) || !STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN.test(text)) {
return false
}
const waitingFor = String(runtimeState?.waiting_for || '').trim()
if (waitingFor && STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN.test(text)) {
return false
}
return true
}

View File

@@ -29,6 +29,19 @@ function normalizeText(value) {
return String(value || '').trim() return String(value || '').trim()
} }
function normalizeRiskCardTitle(value, fallback = '单据风险提示') {
const title = normalizeText(value)
if (!title) {
return fallback
}
const normalizedTitle = title.replace(/^(?:AI\s*提示|AI\s*建议|智能提示|系统提示)\s*[:]\s*/i, '').trim()
if (!normalizedTitle || /^(?:AI\s*提示|AI\s*建议|智能提示|系统提示)$/i.test(normalizedTitle)) {
return fallback
}
return normalizedTitle
}
function uniqueTexts(values) { function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))] return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
} }
@@ -190,47 +203,6 @@ function normalizeIdList(value) {
return [...new Set(rawValues.map((item) => normalizeId(item)).filter(Boolean))] return [...new Set(rawValues.map((item) => normalizeId(item)).filter(Boolean))]
} }
function buildExpenseItemIndexMap(expenseItems = []) {
const itemIndexById = new Map()
;(Array.isArray(expenseItems) ? expenseItems : []).forEach((item, index) => {
const itemId = normalizeId(item?.id)
if (itemId && !itemIndexById.has(itemId)) {
itemIndexById.set(itemId, index + 1)
}
})
return itemIndexById
}
function resolveRiskItemNumbers({ itemId = '', itemIds = [], itemIndex = null } = {}, expenseItems = []) {
const itemIndexById = buildExpenseItemIndexMap(expenseItems)
const itemNumbers = []
const explicitItemIndex = Number(itemIndex)
if (Number.isFinite(explicitItemIndex) && explicitItemIndex > 0) {
itemNumbers.push(Math.floor(explicitItemIndex))
}
const relatedItemIds = uniqueTexts([
normalizeId(itemId),
...normalizeIdList(itemIds)
])
relatedItemIds.forEach((relatedItemId) => {
const resolvedIndex = itemIndexById.get(relatedItemId)
if (resolvedIndex) {
itemNumbers.push(resolvedIndex)
}
})
return [...new Set(itemNumbers)].sort((left, right) => left - right)
}
function buildRiskTitleWithItemNumbers(title, itemNumbers = []) {
const cleanTitle = normalizeText(title) || '单据风险提示'
if (!itemNumbers.length || /^第\s*[\d、,\s]+\s*条[:]/.test(cleanTitle)) {
return cleanTitle
}
return `${itemNumbers.join('、')} 条:${cleanTitle}`
}
function resolveItemRiskFlag(item, claimRiskFlags) { function resolveItemRiskFlag(item, claimRiskFlags) {
const itemId = normalizeId(item?.id) const itemId = normalizeId(item?.id)
if (!itemId || !Array.isArray(claimRiskFlags)) { if (!itemId || !Array.isArray(claimRiskFlags)) {
@@ -489,7 +461,10 @@ function buildCardSuggestion(analysis, insight) {
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis, businessStage = 'reimbursement' }) { function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis, businessStage = 'reimbursement' }) {
const tone = normalizeTone(analysis?.severity) const tone = normalizeTone(analysis?.severity)
const title = normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name) || '附件风险' const title = normalizeRiskCardTitle(
normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name),
'附件风险'
)
return withRiskTags({ return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`, id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
@@ -499,7 +474,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement', businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
tone, tone,
label: resolveRiskLevelLabel(tone), label: resolveRiskLevelLabel(tone),
title: `${index + 1} 条:${title}`, title,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。', risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
summary: normalizeText(analysis?.summary), summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'], ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
@@ -760,14 +735,9 @@ export function buildAttachmentRiskCards({
? flagItemIds ? flagItemIds
: inferRelatedItemIdsForRisk(flag, risks, expenseItems) : inferRelatedItemIdsForRisk(flag, risks, expenseItems)
const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null
const relatedItemNumbers = resolveRiskItemNumbers({ const title = normalizeRiskCardTitle(
itemId: flagItemId, flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code,
itemIds: relatedItemIds, '单据风险提示'
itemIndex
}, expenseItems)
const title = buildRiskTitleWithItemNumbers(
normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
relatedItemNumbers
) )
const summary = normalizeText(flag.summary || flag.message || flag.reason) const summary = normalizeText(flag.summary || flag.message || flag.reason)
const ruleBasis = resolveClaimRiskRuleBasis(flag, { const ruleBasis = resolveClaimRiskRuleBasis(flag, {

View File

@@ -4,17 +4,18 @@ import {
buildStewardSuggestedActions, buildStewardSuggestedActions,
normalizeStewardPlan normalizeStewardPlan
} from './stewardPlanModel.js' } from './stewardPlanModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js' import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 10 const STEWARD_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8 const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5 const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
export function useStewardPlanFlow({ export function useStewardPlanFlow({
activeSessionType, activeSessionType,
attachedFiles, attachedFiles,
composerDraft, composerDraft,
conversationId,
currentUser, currentUser,
fileInputRef, fileInputRef,
messages, messages,
@@ -30,6 +31,7 @@ export function useStewardPlanFlow({
submitting, submitting,
reviewActionBusy, reviewActionBusy,
sessionSwitchBusy, sessionSwitchBusy,
stewardState,
toast toast
}) { }) {
const stewardTypewriterTimers = new Map() const stewardTypewriterTimers = new Map()
@@ -108,9 +110,19 @@ export function useStewardPlanFlow({
const requestPayload = buildStewardPlanRequest({ const requestPayload = buildStewardPlanRequest({
rawText, rawText,
files, files,
currentUser: currentUser.value || {} currentUser: currentUser.value || {},
conversationId: conversationId?.value || '',
stewardState: stewardState?.value || null
}) })
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload, streamRunId) const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload, streamRunId)
const nextConversationId = String(plan?.conversation_id || plan?.conversationId || '').trim()
if (nextConversationId && conversationId) {
conversationId.value = nextConversationId
}
const nextStewardState = plan?.steward_state || plan?.stewardState || null
if (nextStewardState && typeof nextStewardState === 'object' && stewardState) {
stewardState.value = nextStewardState
}
await waitForStewardThinkingQueue(streamRunId) await waitForStewardThinkingQueue(streamRunId)
const typedThinkingEvents = resolveStewardThinkingEvents(pendingMessage.id) const typedThinkingEvents = resolveStewardThinkingEvents(pendingMessage.id)
const normalizedPlan = normalizeStewardPlan(plan, { const normalizedPlan = normalizeStewardPlan(plan, {
@@ -176,7 +188,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) { if (runId !== stewardTypewriterRunId) {
return return
} }
index = resolveStewardTypewriterNextIndex(chars, index) index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
const message = messages.value.find((item) => item.id === messageId) const message = messages.value.find((item) => item.id === messageId)
if (!message) { if (!message) {
return return
@@ -188,8 +200,10 @@ export function useStewardPlanFlow({
...normalizedPlan, ...normalizedPlan,
streamStatus: 'typing' streamStatus: 'typing'
} }
if (index % 4 === 0 || index === total) {
nextTick(scrollToBottom) nextTick(scrollToBottom)
} }
}
const message = messages.value.find((item) => item.id === messageId) const message = messages.value.find((item) => item.id === messageId)
if (!message || runId !== stewardTypewriterRunId) { if (!message || runId !== stewardTypewriterRunId) {

View File

@@ -601,7 +601,7 @@ export function useTravelReimbursementFlow({
startFlowStep('pre-submit-review', { startFlowStep('pre-submit-review', {
title: '自动检测与风险识别', title: '自动检测与风险识别',
tool: 'ExpenseClaimService.submit_claim', tool: 'ExpenseClaimService.submit_claim',
detail: '正在校验基础规则、风险规则和审批路径...' detail: '正在校验财务规则、风险规则和审批路径...'
}) })
} }
@@ -847,7 +847,7 @@ export function useTravelReimbursementFlow({
if (String(response.status || '').trim() === 'submitted') { if (String(response.status || '').trim() === 'submitted') {
return isApplicationSessionActive() return isApplicationSessionActive()
? '申请单提交成功' ? '申请单提交成功'
: `已完成基础规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}` : `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
} }
if (response.submission_blocked) { if (response.submission_blocked) {
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批' return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'

View File

@@ -126,6 +126,10 @@ export function useTravelReimbursementSessionState({
return buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value) return buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
} }
function normalizeStewardState(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
}
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) { function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
const sessionType = resolveAccessibleSessionType( const sessionType = resolveAccessibleSessionType(
resolveInitialSessionType(conversation, fallbackSessionType), resolveInitialSessionType(conversation, fallbackSessionType),
@@ -139,6 +143,7 @@ export function useTravelReimbursementSessionState({
sessionType, sessionType,
messages: buildSessionMessages(restoredMessages, sessionType), messages: buildSessionMessages(restoredMessages, sessionType),
conversationId: resolveInitialConversationId(conversation), conversationId: resolveInitialConversationId(conversation),
stewardState: normalizeStewardState(conversation?.state_json?.steward_state || conversation?.stateJson?.stewardState),
draftClaimId: resolveInitialDraftClaimId(conversation), draftClaimId: resolveInitialDraftClaimId(conversation),
currentInsight: isStewardSessionType(sessionType) currentInsight: isStewardSessionType(sessionType)
? buildSessionInsight(sessionType) ? buildSessionInsight(sessionType)
@@ -162,6 +167,7 @@ export function useTravelReimbursementSessionState({
sessionType: normalizedSessionType, sessionType: normalizedSessionType,
messages: buildSessionMessages([], normalizedSessionType), messages: buildSessionMessages([], normalizedSessionType),
conversationId: '', conversationId: '',
stewardState: null,
draftClaimId: '', draftClaimId: '',
currentInsight: buildSessionInsight(normalizedSessionType), currentInsight: buildSessionInsight(normalizedSessionType),
reviewFilePreviews: [], reviewFilePreviews: [],
@@ -197,6 +203,7 @@ export function useTravelReimbursementSessionState({
sessionType, sessionType,
messages: buildSessionMessages(restoredMessages, sessionType), messages: buildSessionMessages(restoredMessages, sessionType),
conversationId: String(state.conversationId || '').trim(), conversationId: String(state.conversationId || '').trim(),
stewardState: normalizeStewardState(state.stewardState),
draftClaimId: String(state.draftClaimId || '').trim(), draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: isStewardSessionType(sessionType) currentInsight: isStewardSessionType(sessionType)
? buildSessionInsight(sessionType) ? buildSessionInsight(sessionType)
@@ -248,6 +255,7 @@ export function useTravelReimbursementSessionState({
const activeSessionType = ref(initialSessionState.sessionType) const activeSessionType = ref(initialSessionState.sessionType)
const messages = ref(initialSessionState.messages) const messages = ref(initialSessionState.messages)
const conversationId = ref(initialSessionState.conversationId) const conversationId = ref(initialSessionState.conversationId)
const stewardState = ref(normalizeStewardState(initialSessionState.stewardState))
const draftClaimId = ref(initialSessionState.draftClaimId) const draftClaimId = ref(initialSessionState.draftClaimId)
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews) const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
const sessionSnapshots = ref( const sessionSnapshots = ref(
@@ -268,6 +276,7 @@ export function useTravelReimbursementSessionState({
sessionType: resolveAccessibleSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()), sessionType: resolveAccessibleSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()),
messages: serializeSessionMessages(state.messages), messages: serializeSessionMessages(state.messages),
conversationId: String(state.conversationId || '').trim(), conversationId: String(state.conversationId || '').trim(),
stewardState: normalizeStewardState(state.stewardState),
draftClaimId: String(state.draftClaimId || '').trim(), draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: state.currentInsight || null, currentInsight: state.currentInsight || null,
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews), reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
@@ -289,6 +298,7 @@ export function useTravelReimbursementSessionState({
String(persistedState.conversationId || '').trim() String(persistedState.conversationId || '').trim()
|| String(persistedState.draftClaimId || '').trim() || String(persistedState.draftClaimId || '').trim()
|| hasMeaningfulSessionMessages(persistedState.messages) || hasMeaningfulSessionMessages(persistedState.messages)
|| Boolean(persistedState.stewardState)
|| String(persistedState.composerDraft || '').trim() || String(persistedState.composerDraft || '').trim()
) )
@@ -306,6 +316,7 @@ export function useTravelReimbursementSessionState({
sessionType: activeSessionType.value, sessionType: activeSessionType.value,
messages: messages.value, messages: messages.value,
conversationId: conversationId.value, conversationId: conversationId.value,
stewardState: stewardState.value,
draftClaimId: draftClaimId.value, draftClaimId: draftClaimId.value,
currentInsight: currentInsight.value, currentInsight: currentInsight.value,
reviewFilePreviews: reviewFilePreviews.value, reviewFilePreviews: reviewFilePreviews.value,
@@ -330,6 +341,7 @@ export function useTravelReimbursementSessionState({
activeSessionType.value activeSessionType.value
) )
conversationId.value = String(nextState.conversationId || '').trim() conversationId.value = String(nextState.conversationId || '').trim()
stewardState.value = normalizeStewardState(nextState.stewardState)
draftClaimId.value = String(nextState.draftClaimId || '').trim() draftClaimId.value = String(nextState.draftClaimId || '').trim()
currentInsight.value = isStewardSessionType(activeSessionType.value) currentInsight.value = isStewardSessionType(activeSessionType.value)
? buildSessionInsight(activeSessionType.value) ? buildSessionInsight(activeSessionType.value)
@@ -399,6 +411,7 @@ export function useTravelReimbursementSessionState({
activeSessionType, activeSessionType,
messages, messages,
conversationId, conversationId,
stewardState,
draftClaimId, draftClaimId,
sessionSnapshots, sessionSnapshots,
currentInsight, currentInsight,

View File

@@ -1,775 +0,0 @@
import { fetchStewardRuntimeDecision } from '../../services/steward.js'
import {
buildApplicationPreviewSubmitText,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
buildTravelPlanningNudgeMessage,
buildTravelPlanningSuggestedActions
} from '../../utils/travelApplicationPlanning.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_STEWARD
} from './travelReimbursementConversationModel.js'
import {
APPLICATION_PREVIEW_FIELD_ACTION_SET,
STEWARD_ASSISTANT_NAME,
isApplicationSubmitConfirmationText,
isStewardRuntimeCancelText,
isStewardRuntimeContinueText,
normalizeStewardRuntimeInputText,
resolveStewardRuntimeTransportAlias,
shouldPlanNewStewardTasksLocally
} from './travelReimbursementStewardRuntimeTextModel.js'
import {
buildStewardContinuationAfterAction,
pushStewardContinuationMessage,
resolveStewardMissingFieldItems
} from './travelReimbursementStewardFollowupFlow.js'
export { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
export function useTravelReimbursementStewardRuntime(ctx) {
const {
activeSessionType,
applicationSubmitConfirmDialog,
attachedFiles,
composerDraft,
createMessage,
currentUser,
emit,
handleSuggestedAction,
isStewardSession,
linkedRequest,
messages,
nextTick,
persistSessionState,
props,
reviewActionBusy,
scrollToBottom,
sessionSwitchBusy,
submitComposer,
submitStewardPlan,
submitting,
toast,
adjustComposerTextareaHeight,
resolveCurrentUserId
} = ctx
function findLatestApplicationPreviewMessage() {
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
!message.applicationPreview ||
message.applicationSubmitConfirmed
) {
continue
}
return message
}
return null
}
function findPendingApplicationSubmitMessage() {
const message = findLatestApplicationPreviewMessage()
if (!message) {
return null
}
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
if (normalizedPreview.readyToSubmit) {
message.applicationPreview = normalizedPreview
return message
}
return null
}
function pushApplicationSubmitBlockedMessage(userText = '', message = null, options = {}) {
const normalizedPreview = normalizeApplicationPreview(message?.applicationPreview || {})
const missingFields = Array.isArray(normalizedPreview.missingFields)
? normalizedPreview.missingFields
: []
const validationIssues = Array.isArray(normalizedPreview.validationIssues)
? normalizedPreview.validationIssues
: []
if (userText && !options.userMessageAlreadyAdded) {
messages.value.push(createMessage('user', userText))
}
messages.value.push(createMessage(
'assistant',
[
'我理解你是在确认当前申请单,但这张申请单还不能提交。',
'',
missingFields.length
? `还需要先补充:**${missingFields.join('、')}**。`
: validationIssues.length
? `需要先修正:**${validationIssues[0].message}**`
: '请先把申请核对表中的待补充信息补齐。',
'',
'补齐后再输入“确认”,我会继续提交至审批流程。'
].join('\n'),
[],
{
assistantName: String(message?.assistantName || '').trim() || undefined,
meta: ['等待补充']
}
))
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
async function handleApplicationSubmitConfirmationText(options = {}) {
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!isApplicationSubmitConfirmationText(rawText) || files.length) {
return false
}
const latestApplicationMessage = findLatestApplicationPreviewMessage()
if (!latestApplicationMessage) {
return false
}
const targetMessage = findPendingApplicationSubmitMessage()
if (!targetMessage) {
pushApplicationSubmitBlockedMessage(rawText, latestApplicationMessage)
return true
}
applicationSubmitConfirmDialog.value = {
open: true,
message: targetMessage
}
await confirmApplicationSubmit({ userText: rawText })
return true
}
function findPendingStewardSuggestedActionContext(decision = null) {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetTaskId = String(decision?.target_task_id || decision?.targetTaskId || '').trim()
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
if (targetMessageId && String(message.id || '') !== targetMessageId) {
continue
}
const action = message.suggestedActions.find((item) => {
if (String(item?.action_type || '').trim() === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return false
}
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
return !targetTaskId ||
String(payload.steward_next_task_id || payload.target_task_id || '').trim() === targetTaskId
}) || message.suggestedActions[0]
if (action) {
return { message, action }
}
}
return null
}
function findPendingSlotSuggestedActionContext(decision = null) {
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
const fieldValue = String(decision?.field_value || decision?.fieldValue || '').trim()
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
const action = message.suggestedActions.find((item) => {
if (String(item?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return false
}
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
const payloadField = String(payload.field_key || payload.fieldKey || '').trim()
const payloadValue = String(payload.value || item?.label || '').trim()
return payloadField && (!fieldKey || payloadField === fieldKey) && (!fieldValue || payloadValue === fieldValue)
})
if (action) {
return { message, action }
}
}
return null
}
function findPendingSlotSuggestedActionContextByInput(rawText = '') {
const normalizedInput = normalizeStewardRuntimeInputText(rawText)
if (!normalizedInput) {
return null
}
const transportAlias = resolveStewardRuntimeTransportAlias(normalizedInput)
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
const exactMatches = []
const fuzzyMatches = []
message.suggestedActions.forEach((action) => {
if (String(action?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return
}
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
const value = String(payload.value || action?.label || '').trim()
const label = String(action?.label || value).trim()
const tokens = [value, label]
.map((item) => normalizeStewardRuntimeInputText(item))
.filter(Boolean)
if (!fieldKey || !value || !tokens.length) {
return
}
if (tokens.includes(normalizedInput)) {
exactMatches.push({ message, action })
return
}
const actionTransportAlias = resolveStewardRuntimeTransportAlias(`${value}${label}`)
if (
transportAlias &&
(
tokens.includes(normalizeStewardRuntimeInputText(transportAlias)) ||
actionTransportAlias === transportAlias
)
) {
fuzzyMatches.push({ message, action })
return
}
if (tokens.some((token) => token.length >= 2 && normalizedInput.includes(token))) {
fuzzyMatches.push({ message, action })
}
})
if (exactMatches.length === 1) {
return exactMatches[0]
}
if (exactMatches.length > 1) {
return null
}
const uniqueFuzzyMatches = fuzzyMatches.filter((item, index, list) =>
list.findIndex((candidate) => candidate.action === item.action) === index
)
if (uniqueFuzzyMatches.length === 1) {
return uniqueFuzzyMatches[0]
}
if (uniqueFuzzyMatches.length > 1) {
return null
}
}
return null
}
function buildStewardRuntimeState() {
const latestApplicationMessage = findLatestApplicationPreviewMessage()
const applicationPreview = latestApplicationMessage?.applicationPreview
? normalizeApplicationPreview(latestApplicationMessage.applicationPreview)
: null
const applicationContinuation = latestApplicationMessage?.stewardContinuation || null
const pendingSlotContext = findPendingSlotSuggestedActionContext()
const pendingStewardContext = pendingSlotContext ? null : findPendingStewardSuggestedActionContext()
const pendingActionPayload = pendingStewardContext?.action?.payload && typeof pendingStewardContext.action.payload === 'object'
? pendingStewardContext.action.payload
: {}
const pendingSlotPayload = pendingSlotContext?.action?.payload && typeof pendingSlotContext.action.payload === 'object'
? pendingSlotContext.action.payload
: {}
const continuation = applicationContinuation || pendingStewardContext?.message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
const pendingApplication = latestApplicationMessage && applicationPreview
? {
message_id: String(latestApplicationMessage.id || '').trim(),
task_id: String(
applicationContinuation?.currentTaskId ||
applicationContinuation?.current_task_id ||
applicationContinuation?.currentTask?.task_id ||
applicationContinuation?.currentTask?.taskId ||
''
).trim(),
ready_to_submit: Boolean(applicationPreview.readyToSubmit),
missing_fields: Array.isArray(applicationPreview.missingFields) ? applicationPreview.missingFields : [],
fields: applicationPreview.fields || {}
}
: null
return {
waiting_for: pendingApplication
? (pendingApplication.ready_to_submit ? 'application_submit_confirmation' : 'application_field_completion')
: pendingSlotContext
? 'application_field_completion'
: pendingStewardContext
? 'steward_next_task_confirmation'
: '',
current_task: continuation?.currentTask || continuation?.current_task || null,
remaining_tasks: remainingTasks,
completed_tasks: messages.value
.filter((message) => message?.applicationSubmitConfirmed)
.map((message) => ({
message_id: String(message.id || '').trim(),
task_type: 'expense_application'
})),
pending_application: pendingApplication,
pending_steward_action: pendingStewardContext
? {
message_id: String(pendingStewardContext.message?.id || '').trim(),
action_type: String(pendingStewardContext.action?.action_type || '').trim(),
label: String(pendingStewardContext.action?.label || '').trim(),
target_task_id: String(pendingActionPayload.steward_next_task_id || pendingActionPayload.target_task_id || '').trim(),
payload: pendingActionPayload
}
: null,
pending_slot_action: pendingSlotContext
? {
message_id: String(pendingSlotContext.message?.id || '').trim(),
field_key: String(pendingSlotPayload.field_key || pendingSlotPayload.fieldKey || '').trim(),
label: String(pendingSlotContext.action?.label || '').trim(),
payload: pendingSlotPayload
}
: null
}
}
function hasActiveStewardRuntimeDecisionContext(runtimeState = {}) {
return Boolean(
String(runtimeState?.waiting_for || '').trim() ||
runtimeState?.pending_application ||
runtimeState?.pending_steward_action ||
runtimeState?.pending_slot_action ||
runtimeState?.current_task ||
(Array.isArray(runtimeState?.remaining_tasks) && runtimeState.remaining_tasks.length > 0) ||
(Array.isArray(runtimeState?.completed_tasks) && runtimeState.completed_tasks.length > 0)
)
}
function pushStewardRuntimeUserMessage(userText = '') {
const normalizedText = String(userText || '').trim()
if (!normalizedText) {
return false
}
messages.value.push(createMessage('user', normalizedText))
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return true
}
function pushStewardRuntimeResponse(userText = '', decision = null, options = {}) {
if (userText && !options.userMessageAlreadyAdded) {
messages.value.push(createMessage('user', userText))
}
const text = String(decision?.question || decision?.response_text || decision?.responseText || decision?.rationale || '').trim()
if (text) {
messages.value.push(createMessage('assistant', text, [], {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME]
}))
}
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
function buildStewardRuntimeFastPathDecision(rawText = '', runtimeState = {}) {
const normalizedText = String(rawText || '').trim()
if (!normalizedText) {
return null
}
if (shouldPlanNewStewardTasksLocally(normalizedText, runtimeState)) {
return {
next_action: 'plan_new_tasks'
}
}
if (isStewardRuntimeCancelText(normalizedText)) {
return {
next_action: 'cancel_current_action',
response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果你要重新规划,请直接告诉我新的财务事项。'
}
}
const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText)
const payload = slotContext?.action?.payload && typeof slotContext.action.payload === 'object'
? slotContext.action.payload
: {}
if (slotContext) {
return {
next_action: 'fill_current_slot',
target_message_id: String(slotContext.message?.id || '').trim(),
field_key: String(payload.field_key || payload.fieldKey || '').trim(),
field_value: String(payload.value || slotContext.action?.label || normalizedText).trim()
}
}
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
if (runtimeState?.pending_application?.ready_to_submit) {
return {
next_action: 'submit_current_application',
target_message_id: runtimeState.pending_application.message_id || ''
}
}
if (runtimeState?.pending_steward_action) {
return {
next_action: 'continue_next_task',
target_message_id: runtimeState.pending_steward_action.message_id || '',
target_task_id: runtimeState.pending_steward_action.target_task_id || ''
}
}
}
if (String(runtimeState?.waiting_for || '').trim() === 'application_field_completion') {
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
const missingFields = Array.isArray(runtimeState?.pending_application?.missing_fields)
? runtimeState.pending_application.missing_fields
: []
return {
next_action: 'ask_user',
response_text: missingFields.length
? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。你可以直接回复对应选项或填写具体内容。`
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
}
}
}
return null
}
function shouldUseStewardRuntimeLlmDecision(rawText = '', runtimeState = {}) {
if (shouldPlanNewStewardTasksLocally(rawText, runtimeState)) {
return false
}
const normalizedText = normalizeStewardRuntimeInputText(rawText)
if (!normalizedText) {
return false
}
if (
isApplicationSubmitConfirmationText(normalizedText) ||
isStewardRuntimeContinueText(normalizedText) ||
isStewardRuntimeCancelText(normalizedText)
) {
return false
}
if (
findPendingSlotSuggestedActionContextByInput(normalizedText)
) {
return false
}
return true
}
async function executeStewardRuntimeDecision(decision = null, rawText = '', options = {}) {
const nextAction = String(decision?.next_action || decision?.nextAction || '').trim()
const userMessageAlreadyAdded = Boolean(options.userMessageAlreadyAdded)
if (nextAction === 'submit_current_application') {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetMessage = targetMessageId
? messages.value.find((message) => String(message.id || '') === targetMessageId)
: findPendingApplicationSubmitMessage()
if (!targetMessage?.applicationPreview) {
return false
}
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
if (!normalizedPreview.readyToSubmit) {
pushApplicationSubmitBlockedMessage(rawText, targetMessage, { userMessageAlreadyAdded })
return true
}
targetMessage.applicationPreview = normalizedPreview
applicationSubmitConfirmDialog.value = { open: true, message: targetMessage }
await confirmApplicationSubmit({ userText: rawText, skipUserMessage: userMessageAlreadyAdded })
return true
}
if (nextAction === 'continue_next_task') {
const context = findPendingStewardSuggestedActionContext(decision)
if (!context) {
return false
}
if (rawText && !userMessageAlreadyAdded) {
messages.value.push(createMessage('user', rawText))
}
context.action.confirmedByText = true
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
await handleSuggestedAction(context.message, context.action)
return true
}
if (nextAction === 'fill_current_slot') {
const context = findPendingSlotSuggestedActionContext(decision)
if (!context) {
return false
}
await handleSuggestedAction(context.message, {
...context.action,
label: String(decision?.field_value || decision?.fieldValue || context.action.label || '').trim(),
suppressUserEcho: userMessageAlreadyAdded
})
return true
}
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
return true
}
return false
}
async function handleStewardRuntimeDecision(options = {}) {
if (!isStewardSession.value || options.skipStewardPlan) {
return false
}
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!rawText || files.length) {
return false
}
const runtimeState = buildStewardRuntimeState()
if (!hasActiveStewardRuntimeDecisionContext(runtimeState)) {
return false
}
const userMessageAlreadyAdded = options.skipUserMessage
? false
: pushStewardRuntimeUserMessage(rawText)
try {
const fastDecision = buildStewardRuntimeFastPathDecision(rawText, runtimeState)
if (fastDecision) {
if (String(fastDecision.next_action || fastDecision.nextAction || '').trim() === 'plan_new_tasks') {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
})
return true
}
const fastExecuted = await executeStewardRuntimeDecision(fastDecision, rawText, { userMessageAlreadyAdded })
if (fastExecuted) {
return true
}
}
if (!shouldUseStewardRuntimeLlmDecision(rawText, runtimeState)) {
if (userMessageAlreadyAdded) {
pushStewardRuntimeResponse('', {
response_text: '我还需要先确认当前等待项。请回复系统刚刚追问的选项或具体补充内容。'
}, { userMessageAlreadyAdded: true })
return true
}
return false
}
const decision = await fetchStewardRuntimeDecision({
user_message: rawText,
session_type: SESSION_TYPE_STEWARD,
runtime_state: runtimeState,
context_json: {
entry_source: props.entrySource,
user_id: resolveCurrentUserId()
}
}, {
timeoutMs: 45000,
timeoutMessage: '小财管家运行时决策超时,已回到当前上下文兜底处理。'
})
if (String(decision?.next_action || decision?.nextAction || '').trim() === 'plan_new_tasks') {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
})
return true
}
const executed = await executeStewardRuntimeDecision(decision, rawText, { userMessageAlreadyAdded })
if (executed) {
return true
}
if (userMessageAlreadyAdded) {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: true
})
return true
}
return false
} catch (error) {
console.warn('Steward runtime decision failed:', error)
if (userMessageAlreadyAdded) {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: true
})
return true
}
return false
}
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
if (message.applicationPreview) {
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
message.applicationPreview = normalizedPreview
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
if (!normalizedPreview.readyToSubmit) {
const validationIssues = Array.isArray(normalizedPreview.validationIssues)
? normalizedPreview.validationIssues
: []
toast(
validationIssues.length
? validationIssues[0].message
: `请先补充:${normalizedPreview.missingFields.join('、')}`
)
persistSessionState()
return
}
}
applicationSubmitConfirmDialog.value = {
open: true,
message
}
}
function closeApplicationSubmitConfirm() {
if (reviewActionBusy.value) {
return
}
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
}
function resolveApplicationEditClaimId() {
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
return ''
}
const request = linkedRequest.value || {}
if (!request.applicationEditMode) {
return ''
}
return String(request.claimId || request.claim_id || '').trim()
}
async function confirmApplicationSubmit(options = {}) {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
return
}
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
? normalizeApplicationPreview(message.applicationPreview)
: null
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
const applicationEditClaimId = resolveApplicationEditClaimId()
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
const stewardSubmitContinuation = message?.stewardContinuation || null
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: applicationSubmitText,
userText: String(options.userText || '').trim() || '确认提交',
skipUserMessage: Boolean(options.skipUserMessage),
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
stewardContinuation: stewardSubmitContinuation,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claim_id || '').trim()
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
message.applicationSubmitConfirmed = true
emit('draft-saved', {
claimId,
claimNo,
status: 'submitted',
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
documentType: 'application'
})
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
...action,
payload: {
...(action.payload || {}),
applicationPreview,
draftPayload
}
}))
if (planningText && planningActions.length) {
messages.value.push(createMessage('assistant', planningText, [], {
meta: ['行程规划推荐'],
suggestedActions: planningActions
}))
persistSessionState()
nextTick(scrollToBottom)
}
const stewardFollowup = buildStewardContinuationAfterAction({
createMessage,
message,
completedLabel: '申请单已完成'
})
if (stewardFollowup) {
await pushStewardContinuationMessage({
finalMessage: stewardFollowup,
messages,
nextTick,
persistSessionState,
scrollToBottom
})
}
} finally {
reviewActionBusy.value = false
}
}
return {
closeApplicationSubmitConfirm,
confirmApplicationSubmit,
handleApplicationSubmitConfirmationText,
handleStewardRuntimeDecision,
isApplicationSubmitConfirmationText,
openApplicationSubmitConfirm,
resolveStewardMissingFieldItems
}
}

View File

@@ -17,9 +17,9 @@ import {
normalizeApplicationPreview, normalizeApplicationPreview,
normalizeTransportModeOption, normalizeTransportModeOption,
resolveApplicationDateRange, resolveApplicationDateRange,
shouldRequireApplicationModelReview,
shouldUseLocalApplicationPreview shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js' } from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import { fetchOntologyParse } from '../../services/ontology.js' import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js' import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js' import { calculateTravelReimbursement } from '../../services/reimbursements.js'
@@ -29,11 +29,11 @@ import {
handleBudgetCompileReportSubmit, handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js' } from './budgetAssistantReportModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
const STEWARD_ASSISTANT_NAME = '小财管家' const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10 const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8 const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5 const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field' const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
@@ -44,13 +44,6 @@ const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
reason: 'reason', reason: 'reason',
amount: 'amount', amount: 'amount',
transportMode: 'transport_mode', transportMode: 'transport_mode',
transportEstimatedAmount: 'transport_estimated_amount',
trainEstimatedAmount: 'train_estimated_amount',
flightEstimatedAmount: 'flight_estimated_amount',
hotelAmount: 'hotel_amount',
allowanceAmount: 'allowance_amount',
policyTotalAmount: 'policy_total_amount',
reimbursementAmount: 'reimbursement_amount',
department: 'department_name', department: 'department_name',
applicant: 'employee_name', applicant: 'employee_name',
grade: 'employee_grade' grade: 'employee_grade'
@@ -82,13 +75,6 @@ const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
reason: 'reason', reason: 'reason',
amount: 'amount', amount: 'amount',
transport_mode: 'transportMode', transport_mode: 'transportMode',
transport_estimated_amount: 'transportEstimatedAmount',
train_estimated_amount: 'trainEstimatedAmount',
flight_estimated_amount: 'flightEstimatedAmount',
hotel_amount: 'hotelAmount',
allowance_amount: 'allowanceAmount',
policy_total_amount: 'policyTotalAmount',
reimbursement_amount: 'reimbursementAmount',
department_name: 'department', department_name: 'department',
employee_name: 'applicant', employee_name: 'applicant',
employee_grade: 'grade' employee_grade: 'grade'
@@ -101,13 +87,6 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
reason: '事由', reason: '事由',
amount: '金额', amount: '金额',
transport_mode: '出行方式', transport_mode: '出行方式',
transport_estimated_amount: '交通费用预估',
train_estimated_amount: '火车费用预估',
flight_estimated_amount: '飞机费用预估',
hotel_amount: '住宿测算金额',
allowance_amount: '出差补贴金额',
policy_total_amount: '规则测算合计',
reimbursement_amount: '实际报销金额',
attachments: '附件/凭证', attachments: '附件/凭证',
customer_name: '客户或项目对象', customer_name: '客户或项目对象',
merchant_name: '商户/开票方', merchant_name: '商户/开票方',
@@ -118,13 +97,6 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([ const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
'amount', 'amount',
'transport_estimated_amount',
'train_estimated_amount',
'flight_estimated_amount',
'hotel_amount',
'allowance_amount',
'policy_total_amount',
'reimbursement_amount',
'attachments', 'attachments',
'employee_no', 'employee_no',
'department_name', 'department_name',
@@ -628,11 +600,24 @@ export function useTravelReimbursementSubmitComposer(ctx) {
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') { function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview) const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized) const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
if (!missingFields.length) { if (!missingFields.length) {
return fallbackText return fallbackText
} }
if (missingFields.includes('出行方式')) {
return [
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [ return [
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。', '我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'', '',
@@ -725,10 +710,13 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
] ]
if (missingInfo) { if (missingInfo) {
const transportMissing = /出行方式/.test(missingInfo)
events.push({ events.push({
eventId: `${eventPrefix}-gap`, eventId: `${eventPrefix}-gap`,
title: '判断待补充信息', title: '判断待补充信息',
content: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。` content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
}) })
} else { } else {
events.push({ events.push({
@@ -821,12 +809,14 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const chars = Array.from(text) const chars = Array.from(text)
for (let index = 0; index < chars.length;) { for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS) await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index) index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
message.text = chars.slice(0, index).join('') message.text = chars.slice(0, index).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中'] message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing') message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom) nextTick(scrollToBottom)
} }
}
Object.assign(message, finalExtras, { Object.assign(message, finalExtras, {
id: messageId, id: messageId,
@@ -849,39 +839,13 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
} }
function isApplicationDraftPayload(draftPayload) {
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
}
function isSubmittedApplicationDraftPayload(draftPayload) { function isSubmittedApplicationDraftPayload(draftPayload) {
return ( return (
isApplicationDraftPayload(draftPayload) String(draftPayload?.draft_type || '').trim() === 'expense_application'
&& String(draftPayload?.status || '').trim() === 'submitted' && String(draftPayload?.status || '').trim() === 'submitted'
) )
} }
function shouldExposeReviewPayloadForMessage(payload, options = {}) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
if (options.isApplicationSubmitOperation || isApplicationDraftPayload(result.draft_payload)) {
return false
}
return true
}
function buildPresentationPayload(payload, { exposeReviewPayload = true } = {}) {
if (exposeReviewPayload) {
return payload
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
return {
...payload,
result: {
...result,
review_payload: null
}
}
}
function buildOperationFeedbackState(context) { function buildOperationFeedbackState(context) {
if (!context) { if (!context) {
return null return null
@@ -1226,6 +1190,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return preview return preview
} }
try { try {
const fields = preview?.fields || {}
await waitForMockApplicationTransportQuote({
transportMode: fields.transportMode,
location: fields.location,
time: fields.time
})
const result = await calculateTravelReimbursement(estimateRequest.payload) const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user) return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) { } catch (error) {
@@ -1234,8 +1204,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
} }
const requireModelReview = shouldRequireApplicationModelReview(rawText) if (options.skipModelReview) {
if (options.skipModelReview && !requireModelReview) {
return { return {
applicationPreview: await enrichWithPolicyEstimate({ applicationPreview: await enrichWithPolicyEstimate({
...localPreview, ...localPreview,
@@ -2073,31 +2042,24 @@ export function useTravelReimbursementSubmitComposer(ctx) {
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round') operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
}) })
: null : null
const exposeReviewPayload = shouldExposeReviewPayloadForMessage(payload, { isApplicationSubmitOperation }) const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
const presentationPayload = buildPresentationPayload(payload, { exposeReviewPayload }) meta: buildMessageMeta(payload, effectiveFileNames),
const presentationResult = presentationPayload?.result && typeof presentationPayload.result === 'object' citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
? presentationPayload.result suggestedActions: Array.isArray(payload?.result?.suggested_actions)
: {} ? payload.result.suggested_actions
const resultReviewPayload = presentationResult.review_payload || null : [],
const resultSuggestedActions = exposeReviewPayload && Array.isArray(presentationResult.suggested_actions) queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
? presentationResult.suggested_actions draftPayload: payload?.result?.draft_payload || null,
: [] reviewPayload: payload?.result?.review_payload || null,
const assistantMessage = createMessage('assistant', resolveAssistantResultText(presentationPayload, fallbackAnswer), [], {
meta: buildMessageMeta(presentationPayload, effectiveFileNames),
citations: Array.isArray(presentationResult.citations) ? presentationResult.citations : [],
suggestedActions: resultSuggestedActions,
queryPayload: normalizeExpenseQueryPayload(presentationResult.query_payload),
draftPayload: presentationResult.draft_payload || null,
reviewPayload: resultReviewPayload,
reviewPanelScope: stewardDelegated reviewPanelScope: stewardDelegated
? '' ? ''
: resolveReviewPanelScope({ : resolveReviewPanelScope({
reviewPayload: resultReviewPayload, reviewPayload: payload?.result?.review_payload || null,
reviewAction: reviewActionResult, reviewAction: reviewActionResult,
fileCount: files.length, fileCount: files.length,
rawText rawText
}), }),
riskFlags: Array.isArray(presentationResult.risk_flags) ? presentationResult.risk_flags : [], riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
operationFeedback: buildOperationFeedbackState(operationFeedbackContext), operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined, assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
stewardContinuation: options.stewardContinuation || null stewardContinuation: options.stewardContinuation || null
@@ -2122,7 +2084,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} else { } else {
replaceMessage(pendingMessage.id, assistantMessage) replaceMessage(pendingMessage.id, assistantMessage)
const nextInsight = buildAgentInsight( const nextInsight = buildAgentInsight(
presentationPayload, payload,
effectiveFileNames, effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews) mergeFilePreviews(filePreviews, ocrFilePreviews)
) )

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildStewardSuggestedActions
} from '../src/views/scripts/stewardPlanModel.js'
test('steward pending flow confirmation builds candidate actions', () => {
const actions = buildStewardSuggestedActions({
plan_id: 'steward-plan-pending-flow',
plan_status: 'needs_flow_confirmation',
next_action: 'confirm_flow',
pending_flow_confirmation: {
status: 'pending',
reason: '缺少申请或报销动作词。',
candidate_flows: [
{
flow_id: 'travel_application',
label: '补办出差申请',
confidence: 0.52,
ontology_fields: {
time_range: '2026-02-20',
location: '上海',
expense_type: 'travel',
reason: '辅助国网仿生产环境部署'
},
missing_fields: ['transport_mode']
},
{
flow_id: 'travel_reimbursement',
label: '发起费用报销',
confidence: 0.48,
ontology_fields: {
time_range: '2026-02-20',
location: '上海',
expense_type: 'travel',
reason: '辅助国网仿生产环境部署'
},
missing_fields: []
}
]
}
})
assert.equal(actions.length, 2)
assert.deepEqual(actions.map((item) => item.label), ['补办出差申请', '发起费用报销'])
assert.equal(actions[0].payload.steward_confirm_flow, true)
assert.equal(actions[0].payload.flow_id, 'travel_application')
assert.equal(actions[1].payload.flow_id, 'travel_reimbursement')
})

View File

@@ -438,7 +438,7 @@ test('AI advice hides generic auto review summaries when a specific hotel over-s
}) })
assert.equal(riskCards.length, 1) assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].title, '第 1 条AI提示住宿金额超出报销标准') assert.equal(riskCards[0].title, '住宿金额超出报销标准')
assert.equal(riskCards[0].tone, 'high') assert.equal(riskCards[0].tone, 'high')
}) })
@@ -636,7 +636,7 @@ test('route-level risk cards keep related item ids for every affected expense ro
assert.equal(riskCards.length, 1) assert.equal(riskCards.length, 1)
assert.deepEqual(riskCards[0].itemIds, ['travel-item-2', 'travel-item-3']) assert.deepEqual(riskCards[0].itemIds, ['travel-item-2', 'travel-item-3'])
assert.equal(riskCards[0].title, '第 2、3 条:多城市行程待说明') assert.equal(riskCards[0].title, '多城市行程待说明')
assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/) assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/)
}) })
@@ -810,10 +810,35 @@ test('expense detail table shows each item filled time from item creation time',
test('expense detail table has per-item risk explanation column', () => { test('expense detail table has per-item risk explanation column', () => {
assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/) assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
assert.match(detailViewTemplate, /<EnterpriseSelect[\s\S]*class="editor-select"/)
assert.match(detailViewTemplate, /<ElDatePicker[\s\S]*v-model="expenseEditor\.itemDate"/)
assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemReason"/)
assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemAmount"/)
assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemNote"[\s\S]*type="textarea"[\s\S]*:rows="1"/)
assert.doesNotMatch(detailViewTemplate, /<input[\s\S]*v-model="expenseEditor\./)
assert.doesNotMatch(detailViewTemplate, /<textarea[\s\S]*v-model="expenseEditor\./)
assert.match(detailViewScript, /import \{ ElDatePicker \} from 'element-plus\/es\/components\/date-picker\/index\.mjs'/)
assert.match(detailViewScript, /import \{ ElInput \} from 'element-plus\/es\/components\/input\/index\.mjs'/)
assert.match(detailViewScript, /ElDatePicker,[\s\S]*ElInput,/)
assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/) assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/)
assert.match(detailViewTemplate, /class="editor-textarea risk-note-editor-textarea"[\s\S]*rows="1"/) assert.doesNotMatch(detailViewTemplate, /@input="resizeExpenseNoteInput"/)
assert.match(detailViewTemplate, /@input="resizeExpenseNoteInput"/) assert.doesNotMatch(detailViewTemplate, /用于说明改签、绕行、超标、票据异常等情况/)
assert.match(detailViewStyle, /\.risk-note-editor-textarea[\s\S]*max-height: 78px/) assert.match(detailViewStyle, /\.detail-expense-table \.col-type \{ width: 14%; \}/)
assert.match(detailViewStyle, /\.detail-expense-table \.col-attachment \{ width: 15%; \}/)
assert.match(detailViewStyle, /\.detail-expense-table \{[\s\S]*--expense-editor-control-height: 34px;[\s\S]*--expense-editor-control-line-height: 16px;/)
assert.match(detailViewStyle, /\.editor-control \{/)
assert.match(detailViewStyle, /\.editor-control:not\(\.risk-note-editor-input\),[\s\S]*\.editor-select \{[\s\S]*height: var\(--expense-editor-control-height\);/)
assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__wrapper\),[\s\S]*\.editor-date-picker :deep\(\.el-input__wrapper\) \{/)
assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__wrapper\),[\s\S]*height: var\(--expense-editor-control-height\);[\s\S]*line-height: var\(--expense-editor-control-line-height\);/)
assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__inner\),[\s\S]*height: var\(--expense-editor-control-line-height\)( !important)?;[\s\S]*line-height: var\(--expense-editor-control-line-height\)( !important)?;/)
assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__prefix\),[\s\S]*height: var\(--expense-editor-control-height\);/)
assert.doesNotMatch(detailViewStyle, /\.editor-input,\s*\.editor-select,\s*\.editor-textarea \{/)
assert.match(detailViewStyle, /\.editor-select \{[\s\S]*padding: 0;[\s\S]*border: 0;/)
assert.match(detailViewStyle, /\.editor-select :deep\(\.el-select__wrapper\) \{[\s\S]*min-height: var\(--expense-editor-control-height\);[\s\S]*height: var\(--expense-editor-control-height\);/)
assert.match(detailViewStyle, /\.risk-note-editor-input\.el-textarea \{[\s\S]*min-height: var\(--expense-editor-control-height\);[\s\S]*height: var\(--expense-editor-control-height\);/)
assert.match(detailViewStyle, /\.risk-note-editor-input :deep\(\.el-textarea__inner\) \{[\s\S]*min-height: var\(--expense-editor-control-height\) !important;[\s\S]*height: var\(--expense-editor-control-height\);[\s\S]*line-height: var\(--expense-editor-control-line-height\)( !important)?;[\s\S]*max-height: calc\(var\(--expense-editor-control-height\) \+ var\(--expense-editor-control-line-height\) \* 2\)( !important)?;[\s\S]*resize: none( !important)?;/)
assert.doesNotMatch(detailViewScript, /resizeExpenseNoteInput/)
assert.doesNotMatch(detailViewScript, /scrollHeight/)
assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/) assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
assert.match(detailViewScript, /itemNote: ''/) assert.match(detailViewScript, /itemNote: ''/)
assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/) assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/)

View File

@@ -7,6 +7,7 @@ import {
ASSISTANT_SCOPE_SESSION_APPLICATION, ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE, ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_KNOWLEDGE, ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
ASSISTANT_SCOPE_SESSION_STEWARD,
inferAssistantScopeTarget inferAssistantScopeTarget
} from '../src/utils/assistantSessionScope.js' } from '../src/utils/assistantSessionScope.js'
import { import {
@@ -48,6 +49,10 @@ test('workbench prompt applies travel phrases to application assistant scope', (
inferAssistantScopeTarget('准备去国网现场做仿生产环境部署差旅3天'), inferAssistantScopeTarget('准备去国网现场做仿生产环境部署差旅3天'),
ASSISTANT_SCOPE_SESSION_APPLICATION ASSISTANT_SCOPE_SESSION_APPLICATION
) )
assert.equal(
inferAssistantScopeTarget('2月20-23日去上海出差辅助国网仿生产环境部署'),
ASSISTANT_SCOPE_SESSION_STEWARD
)
assert.equal( assert.equal(
inferAssistantScopeTarget('我要报销去北京的费用'), inferAssistantScopeTarget('我要报销去北京的费用'),
ASSISTANT_SCOPE_SESSION_EXPENSE ASSISTANT_SCOPE_SESSION_EXPENSE
@@ -103,6 +108,14 @@ test('workbench model routing maps ontology result before entering assistant', (
), ),
ASSISTANT_SCOPE_SESSION_APPLICATION ASSISTANT_SCOPE_SESSION_APPLICATION
) )
assert.equal(
resolveWorkbenchSessionTypeFromOntology(
travelOntology,
'2月20-23日去上海出差辅助国网仿生产环境部署',
ASSISTANT_SCOPE_SESSION_APPLICATION
),
ASSISTANT_SCOPE_SESSION_STEWARD
)
assert.equal( assert.equal(
resolveWorkbenchSessionTypeFromOntology( resolveWorkbenchSessionTypeFromOntology(
reimbursementOntology, reimbursementOntology,
@@ -128,3 +141,16 @@ test('workbench model routing maps ontology result before entering assistant', (
ASSISTANT_SCOPE_SESSION_APPLICATION ASSISTANT_SCOPE_SESSION_APPLICATION
) )
}) })
test('workbench ambiguous travel flow uses steward fast path before ontology parsing', () => {
const fastPathIndex = appShellComposable.indexOf(
'fallbackSessionType === ASSISTANT_SCOPE_SESSION_STEWARD'
)
const ontologyParseIndex = appShellComposable.indexOf('fetchOntologyParse(')
assert.ok(fastPathIndex >= 0, 'expected steward fallback fast path in smart entry routing')
assert.ok(
fastPathIndex < ontologyParseIndex,
'expected steward fallback to return before slow ontology parsing'
)
})