diff --git a/docs/superpowers/plans/2026-06-15-steward-application-reimbursement-state.md b/docs/superpowers/plans/2026-06-15-steward-application-reimbursement-state.md new file mode 100644 index 0000000..5e1dfc1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-steward-application-reimbursement-state.md @@ -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 +``` diff --git a/document/development/小财管家本体JSON流程/CONCEPT.md b/document/development/小财管家本体JSON流程/CONCEPT.md new file mode 100644 index 0000000..cbe2f9e --- /dev/null +++ b/document/development/小财管家本体JSON流程/CONCEPT.md @@ -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..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 模板。 diff --git a/document/development/小财管家本体JSON流程/TODO.md b/document/development/小财管家本体JSON流程/TODO.md new file mode 100644 index 0000000..a006a00 --- /dev/null +++ b/document/development/小财管家本体JSON流程/TODO.md @@ -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: 风险与开放问题] diff --git a/server/rules/finance-rules/交通工具等级标准.xlsx b/server/rules/finance-rules/交通工具等级标准.xlsx new file mode 100644 index 0000000..def1b3e Binary files /dev/null and b/server/rules/finance-rules/交通工具等级标准.xlsx differ diff --git a/server/rules/finance-rules/交通费用预估表.xlsx b/server/rules/finance-rules/交通费用预估表.xlsx new file mode 100644 index 0000000..08d7f37 Binary files /dev/null and b/server/rules/finance-rules/交通费用预估表.xlsx differ diff --git a/server/rules/finance-rules/公司通信费报销规则.xlsx b/server/rules/finance-rules/公司通信费报销规则.xlsx index 510877a..ea10e83 100644 Binary files a/server/rules/finance-rules/公司通信费报销规则.xlsx and b/server/rules/finance-rules/公司通信费报销规则.xlsx differ diff --git a/server/rules/finance-rules/出差补助标准.xlsx b/server/rules/finance-rules/出差补助标准.xlsx new file mode 100644 index 0000000..2934947 Binary files /dev/null and b/server/rules/finance-rules/出差补助标准.xlsx differ diff --git a/server/rules/finance-rules/地区淡旺季映射表.xlsx b/server/rules/finance-rules/地区淡旺季映射表.xlsx new file mode 100644 index 0000000..27bcb4e Binary files /dev/null and b/server/rules/finance-rules/地区淡旺季映射表.xlsx differ diff --git a/server/rules/finance-rules/差旅住宿费标准.xlsx b/server/rules/finance-rules/差旅住宿费标准.xlsx new file mode 100644 index 0000000..ea7f8fe Binary files /dev/null and b/server/rules/finance-rules/差旅住宿费标准.xlsx differ diff --git a/server/rules/finance-rules/差旅职级映射表.xlsx b/server/rules/finance-rules/差旅职级映射表.xlsx new file mode 100644 index 0000000..40e83f1 Binary files /dev/null and b/server/rules/finance-rules/差旅职级映射表.xlsx differ diff --git a/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json b/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json index 648f529..7f0f6da 100644 --- a/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json +++ b/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json @@ -1,17 +1,19 @@ { "schema_version": "2.0", "rule_code": "risk.application.large_expense_without_preapproval", - "name": "?????????", - "description": "???????? 2000 ?????????????", + "name": "通用大额费用无前置申请", + "description": "非业务招待、非办公用品的通用费用超过 2000 元且缺少关联费用申请。", "enabled": true, "requires_attachment": false, "risk_dimension": "expense_control_demo", - "risk_category": "????", + "risk_category": "申请前置", "ontology_signal": "application_required", "evaluator": "template_rule", "template_key": "composite_rule_v1", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], @@ -34,67 +36,67 @@ "fields": [ { "key": "claim.amount", - "label": "????", + "label": "报销金额", "type": "number", "source": "claim" }, { "key": "claim.expense_type", - "label": "????", + "label": "费用类型", "type": "enum", "source": "claim" }, { "key": "claim.department_name", - "label": "??", + "label": "部门", "type": "text", "source": "claim" }, { "key": "claim.reason", - "label": "??", + "label": "事由", "type": "text", "source": "claim" }, { "key": "item.item_reason", - "label": "????", + "label": "明细事由", "type": "text", "source": "item" }, { "key": "application.id", - "label": "???ID", + "label": "申请单ID", "type": "text", "source": "application" }, { "key": "application.claim_no", - "label": "????", + "label": "申请单号", "type": "text", "source": "application" }, { "key": "application.status", - "label": "????", + "label": "申请状态", "type": "enum", "source": "application" }, { "key": "application.approved_amount", - "label": "??????", + "label": "申请审批金额", "type": "number", "source": "application" }, { "key": "application.expense_type", - "label": "??????", + "label": "申请费用类型", "type": "enum", "source": "application" }, { "key": "application.department_name", - "label": "????", + "label": "申请部门", "type": "text", "source": "application" } @@ -144,10 +146,10 @@ "meal", "entertainment", "office", - "????", - "??", - "????", - "??" + "业务招待", + "招待", + "办公用品", + "办公" ] } ], @@ -161,10 +163,12 @@ ] }, "formula": "amount > threshold AND NOT hasApplication", - "condition_summary": "?????????????????? 2000 ????????????????", - "message_template": "?????? 2000 ?????????????????????????", + "condition_summary": "非业务招待、非办公用品的通用费用超过 2000 元且未关联费用申请时触发。", + "message_template": "通用大额费用超过 2000 元但未找到关联费用申请,请补充前置申请或审批说明。", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], @@ -177,14 +181,14 @@ "facts": [ { "id": "A", - "label": "????", + "label": "报销金额", "fields": [ "claim.amount" ] }, { "id": "B", - "label": "???", + "label": "关联申请", "fields": [ "application.id", "application.claim_no" @@ -192,7 +196,15 @@ } ], "hit_logic": "A > threshold AND NOT EXISTS(B)" - } + }, + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "outcomes": { "pass": { @@ -206,25 +218,43 @@ } }, "metadata": { - "owner": "??????", + "owner": "财务制度管理组", "stability": "platform", - "source_ref": "??????????", + "source_ref": "公司费用申请审批规则", "created_at": "2026-06-05T00:00:00+08:00", "created_by": "system", "risk_score": 86, "risk_level": "high", - "rule_title": "?????????", + "rule_title": "通用大额费用无前置申请", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], "expense_types": [ "all" ], - "budget_required": true + "budget_required": true, + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "severity": "high", "risk_score": 86, - "risk_level": "high" + "risk_level": "high", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] } diff --git a/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json b/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json index 0045bdd..7e11793 100644 --- a/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json +++ b/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json @@ -1,17 +1,19 @@ { "schema_version": "2.0", "rule_code": "risk.application.meal_high_value_without_preapproval", - "name": "??????????", - "description": "????????? 500 ?????????????", + "name": "业务招待高金额无前置申请", + "description": "业务招待费超过 500 元且缺少关联费用申请或审批记录。", "enabled": true, "requires_attachment": false, "risk_dimension": "expense_control_demo", - "risk_category": "????", + "risk_category": "申请前置", "ontology_signal": "application_required", "evaluator": "template_rule", "template_key": "composite_rule_v1", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], @@ -36,67 +38,67 @@ "fields": [ { "key": "claim.amount", - "label": "????", + "label": "报销金额", "type": "number", "source": "claim" }, { "key": "claim.expense_type", - "label": "????", + "label": "费用类型", "type": "enum", "source": "claim" }, { "key": "claim.department_name", - "label": "??", + "label": "部门", "type": "text", "source": "claim" }, { "key": "claim.reason", - "label": "??", + "label": "事由", "type": "text", "source": "claim" }, { "key": "item.item_reason", - "label": "????", + "label": "明细事由", "type": "text", "source": "item" }, { "key": "application.id", - "label": "???ID", + "label": "申请单ID", "type": "text", "source": "application" }, { "key": "application.claim_no", - "label": "????", + "label": "申请单号", "type": "text", "source": "application" }, { "key": "application.status", - "label": "????", + "label": "申请状态", "type": "enum", "source": "application" }, { "key": "application.approved_amount", - "label": "??????", + "label": "申请审批金额", "type": "number", "source": "application" }, { "key": "application.expense_type", - "label": "??????", + "label": "申请费用类型", "type": "enum", "source": "application" }, { "key": "application.department_name", - "label": "????", + "label": "申请部门", "type": "text", "source": "application" } @@ -146,10 +148,12 @@ ] }, "formula": "amount > threshold AND NOT hasApplication", - "condition_summary": "??????????? 500 ????????????????", - "message_template": "??????? 500 ?????????????????????????", + "condition_summary": "业务招待费超过 500 元且未关联已审批费用申请时触发。", + "message_template": "业务招待费超过 500 元但未找到关联费用申请,请补充前置申请或审批说明。", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], @@ -163,14 +167,14 @@ "facts": [ { "id": "A", - "label": "????", + "label": "报销金额", "fields": [ "claim.amount" ] }, { "id": "B", - "label": "???", + "label": "关联申请", "fields": [ "application.id", "application.claim_no" @@ -178,7 +182,15 @@ } ], "hit_logic": "A > threshold AND NOT EXISTS(B)" - } + }, + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "outcomes": { "pass": { @@ -192,16 +204,18 @@ } }, "metadata": { - "owner": "??????", + "owner": "财务制度管理组", "stability": "platform", - "source_ref": "??????????", + "source_ref": "公司费用申请审批规则", "created_at": "2026-06-05T00:00:00+08:00", "created_by": "system", "risk_score": 88, "risk_level": "high", - "rule_title": "??????????", + "rule_title": "业务招待高金额无前置申请", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], @@ -209,9 +223,25 @@ "meal", "entertainment" ], - "budget_required": true + "budget_required": true, + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "severity": "high", "risk_score": 88, - "risk_level": "high" + "risk_level": "high", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] } diff --git a/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json b/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json index fd96f97..2c2258d 100644 --- a/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json +++ b/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json @@ -1,17 +1,19 @@ { "schema_version": "2.0", "rule_code": "risk.application.office_bulk_without_purchase", - "name": "???????????", - "description": "???????????????? 2000 ???????????", + "name": "办公用品批量采购无前置申请", + "description": "办公用品或办公采购费用超过 2000 元且缺少关联费用申请或采购审批。", "enabled": true, "requires_attachment": false, "risk_dimension": "expense_control_demo", - "risk_category": "????", + "risk_category": "申请前置", "ontology_signal": "application_required", "evaluator": "template_rule", "template_key": "composite_rule_v1", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], @@ -34,67 +36,67 @@ "fields": [ { "key": "claim.amount", - "label": "????", + "label": "报销金额", "type": "number", "source": "claim" }, { "key": "claim.expense_type", - "label": "????", + "label": "费用类型", "type": "enum", "source": "claim" }, { "key": "claim.department_name", - "label": "??", + "label": "部门", "type": "text", "source": "claim" }, { "key": "claim.reason", - "label": "??", + "label": "事由", "type": "text", "source": "claim" }, { "key": "item.item_reason", - "label": "????", + "label": "明细事由", "type": "text", "source": "item" }, { "key": "application.id", - "label": "???ID", + "label": "申请单ID", "type": "text", "source": "application" }, { "key": "application.claim_no", - "label": "????", + "label": "申请单号", "type": "text", "source": "application" }, { "key": "application.status", - "label": "????", + "label": "申请状态", "type": "enum", "source": "application" }, { "key": "application.approved_amount", - "label": "??????", + "label": "申请审批金额", "type": "number", "source": "application" }, { "key": "application.expense_type", - "label": "??????", + "label": "申请费用类型", "type": "enum", "source": "application" }, { "key": "application.department_name", - "label": "????", + "label": "申请部门", "type": "text", "source": "application" } @@ -144,10 +146,12 @@ ] }, "formula": "amount > threshold AND NOT hasApplication", - "condition_summary": "???????????????? 2000 ????????????????", - "message_template": "??????? 2000 ??????????????????????????????", + "condition_summary": "办公用品费用超过 2000 元且未关联费用申请或采购审批时触发。", + "message_template": "办公用品费用超过 2000 元但未找到关联费用申请,请补充采购申请或审批说明。", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], @@ -160,14 +164,14 @@ "facts": [ { "id": "A", - "label": "????", + "label": "报销金额", "fields": [ "claim.amount" ] }, { "id": "B", - "label": "???", + "label": "关联申请", "fields": [ "application.id", "application.claim_no" @@ -175,7 +179,15 @@ } ], "hit_logic": "A > threshold AND NOT EXISTS(B)" - } + }, + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "outcomes": { "pass": { @@ -189,25 +201,43 @@ } }, "metadata": { - "owner": "??????", + "owner": "财务制度管理组", "stability": "platform", - "source_ref": "??????????", + "source_ref": "公司费用申请审批规则", "created_at": "2026-06-05T00:00:00+08:00", "created_by": "system", "risk_score": 84, "risk_level": "high", - "rule_title": "???????????", + "rule_title": "办公用品批量采购无前置申请", "finance_rule_code": "expense.preapproval.policy", - "finance_rule_sheet": "????????", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], "expense_types": [ "office" ], - "budget_required": true + "budget_required": true, + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "severity": "high", "risk_score": 84, - "risk_level": "high" + "risk_level": "high", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] } diff --git a/server/rules/risk-rules/risk.application.travel_large_without_preapproval.json b/server/rules/risk-rules/risk.application.travel_large_without_preapproval.json index 719f7e3..df764be 100644 --- a/server/rules/risk-rules/risk.application.travel_large_without_preapproval.json +++ b/server/rules/risk-rules/risk.application.travel_large_without_preapproval.json @@ -10,8 +10,8 @@ "ontology_signal": "application_required", "evaluator": "template_rule", "template_key": "keyword_match_v1", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "差旅住宿费标准", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], @@ -119,15 +119,55 @@ "未申请" ], "condition_summary": "差旅金额达到大额阈值且缺少有效出差申请时触发。", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "差旅住宿费标准", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], "expense_types": [ "travel" ], - "budget_required": true + "budget_required": true, + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + }, + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_transport_estimate", + "sheet": "交通费用预估表", + "name": "交通费用预估表", + "component": "transport_estimate" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] }, "outcomes": { "pass": { @@ -149,17 +189,97 @@ "risk_score": 82, "risk_level": "high", "rule_title": "大额差旅未申请", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "差旅住宿费标准", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "reimbursement" ], "expense_types": [ "travel" ], - "budget_required": true + "budget_required": true, + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + }, + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_transport_estimate", + "sheet": "交通费用预估表", + "name": "交通费用预估表", + "component": "transport_estimate" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] }, "severity": "high", "risk_score": 82, - "risk_level": "high" + "risk_level": "high", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + }, + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_transport_estimate", + "sheet": "交通费用预估表", + "name": "交通费用预估表", + "component": "transport_estimate" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.high.city_mismatch.json b/server/rules/risk-rules/risk.travel.high.city_mismatch.json index e323164..b3e7dfd 100644 --- a/server/rules/risk-rules/risk.travel.high.city_mismatch.json +++ b/server/rules/risk-rules/risk.travel.high.city_mismatch.json @@ -10,7 +10,7 @@ "ontology_signal": "travel_city_mismatch", "evaluator": "template_rule", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -105,7 +105,31 @@ "项目现场" ], "condition_summary": "票据城市未覆盖申报目的地,或路线出现无法由本次票据起终点和申报目的地解释的额外城市且无合理说明。", - "message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。" + "message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "差旅住宿费标准", + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "outcomes": { "pass": { @@ -121,14 +145,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 90, "risk_level": "high", "rule_title": "差旅目的地与票据城市不一致高风险", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -146,7 +170,29 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "severity": "high", "risk_score": 90, @@ -160,5 +206,27 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.high.date_outside_trip.json b/server/rules/risk-rules/risk.travel.high.date_outside_trip.json index 65ffdba..14d0985 100644 --- a/server/rules/risk-rules/risk.travel.high.date_outside_trip.json +++ b/server/rules/risk-rules/risk.travel.high.date_outside_trip.json @@ -10,7 +10,7 @@ "ontology_signal": "travel_date_outside_trip_window", "evaluator": "template_rule", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -102,7 +102,37 @@ ], "hit_logic": "ticket_date_outside_trip", "condition_summary": "任一票据/明细日期早于出差开始日前 1 天或晚于结束日后 1 天。", - "message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。" + "message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "差旅住宿费标准", + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] }, "outcomes": { "pass": { @@ -118,14 +148,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 88, "risk_level": "high", "rule_title": "票据日期超出差旅行程高风险", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -143,7 +173,35 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] }, "severity": "high", "risk_score": 88, @@ -157,5 +215,33 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.high.personal_purpose.json b/server/rules/risk-rules/risk.travel.high.personal_purpose.json index b13c80b..238b2e8 100644 --- a/server/rules/risk-rules/risk.travel.high.personal_purpose.json +++ b/server/rules/risk-rules/risk.travel.high.personal_purpose.json @@ -10,7 +10,7 @@ "ontology_signal": "travel_personal_purpose", "evaluator": "template_rule", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -76,7 +76,37 @@ ], "condition_summary": "差旅事由或票据文本命中个人旅游/私人目的关键词。", "message_template": "识别到个人旅游或非公务目的表达,请确认是否属于公司差旅范围。", - "template_key": "keyword_match_v1" + "template_key": "keyword_match_v1", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "差旅住宿费标准", + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] }, "outcomes": { "pass": { @@ -92,14 +122,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 86, "risk_level": "high", "rule_title": "个人旅游或非公务目的高风险", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -117,7 +147,35 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] }, "severity": "high", "risk_score": 86, @@ -131,5 +189,33 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.high.preapproval_absent.json b/server/rules/risk-rules/risk.travel.high.preapproval_absent.json index 2017331..11c079c 100644 --- a/server/rules/risk-rules/risk.travel.high.preapproval_absent.json +++ b/server/rules/risk-rules/risk.travel.high.preapproval_absent.json @@ -9,8 +9,8 @@ "risk_category": "差旅费-申请审批", "ontology_signal": "travel_preapproval_absent", "evaluator": "template_rule", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "expense_application", "reimbursement" @@ -76,7 +76,19 @@ ], "condition_summary": "差旅申请/报销文本命中未申请、未审批或事后补申请关键词。", "message_template": "识别到差旅未事前申请或事后补申请迹象,请补齐已审批的差旅申请后再提交。", - "template_key": "keyword_match_v1" + "template_key": "keyword_match_v1", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "outcomes": { "pass": { @@ -92,14 +104,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:费用申请审批规则", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 92, "risk_level": "high", "rule_title": "差旅未申请或事后补申请高风险", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "expense_application", "reimbursement" @@ -117,7 +129,17 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "severity": "high", "risk_score": 92, @@ -131,5 +153,15 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.low.application_fields_missing.json b/server/rules/risk-rules/risk.travel.low.application_fields_missing.json index 0cc8e8b..6c436bf 100644 --- a/server/rules/risk-rules/risk.travel.low.application_fields_missing.json +++ b/server/rules/risk-rules/risk.travel.low.application_fields_missing.json @@ -9,8 +9,8 @@ "risk_category": "差旅费-申请信息", "ontology_signal": "travel_application_fields_missing", "evaluator": "template_rule", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "expense_application" ], @@ -80,7 +80,49 @@ ], "condition_summary": "差旅申请缺少事由、地点、起止时间或预计金额。", "message_template": "差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。", - "template_key": "field_required_v1" + "template_key": "field_required_v1", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + }, + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_transport_estimate", + "sheet": "交通费用预估表", + "name": "交通费用预估表", + "component": "transport_estimate" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] }, "outcomes": { "pass": { @@ -96,14 +138,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:费用申请审批规则、差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、交通费用预估表、出差补助标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 42, "risk_level": "low", "rule_title": "差旅申请基础信息不完整低风险", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "expense_application" ], @@ -120,7 +162,47 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + }, + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_transport_estimate", + "sheet": "交通费用预估表", + "name": "交通费用预估表", + "component": "transport_estimate" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] }, "severity": "low", "risk_score": 42, @@ -134,5 +216,45 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + }, + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + }, + { + "code": "rule.expense.company_travel_transport_estimate", + "sheet": "交通费用预估表", + "name": "交通费用预估表", + "component": "transport_estimate" + }, + { + "code": "rule.expense.company_travel_allowance_reimbursement", + "sheet": "出差补助标准", + "name": "出差补助报销标准", + "component": "allowance" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json b/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json index 8a2bf1c..9467858 100644 --- a/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json +++ b/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json @@ -10,7 +10,7 @@ "ontology_signal": "travel_attachment_ocr_missing", "evaluator": "template_rule", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -50,7 +50,31 @@ ], "condition_summary": "差旅附件缺少可读取 OCR 文本。", "message_template": "差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。", - "template_key": "field_required_v1" + "template_key": "field_required_v1", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "差旅住宿费标准", + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "outcomes": { "pass": { @@ -66,14 +90,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 38, "risk_level": "low", "rule_title": "差旅附件无法识别低风险", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -91,7 +115,29 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "severity": "low", "risk_score": 38, @@ -105,5 +151,27 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json b/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json index 5c0fcef..603e415 100644 --- a/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json +++ b/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json @@ -9,8 +9,8 @@ "risk_category": "差旅费-市内交通", "ontology_signal": "travel_local_transport_detail_missing", "evaluator": "template_rule", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_code": "rule.expense.company_travel_transport_class", + "finance_rule_sheet": "交通工具等级标准", "business_stage": [ "expense_application", "reimbursement" @@ -102,7 +102,19 @@ ] }, "condition_summary": "存在市内交通关键词,但文本中缺少起点、终点或路线说明。", - "message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。" + "message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。", + "finance_rule_code": "rule.expense.company_travel_transport_class", + "finance_rule_sheet": "交通工具等级标准", + "basic_rule_code": "rule.expense.company_travel_transport_class", + "basic_rule_sheet": "交通工具等级标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "outcomes": { "pass": { @@ -118,14 +130,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:交通工具等级标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 36, "risk_level": "low", "rule_title": "市内交通路线说明不足低风险", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_code": "rule.expense.company_travel_transport_class", + "finance_rule_sheet": "交通工具等级标准", "business_stage": [ "expense_application", "reimbursement" @@ -143,7 +155,17 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_transport_class", + "basic_rule_sheet": "交通工具等级标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "severity": "low", "risk_score": 36, @@ -157,5 +179,15 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_transport_class", + "basic_rule_sheet": "交通工具等级标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json b/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json index 9618ab0..0cf78b7 100644 --- a/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json +++ b/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json @@ -10,7 +10,7 @@ "ontology_signal": "travel_vague_ticket_content", "evaluator": "vague_goods_description", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -49,7 +49,31 @@ }, "params": { "condition_summary": "票据未识别为明确的酒店、交通等差旅票据,且商品或服务名称过于笼统,无法直接对应差旅事项。", - "message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。" + "message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "差旅住宿费标准", + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "outcomes": { "pass": { @@ -65,14 +89,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 34, "risk_level": "low", "rule_title": "差旅票据服务内容笼统低风险", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -90,7 +114,29 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "severity": "low", "risk_score": 34, @@ -103,5 +149,27 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json b/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json index 669cec4..dc6e0cb 100644 --- a/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json +++ b/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json @@ -10,7 +10,7 @@ "ontology_signal": "travel_duplicate_ticket", "evaluator": "duplicate_invoice", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -49,7 +49,31 @@ }, "params": { "condition_summary": "票据号码在当前单据或历史报销中重复出现。", - "message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。" + "message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "差旅住宿费标准", + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "outcomes": { "pass": { @@ -65,14 +89,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 75, "risk_level": "medium", "rule_title": "差旅票据重复中风险", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -90,7 +114,29 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "severity": "medium", "risk_score": 75, @@ -103,5 +149,27 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json b/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json index f4d986a..1da4158 100644 --- a/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json +++ b/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json @@ -10,7 +10,7 @@ "ontology_signal": "travel_multi_city_without_reason", "evaluator": "multi_city_reason_required", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -67,7 +67,31 @@ }, "params": { "condition_summary": "差旅行程涉及 3 个及以上城市,且事由未包含中转、多地、改签、绕行等说明。", - "message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。" + "message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "差旅住宿费标准", + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "outcomes": { "pass": { @@ -83,14 +107,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 72, "risk_level": "medium", "rule_title": "多城市行程缺少说明中风险", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -108,7 +132,29 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "severity": "medium", "risk_score": 72, @@ -121,5 +167,27 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json b/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json index 55a61c6..3dcc2e4 100644 --- a/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json +++ b/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json @@ -9,8 +9,8 @@ "risk_category": "差旅费-事由完整性", "ontology_signal": "travel_reason_too_brief", "evaluator": "reason_too_brief", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "expense_application", "reimbursement" @@ -50,7 +50,19 @@ "params": { "min_reason_length": 10, "condition_summary": "合并申请/报销事由后有效字符少于 10 个。", - "message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。" + "message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "outcomes": { "pass": { @@ -66,14 +78,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:费用申请审批规则", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 68, "risk_level": "medium", "rule_title": "差旅事由过短中风险", - "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", "business_stage": [ "expense_application", "reimbursement" @@ -91,7 +103,17 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] }, "severity": "medium", "risk_score": 68, @@ -104,5 +126,15 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "expense.preapproval.policy", + "basic_rule_sheet": "费用申请审批规则", + "basic_rule_refs": [ + { + "code": "expense.preapproval.policy", + "sheet": "费用申请审批规则", + "name": "公司费用申请审批规则", + "component": "preapproval" + } + ] } diff --git a/server/rules/risk-rules/risk.travel.medium.title_mismatch.json b/server/rules/risk-rules/risk.travel.medium.title_mismatch.json index 5461f0a..714e91f 100644 --- a/server/rules/risk-rules/risk.travel.medium.title_mismatch.json +++ b/server/rules/risk-rules/risk.travel.medium.title_mismatch.json @@ -10,7 +10,7 @@ "ontology_signal": "travel_invoice_title_mismatch", "evaluator": "identity_consistency", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -60,7 +60,31 @@ "远光软件" ], "condition_summary": "票据抬头/购买方不包含报销人姓名,也不包含公司抬头关键词。", - "message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。" + "message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "差旅住宿费标准", + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "outcomes": { "pass": { @@ -76,14 +100,14 @@ "metadata": { "owner": "admin", "stability": "admin_configured", - "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准", "created_at": "2026-05-26T07:06:27.746703+00:00", "created_by": "admin", "risk_score": 64, "risk_level": "medium", "rule_title": "差旅票据抬头不一致中风险", "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", - "finance_rule_sheet": "公司差旅费报销规则", + "finance_rule_sheet": "差旅住宿费标准", "business_stage": [ "expense_application", "reimbursement" @@ -101,7 +125,29 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] }, "severity": "medium", "risk_score": 64, @@ -114,5 +160,27 @@ "model": "risk_score_v3", "source": "admin_manual_travel_risk_catalog", "reason": "按差旅费报销高/中/低风险分层手工设定。" - } + }, + "basic_rule_code": "rule.expense.company_travel_expense_reimbursement", + "basic_rule_sheet": "差旅住宿费标准", + "basic_rule_refs": [ + { + "code": "rule.expense.company_travel_expense_reimbursement", + "sheet": "差旅住宿费标准", + "name": "差旅住宿报销标准", + "component": "lodging" + }, + { + "code": "rule.expense.company_travel_season_mapping", + "sheet": "地区淡旺季映射表", + "name": "地区淡旺季映射表", + "component": "season_mapping" + }, + { + "code": "rule.expense.company_travel_transport_class", + "sheet": "交通工具等级标准", + "name": "交通工具等级标准", + "component": "transport" + } + ] } diff --git a/server/src/app/api/v1/endpoints/steward.py b/server/src/app/api/v1/endpoints/steward.py index 6d283d1..74acb4b 100644 --- a/server/src/app/api/v1/endpoints/steward.py +++ b/server/src/app/api/v1/endpoints/steward.py @@ -20,7 +20,9 @@ from app.schemas.steward import ( StewardSlotDecisionResponse, StewardThinkingEvent, ) +from app.services.agent_conversations import AgentConversationService 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_planner import StewardPlannerService 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: 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: 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, db: DbSession, ) -> 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( @@ -82,7 +87,7 @@ def create_steward_runtime_decision( ) async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> 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", ) @@ -90,6 +95,7 @@ async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> Str async def _iter_steward_plan_events( payload: StewardPlanRequest, planner: StewardPlannerService, + db: Session, ) -> AsyncIterator[str]: yield _encode_stream_event( "thinking", @@ -105,6 +111,7 @@ async def _iter_steward_plan_events( try: plan = planner.build_plan(payload) + plan = _attach_conversation_state(db, payload, plan) except ValueError as exc: yield _encode_stream_event("error", {"message": str(exc)}) return @@ -124,3 +131,131 @@ def _build_steward_planner(db: Session) -> StewardPlannerService: return StewardPlannerService( 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 {} diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index 39211d5..a87889c 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -198,6 +198,9 @@ class TravelReimbursementCalculatorRequest(BaseModel): days: int = Field(ge=1, le=365) location: str = Field(min_length=1, max_length=120) grade: str | None = Field(default=None, max_length=30) + transport_mode: str | None = Field(default=None, max_length=30) + origin_location: str | None = Field(default=None, max_length=120) + travel_date: date | None = None class TravelReimbursementCalculatorResponse(BaseModel): @@ -215,6 +218,17 @@ class TravelReimbursementCalculatorResponse(BaseModel): basic_allowance_rate: Decimal total_allowance_rate: Decimal allowance_amount: Decimal + transport_mode: str = "" + transport_origin: str = "" + transport_destination: str = "" + transport_estimated_amount: Decimal = Decimal("0.00") + transport_estimate_basis: str = "" + transport_estimate_confidence: str = "" + transport_estimate_source: str = "" + transport_estimate_rule_code: str = "" + transport_estimate_rule_name: str = "" + transport_estimate_rule_version: str = "" + travel_date: date | None = None total_amount: Decimal rule_name: str rule_version: str diff --git a/server/src/app/schemas/steward.py b/server/src/app/schemas/steward.py index 1efb83c..9ebd886 100644 --- a/server/src/app/schemas/steward.py +++ b/server/src/app/schemas/steward.py @@ -8,11 +8,13 @@ from pydantic import BaseModel, Field StewardTaskType = Literal["expense_application", "reimbursement"] StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"] StewardPlanningSource = Literal["llm_function_call", "rule_fallback"] +StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"] StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"] StewardSlotNextAction = Literal["ask_user", "render_preview"] StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"] StewardRuntimeNextAction = Literal[ "plan_new_tasks", + "continue_selected_flow", "submit_current_application", "continue_next_task", "fill_current_slot", @@ -29,6 +31,8 @@ StewardTaskStatus = Literal[ "blocked", ] StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"] +StewardFlowId = Literal["travel_application", "travel_reimbursement"] +StewardPendingFlowStatus = Literal["none", "pending", "confirmed", "rejected"] class StewardAttachmentInput(BaseModel): @@ -90,15 +94,39 @@ class StewardConfirmationAction(BaseModel): payload: dict[str, Any] = Field(default_factory=dict, description="确认后继续执行所需载荷。") +class StewardCandidateFlow(BaseModel): + flow_id: StewardFlowId = Field(description="候选业务流程。") + label: str = Field(description="用户可见候选流程名称。") + confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="候选流程置信度。") + reason: str = Field(default="", description="候选流程依据。") + ontology_fields: dict[str, str] = Field(default_factory=dict, description="候选流程可继承的 canonical ontology 字段。") + missing_fields: list[str] = Field(default_factory=list, description="候选流程仍缺失的 canonical ontology 字段。") + + +class StewardPendingFlowConfirmation(BaseModel): + status: StewardPendingFlowStatus = Field(default="none", description="候选流程确认状态。") + source_message: str = Field(default="", description="触发候选流程确认的用户原始输入。") + reason: str = Field(default="", description="需要确认流程方向的原因。") + candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="候选业务流程。") + + class StewardPlanResponse(BaseModel): plan_id: str = Field(description="小财管家计划 ID。") plan_status: str = Field(default="needs_confirmation", description="计划状态。") planning_source: StewardPlanningSource = Field(default="rule_fallback", description="计划生成来源。") + next_action: StewardPlanNextAction = Field(default="confirm_task", description="计划完成后的下一步动作。") + conversation_id: str = Field(default="", description="持久化会话 ID。") + steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家跨轮业务状态。") summary: str = Field(description="计划摘要。") thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。") tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。") attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。") confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。") + pending_flow_confirmation: StewardPendingFlowConfirmation = Field( + default_factory=StewardPendingFlowConfirmation, + description="申请/报销流程不明确时等待用户确认的候选流程。", + ) + candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="等待用户确认的候选流程快捷列表。") model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。") @@ -146,4 +174,18 @@ class StewardRuntimeDecisionResponse(BaseModel): question: str = Field(default="", description="需要追问用户时展示的问题。") response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。") rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。") + steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家更新后的跨轮业务状态。") model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。") + + +class StewardFlowStatePatch(BaseModel): + active_flow: StewardFlowId = Field(description="本轮对话正在推进的业务流程。") + flow_id: StewardFlowId = Field(description="需要合并字段的目标业务流程。") + intent: str = Field(default="", description="本轮识别出的业务意图。") + status: str = Field(default="collecting", description="流程状态。") + fields: dict[str, Any] = Field(default_factory=dict, description="待写入流程的本体字段 patch。") + missing_fields: list[str] = Field(default_factory=list, description="仍缺失的 canonical ontology 字段。") + application_claim_id: str = Field(default="", description="出差申请流程已生成的申请单 ID。") + linked_application_claim_id: str = Field(default="", description="报销流程关联的申请单 ID。") + attachments: list[dict[str, Any]] = Field(default_factory=list, description="流程关联附件摘要。") + evidence: list[dict[str, Any]] = Field(default_factory=list, description="字段来源证据。") diff --git a/server/src/app/services/agent_asset_finance_spreadsheets.py b/server/src/app/services/agent_asset_finance_spreadsheets.py new file mode 100644 index 0000000..0adad2e --- /dev/null +++ b/server/src/app/services/agent_asset_finance_spreadsheets.py @@ -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], + ) diff --git a/server/src/app/services/agent_asset_spreadsheet.py b/server/src/app/services/agent_asset_spreadsheet.py index be8fc2b..a460036 100644 --- a/server/src/app/services/agent_asset_spreadsheet.py +++ b/server/src/app/services/agent_asset_spreadsheet.py @@ -14,6 +14,16 @@ from zipfile import ZIP_DEFLATED, ZipFile from openpyxl import load_workbook from app.core.config import SERVER_DIR, get_settings +from app.services.agent_asset_finance_spreadsheets import build_communication_expense_workbook +from app.services.agent_asset_travel_spreadsheets import ( + build_travel_allowance_workbook, + build_travel_grade_mapping_workbook, + build_travel_lodging_workbook_from_source, + build_travel_season_mapping_workbook, + build_travel_transport_class_workbook, + build_travel_transport_estimate_workbook, + build_xlsx_bytes_from_source_sheet, +) RULE_SPREADSHEET_BLOCK_PATTERN = re.compile( r"```rule-spreadsheet\s*(\{.*?\})\s*```", @@ -21,11 +31,29 @@ RULE_SPREADSHEET_BLOCK_PATTERN = re.compile( ) COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement" -COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx" +COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "差旅住宿费标准.xlsx" +COMPANY_TRAVEL_SOURCE_RULE_FILENAME = "公司差旅费报销规则.xlsx" +COMPANY_TRAVEL_ALLOWANCE_RULE_CODE = "rule.expense.company_travel_allowance_reimbursement" +COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME = "出差补助标准.xlsx" +COMPANY_TRAVEL_TRANSPORT_RULE_CODE = "rule.expense.company_travel_transport_class" +COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME = "交通工具等级标准.xlsx" +COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE = "rule.expense.company_travel_transport_estimate" +COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME = "交通费用预估表.xlsx" +COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE = "rule.expense.company_travel_grade_mapping" +COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME = "差旅职级映射表.xlsx" +COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE = "rule.expense.company_travel_season_mapping" +COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME = "地区淡旺季映射表.xlsx" COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement" COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx" COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement" COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx" +TRAVEL_SPREADSHEET_RULE_CODES = { + COMPANY_TRAVEL_EXPENSE_RULE_CODE, + COMPANY_TRAVEL_ALLOWANCE_RULE_CODE, + COMPANY_TRAVEL_TRANSPORT_RULE_CODE, + COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE, + COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE, +} FINANCE_RULES_LIBRARY = "finance-rules" RISK_RULES_LIBRARY = "risk-rules" RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY} @@ -284,65 +312,79 @@ class AgentAssetSpreadsheetManager: @staticmethod def build_company_travel_rule_template() -> bytes: - standard_rows = [ - ["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"], - [ - "长途交通", - "飞机、高铁、火车等跨城出行", - "行程单、车票、发票", - "据实报销", - "超预算需直属领导审批", - "优先选择公共交通", - ], - [ - "住宿费", - "出差住宿", - "酒店发票、入住清单", - "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", - "超标需总监审批", - "协议酒店优先", - ], - [ - "市内交通", - "出租车、网约车、地铁、公交", - "发票或电子行程单", - "150/天", - "超限需补充说明", - "夜间或无公共交通场景可豁免", - ], - [ - "餐补", - "出差期间日常补助", - "无需票据", - "120/天", - "系统自动核定", - "当天往返默认不享受", - ], - [ - "招待餐费", - "客户接待或项目宴请", - "餐饮发票、参与人清单", - "300/人", - "需业务负责人审批", - "需关联客户或项目", - ], + return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template() + + @staticmethod + def build_travel_lodging_rule_template() -> bytes: + lodging_rows = [ + ["地区(城市)", "城市级别", "P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "备注"], + ["北京", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"], + ["上海", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"], + ["广州", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "广交会期间可按例外流程说明"], + ["深圳", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "旺季需补充超标说明"], + ["杭州", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""], + ["南京", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""], + ["成都", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""], + ["武汉", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""], + ["其他地区", "其他地区", 320, 320, 320, 320, 380, 380, 380, 450, 450, "未单列城市按其他地区执行"], ] - instruction_rows = [ - ["字段", "填写说明"], - ["费用分类", "建议保持固定选项,避免审批口径漂移。"], - ["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"], - ["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"], - ["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"], - ["审批要求", "超标、例外、补录等情形应写清升级审批链。"], - ["备注", "记录豁免条件、灰度口径或制度来源。"], - ["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"], - ] - return _build_xlsx_bytes( - [ - ("差旅报销标准", standard_rows), - ("填表说明", instruction_rows), - ] + source_path = ( + SERVER_DIR + / "rules" + / FINANCE_RULES_LIBRARY + / COMPANY_TRAVEL_SOURCE_RULE_FILENAME ) + return build_travel_lodging_workbook_from_source(source_path, lodging_rows) + + @staticmethod + def build_travel_allowance_rule_template() -> bytes: + return build_travel_allowance_workbook() + + @staticmethod + def build_travel_transport_rule_template() -> bytes: + return build_travel_transport_class_workbook() + + @staticmethod + def build_travel_grade_mapping_template() -> bytes: + return build_travel_grade_mapping_workbook() + + @staticmethod + def build_travel_season_mapping_template() -> bytes: + source_path = ( + SERVER_DIR + / "rules" + / FINANCE_RULES_LIBRARY + / COMPANY_TRAVEL_SOURCE_RULE_FILENAME + ) + return build_travel_season_mapping_workbook(source_path) + + @staticmethod + def build_travel_transport_estimate_rule_template() -> bytes: + return build_travel_transport_estimate_workbook() + + @staticmethod + def build_company_communication_rule_template() -> bytes: + return build_communication_expense_workbook() + + @staticmethod + def _build_travel_source_sheet( + sheet_name: str, + *, + fallback_rows: list[list[object]], + ) -> bytes: + source_path = ( + SERVER_DIR + / "rules" + / FINANCE_RULES_LIBRARY + / COMPANY_TRAVEL_SOURCE_RULE_FILENAME + ) + if source_path.exists(): + try: + return build_xlsx_bytes_from_source_sheet(source_path, sheet_name) + except (OSError, ValueError): + pass + + return _build_xlsx_bytes([(sheet_name, fallback_rows)]) @staticmethod def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes: @@ -350,7 +392,17 @@ class AgentAssetSpreadsheetManager: @staticmethod def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes: - return _build_xlsx_bytes([(sheet_name, [[""]])]) + return _build_xlsx_bytes( + [ + ( + sheet_name, + [ + ["规则项", "适用条件", "标准/阈值", "所需材料", "审批要求", "备注"], + ["", "", "", "", "", ""], + ], + ) + ] + ) @staticmethod def rebuild_from_uploaded_content(content: bytes) -> bytes: @@ -360,23 +412,20 @@ class AgentAssetSpreadsheetManager: try: workbook = load_workbook( filename=BytesIO(content), - read_only=True, + read_only=False, data_only=False, ) except Exception as exc: # noqa: BLE001 raise ValueError("无法解析上传的 Excel 表格。") from exc - sheets: list[tuple[str, list[list[object]]]] = [] - for worksheet in workbook.worksheets: - rows = [ - list(row) - for row in worksheet.iter_rows(values_only=True) - ] - sheets.append((worksheet.title, _trim_empty_table(rows))) - - if not sheets: - raise ValueError("上传的 Excel 表格中没有可导入的工作表。") - return _build_xlsx_bytes(sheets) + try: + if not workbook.worksheets: + raise ValueError("上传的 Excel 表格中没有可导入的工作表。") + rebuilt_buffer = BytesIO() + workbook.save(rebuilt_buffer) + return rebuilt_buffer.getvalue() + finally: + workbook.close() def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes: @@ -544,7 +593,7 @@ def _build_styles_xml() -> str: return ( '' '' - '' + '' '' '' '' @@ -562,6 +611,14 @@ def _build_styles_xml() -> str: def _build_sheet_xml(rows: list[list[object]]) -> str: normalized_rows = rows or [[""]] max_column_count = max((len(row) for row in normalized_rows), default=1) + column_widths = _build_sheet_column_widths(normalized_rows, max_column_count) + column_xml = "".join( + ( + f'' + ) + for index, width in enumerate(column_widths, start=1) + ) worksheet_rows: list[str] = [] 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( f'{escape(text)}' ) - worksheet_rows.append(f'{"".join(cells)}') + worksheet_rows.append( + f'{"".join(cells)}' + ) dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}" return ( '' '' f'' - "" - "" + '' + "" + f"{column_xml}" f"{''.join(worksheet_rows)}" "" ) @@ -596,6 +656,31 @@ def _column_letter(index: int) -> str: return result +def _build_sheet_column_widths( + rows: list[list[object]], + max_column_count: int, +) -> list[str]: + widths: list[str] = [] + for column_index in range(max_column_count): + max_text_width = 0.0 + for row in rows[:120]: + value = row[column_index] if column_index < len(row) else "" + text = "" if value is None else str(value) + if not text: + continue + max_text_width = max(max_text_width, _estimate_display_width(text)) + width = min(max(max_text_width + 4, 16), 42) + widths.append(f"{width:.1f}") + return widths + + +def _estimate_display_width(text: str) -> float: + width = 0.0 + for char in text: + width += 2.0 if ord(char) > 127 else 1.0 + return width + + def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]: normalized_rows = [list(row) for row in rows] while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]): diff --git a/server/src/app/services/agent_asset_spreadsheet_helpers.py b/server/src/app/services/agent_asset_spreadsheet_helpers.py index e789689..7ea631b 100644 --- a/server/src/app/services/agent_asset_spreadsheet_helpers.py +++ b/server/src/app/services/agent_asset_spreadsheet_helpers.py @@ -13,8 +13,18 @@ from app.schemas.agent_asset import ( from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_TRAVEL_ALLOWANCE_RULE_CODE, + COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE, + COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME, + COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE, + COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME, + COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE, + COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME, + COMPANY_TRAVEL_TRANSPORT_RULE_CODE, + COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME, FINANCE_RULES_LIBRARY, RULE_LIBRARY_NAMES, SPREADSHEET_MIME_TYPE, @@ -133,7 +143,7 @@ class AgentAssetSpreadsheetHelperMixin: } if config_json.get("rule_document") != expected_document: config_json["detail_mode"] = "spreadsheet" - config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则" + config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则" config_json["rule_library"] = library config_json["rule_document"] = expected_document asset.config_json = config_json @@ -160,7 +170,7 @@ class AgentAssetSpreadsheetHelperMixin: ) config_json = dict(asset.config_json or {}) config_json["detail_mode"] = "spreadsheet" - config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则" + config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则" config_json["rule_library"] = library config_json["rule_document"] = { **self.spreadsheet_manager.build_rule_document_config( @@ -187,6 +197,16 @@ class AgentAssetSpreadsheetHelperMixin: return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE: return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME + if asset.code == COMPANY_TRAVEL_ALLOWANCE_RULE_CODE: + return COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME + if asset.code == COMPANY_TRAVEL_TRANSPORT_RULE_CODE: + return COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME + if asset.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE: + return COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME + if asset.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE: + return COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME + if asset.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE: + return COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME fallback = Path(str(asset.name or "规则表").strip()).name return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx" diff --git a/server/src/app/services/agent_asset_travel_spreadsheets.py b/server/src/app/services/agent_asset_travel_spreadsheets.py new file mode 100644 index 0000000..75f8c08 --- /dev/null +++ b/server/src/app/services/agent_asset_travel_spreadsheets.py @@ -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 diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index 0e8ab50..ab94a02 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -74,7 +74,7 @@ class AgentAssetService( ) -> list[AgentAssetListItem]: self._ensure_ready() if asset_type in {None, "", AgentAssetType.RULE.value}: - self.sync_platform_risk_rules_from_library() + self.sync_rule_assets_from_libraries() assets = self.repository.list( asset_type=asset_type, status=status, domain=domain, keyword=keyword ) @@ -94,7 +94,7 @@ class AgentAssetService( ) -> PageResult[AgentAssetListItem]: self._ensure_ready() if asset_type in {None, "", AgentAssetType.RULE.value}: - self.sync_platform_risk_rules_from_library() + self.sync_rule_assets_from_libraries() assets = self.repository.list( asset_type=asset_type, status=status, @@ -552,6 +552,13 @@ class AgentAssetService( self.db.commit() 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( self, asset: AgentAsset, payload: AgentAssetVersionCreate ) -> None: diff --git a/server/src/app/services/agent_conversations.py b/server/src/app/services/agent_conversations.py index 919ddb2..e115e00 100644 --- a/server/src/app/services/agent_conversations.py +++ b/server/src/app/services/agent_conversations.py @@ -19,6 +19,7 @@ STATEFUL_CONTEXT_KEYS = ( "ocr_summary", "ocr_documents", "review_form_values", + "steward_state", "business_time_context", ) REVIEW_FLOW_CONTEXT_KEYS = { diff --git a/server/src/app/services/agent_foundation_asset_seed.py b/server/src/app/services/agent_foundation_asset_seed.py index 76b0304..494c11c 100644 --- a/server/src/app/services/agent_foundation_asset_seed.py +++ b/server/src/app/services/agent_foundation_asset_seed.py @@ -270,7 +270,7 @@ class AgentFoundationAssetSeedMixin: config_json={ "severity": "medium", "enabled": True, - "tag": "财务规则", + "tag": "基础规则", "detail_mode": "spreadsheet", "rule_library": FINANCE_RULES_LIBRARY, "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], @@ -296,7 +296,7 @@ class AgentFoundationAssetSeedMixin: config_json={ "severity": "medium", "enabled": True, - "tag": "财务规则", + "tag": "基础规则", "detail_mode": "spreadsheet", "rule_library": FINANCE_RULES_LIBRARY, "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], @@ -320,7 +320,7 @@ class AgentFoundationAssetSeedMixin: config_json={ "severity": "high", "enabled": True, - "tag": "财务规则", + "tag": "申请规则", "detail_mode": "spreadsheet", "rule_library": FINANCE_RULES_LIBRARY, "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], @@ -729,7 +729,7 @@ class AgentFoundationAssetSeedMixin: version=COMPANY_TRAVEL_RULE_VERSION, reviewer="顾承宇", review_status=AgentReviewStatus.APPROVED.value, - review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + review_note="首版 Excel 规则表已确认,可作为基础规则使用。", reviewed_at=datetime.now(UTC), ), AgentAssetReview( @@ -737,7 +737,7 @@ class AgentFoundationAssetSeedMixin: version=COMPANY_COMMUNICATION_RULE_VERSION, reviewer="顾承宇", review_status=AgentReviewStatus.APPROVED.value, - review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + review_note="首版 Excel 规则表已确认,可作为基础规则使用。", reviewed_at=datetime.now(UTC), ), ] diff --git a/server/src/app/services/agent_foundation_asset_topup.py b/server/src/app/services/agent_foundation_asset_topup.py index 15f1a10..1222319 100644 --- a/server/src/app/services/agent_foundation_asset_topup.py +++ b/server/src/app/services/agent_foundation_asset_topup.py @@ -368,7 +368,7 @@ class AgentFoundationAssetTopUpMixin: config_json={ "severity": "medium", "enabled": True, - "tag": "财务规则", + "tag": "基础规则", "detail_mode": "spreadsheet", "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], "ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], @@ -391,7 +391,7 @@ class AgentFoundationAssetTopUpMixin: config_json={ "severity": "medium", "enabled": True, - "tag": "财务规则", + "tag": "基础规则", "detail_mode": "spreadsheet", "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], "ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], @@ -415,7 +415,7 @@ class AgentFoundationAssetTopUpMixin: config_json={ "severity": "high", "enabled": True, - "tag": "财务规则", + "tag": "申请规则", "detail_mode": "spreadsheet", "rule_library": FINANCE_RULES_LIBRARY, "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], @@ -453,7 +453,7 @@ class AgentFoundationAssetTopUpMixin: **(company_travel_rule.config_json or {}), "severity": "medium", "enabled": True, - "tag": "财务规则", + "tag": "基础规则", "detail_mode": "spreadsheet", "rule_library": FINANCE_RULES_LIBRARY, "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], @@ -489,7 +489,7 @@ class AgentFoundationAssetTopUpMixin: version=COMPANY_TRAVEL_RULE_VERSION, reviewer="顾承宇", review_status=AgentReviewStatus.APPROVED.value, - review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + review_note="首版 Excel 规则表已确认,可作为基础规则使用。", reviewed_at=datetime.now(UTC), ) @@ -523,7 +523,7 @@ class AgentFoundationAssetTopUpMixin: **(company_communication_rule.config_json or {}), "severity": "medium", "enabled": True, - "tag": "财务规则", + "tag": "基础规则", "detail_mode": "spreadsheet", "rule_library": FINANCE_RULES_LIBRARY, "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], @@ -569,7 +569,7 @@ class AgentFoundationAssetTopUpMixin: version=COMPANY_COMMUNICATION_RULE_VERSION, reviewer="顾承宇", review_status=AgentReviewStatus.APPROVED.value, - review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + review_note="首版 Excel 规则表已确认,可作为基础规则使用。", reviewed_at=datetime.now(UTC), ) @@ -591,7 +591,7 @@ class AgentFoundationAssetTopUpMixin: **(company_preapproval_rule.config_json or {}), "severity": "high", "enabled": True, - "tag": "财务规则", + "tag": "申请规则", "detail_mode": "spreadsheet", "rule_library": FINANCE_RULES_LIBRARY, "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], @@ -640,7 +640,7 @@ class AgentFoundationAssetTopUpMixin: version=COMPANY_PREAPPROVAL_RULE_VERSION, reviewer="顾承宣", review_status=AgentReviewStatus.APPROVED.value, - review_note="首版费用申请审批规则表已确认,可作为财务规则使用。", + review_note="首版费用申请审批规则表已确认,可作为申请规则使用。", reviewed_at=datetime.now(UTC), ) diff --git a/server/src/app/services/agent_foundation_preapproval_spreadsheet.py b/server/src/app/services/agent_foundation_preapproval_spreadsheet.py new file mode 100644 index 0000000..4cc91ee --- /dev/null +++ b/server/src/app/services/agent_foundation_preapproval_spreadsheet.py @@ -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 进行数值比较"], + ["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"], + ["审批要求", "说明申请单进入审批链后的最低审批要求"], + ["风险动作", "说明报销阶段未满足规则时的系统处理"], + ], + ), + ] diff --git a/server/src/app/services/agent_foundation_spreadsheets.py b/server/src/app/services/agent_foundation_spreadsheets.py index 63c193f..95a4c7a 100644 --- a/server/src/app/services/agent_foundation_spreadsheets.py +++ b/server/src/app/services/agent_foundation_spreadsheets.py @@ -5,6 +5,10 @@ from pathlib import Path from sqlalchemy import select from app.core.agent_enums import ( + AgentAssetContentType, + AgentAssetDomain, + AgentAssetType, + AgentReviewStatus, AgentAssetStatus, ) from app.core.logging import get_logger @@ -12,22 +16,38 @@ from app.models.agent_asset import AgentAsset from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_TRAVEL_ALLOWANCE_RULE_CODE, + COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME, COMPANY_PREAPPROVAL_RULE_CODE, COMPANY_PREAPPROVAL_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE, + COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME, + COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE, + COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME, + COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE, + COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME, + COMPANY_TRAVEL_TRANSPORT_RULE_CODE, + COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME, FINANCE_RULES_LIBRARY, AgentAssetSpreadsheetManager, ) from app.services.agent_foundation_constants import ( COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, + COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON, + COMPANY_PREAPPROVAL_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, + COMPANY_TRAVEL_RULE_VERSION, ) from app.services.finance_rule_catalog import ( DEPRECATED_FINANCE_RULE_CODES, DEPRECATED_FINANCE_RULE_REPLACEMENTS, ) +from app.services.agent_foundation_preapproval_spreadsheet import ( + build_preapproval_rule_workbook_sheets, +) logger = get_logger("app.services.agent_foundation") @@ -44,25 +64,131 @@ class AgentFoundationSpreadsheetMixin: synced_count += int( self._ensure_core_finance_rule_asset( code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + name="差旅住宿报销标准", + description="按地区和职级维护差旅住宿费报销上限。", scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], finance_rule_sheet="差旅住宿费标准", - expense_types=["travel", "hotel", "transport"], + expense_types=["hotel"], + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + workbook_content=AgentAssetSpreadsheetManager.build_travel_lodging_rule_template(), + rule_template_label="差旅住宿 Excel 模板", + travel_policy_component="lodging", + ) + ) + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_TRAVEL_ALLOWANCE_RULE_CODE, + name="出差补助报销标准", + description="按地区维护伙食补助、基本出差补贴和补助合计。", + scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + finance_rule_sheet="出差补助标准", + expense_types=["travel"], + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + file_name=COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME, + workbook_content=AgentAssetSpreadsheetManager.build_travel_allowance_rule_template(), + rule_template_label="出差补助 Excel 模板", + travel_policy_component="allowance", + ) + ) + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_TRAVEL_TRANSPORT_RULE_CODE, + name="交通工具等级标准", + description="按员工职级维护飞机、火车等长途交通工具等级。", + scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + finance_rule_sheet="交通工具等级标准", + expense_types=["travel", "transport"], + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + file_name=COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME, + workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_rule_template(), + rule_template_label="交通工具等级 Excel 模板", + travel_policy_component="transport", + ) + ) + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE, + name="交通费用预估表", + description="按出发城市、目的地和交通方式维护申请阶段预算占用的交通费用预估金额。", + scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + finance_rule_sheet="交通费用预估表", + expense_types=["travel", "transport"], + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + file_name=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME, + workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_estimate_rule_template(), + rule_template_label="交通费用预估 Excel 模板", + travel_policy_component="transport_estimate", + ) + ) + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE, + name="差旅职级映射表", + description="明确 P0-P8 九级职级与住宿、交通规则列之间的对应关系,其中 P8 为董事会。", + scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + finance_rule_sheet="差旅职级映射表", + expense_types=["hotel", "travel", "transport"], + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + file_name=COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME, + workbook_content=AgentAssetSpreadsheetManager.build_travel_grade_mapping_template(), + rule_template_label="差旅职级映射 Excel 模板", + travel_policy_component="grade_mapping", + ) + ) + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE, + name="地区淡旺季映射表", + description="明确住宿标准中旺季地区、旺季月份和旺季超标限额的对应关系。", + scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + finance_rule_sheet="地区淡旺季映射表", + expense_types=["hotel", "travel"], + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + file_name=COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME, + workbook_content=AgentAssetSpreadsheetManager.build_travel_season_mapping_template(), + rule_template_label="地区淡旺季映射 Excel 模板", + travel_policy_component="season_mapping", ) ) synced_count += int( self._ensure_core_finance_rule_asset( code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + name="公司通信费报销规则", + description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], finance_rule_sheet="通信费报销标准", expense_types=["communication"], + version=COMPANY_COMMUNICATION_RULE_VERSION, + reviewer="顾承宇", + file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + workbook_content=AgentAssetSpreadsheetManager.build_company_communication_rule_template(), + rule_template_label="通信费报销 Excel 模板", + finance_rule_code="expense.communication.policy", + refresh_workbook_content=True, ) ) synced_count += int( self._ensure_core_finance_rule_asset( code=COMPANY_PREAPPROVAL_RULE_CODE, + name="公司费用申请审批规则", + description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。", scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], finance_rule_sheet="费用申请审批规则", expense_types=["meal", "entertainment", "office", "all"], + version=COMPANY_PREAPPROVAL_RULE_VERSION, + reviewer="顾承宣", + file_name=COMPANY_PREAPPROVAL_RULE_FILENAME, + workbook_content=None, + rule_template_label="费用申请审批 Excel 模板", + finance_rule_code="expense.preapproval.policy", + tag="申请规则", ) ) return synced_count @@ -71,30 +197,183 @@ class AgentFoundationSpreadsheetMixin: self, *, code: str, + name: str, + description: str, scenario_category: str, finance_rule_sheet: str, expense_types: list[str], + version: str, + reviewer: str, + file_name: str, + workbook_content: bytes | None, + rule_template_label: str, + finance_rule_code: str | None = None, + travel_policy_component: str = "", + tag: str = "基础规则", + refresh_workbook_content: bool = False, ) -> bool: asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code)) + created_asset = asset is None if asset is None: - return False + asset = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code=code, + name=name, + description=description, + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=[scenario_category], + owner="财务制度管理组", + reviewer=reviewer, + status=AgentAssetStatus.ACTIVE.value, + current_version=version, + config_json={ + "severity": "medium", + "enabled": True, + "tag": tag, + "rule_tag": tag, + "tags": [tag], + "rule_tags": [tag], + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": scenario_category, + "ai_review_category": scenario_category, + "finance_rule_code": code, + "finance_rule_sheet": finance_rule_sheet, + "expense_types": expense_types, + "business_stage": ["expense_application", "reimbursement"], + "budget_required": True, + "rule_template_label": rule_template_label, + }, + ) + else: + asset.name = name + asset.description = description + asset.owner = asset.owner or "财务制度管理组" + asset.reviewer = asset.reviewer or reviewer + if not str(asset.current_version or "").strip(): + asset.current_version = version + if not str(asset.working_version or "").strip(): + asset.working_version = asset.current_version + if not str(asset.published_version or "").strip(): + asset.published_version = asset.current_version + if not str(asset.status or "").strip() or asset.status == AgentAssetStatus.DISABLED.value: + asset.status = AgentAssetStatus.ACTIVE.value + asset.scenario_json = [scenario_category] - asset.config_json = { + config_json = { **(asset.config_json or {}), "enabled": True, - "tag": "财务规则", + "tag": tag, + "rule_tag": tag, + "tags": [tag], + "rule_tags": [tag], "detail_mode": "spreadsheet", "rule_library": FINANCE_RULES_LIBRARY, "scenario_category": scenario_category, "ai_review_category": scenario_category, - "finance_rule_code": code, + "finance_rule_code": finance_rule_code or code, "finance_rule_sheet": finance_rule_sheet, "expense_types": expense_types, "business_stage": ["expense_application", "reimbursement"], "budget_required": True, + "rule_template_label": rule_template_label, } + if travel_policy_component: + config_json["travel_policy_component"] = travel_policy_component + asset.config_json = config_json + rule_document = (asset.config_json or {}).get("rule_document") + has_rule_document = isinstance(rule_document, dict) and bool( + str(rule_document.get("storage_key") or "").strip() + ) + if workbook_content is not None and ( + created_asset or not has_rule_document or refresh_workbook_content + ): + self._ensure_finance_rule_asset_document( + asset, + version=version, + reviewer=reviewer, + file_name=file_name, + content=workbook_content, + force_live_document=refresh_workbook_content, + ) return True + def _ensure_finance_rule_asset_document( + self, + asset: AgentAsset, + *, + version: str, + reviewer: str, + file_name: str, + content: bytes, + force_live_document: bool = False, + ) -> None: + manager = AgentAssetSpreadsheetManager() + manager.ensure_rule_library_dirs() + rule_document = (asset.config_json or {}).get("rule_document") + storage_key = ( + str(rule_document.get("storage_key") or "").strip() + if isinstance(rule_document, dict) + else "" + ) + should_seed_file = force_live_document or not storage_key + if storage_key: + try: + current_path = manager.resolve_storage_path(storage_key) + except FileNotFoundError: + current_path = None + should_seed_file = should_seed_file or current_path is None or not current_path.exists() + + if should_seed_file: + metadata = manager.store_rule_library_spreadsheet( + library=FINANCE_RULES_LIBRARY, + file_name=file_name, + content=content, + actor_name="系统初始化", + source="rule-library", + ) + asset.config_json = { + **(asset.config_json or {}), + "rule_document": { + **AgentAssetSpreadsheetManager.build_rule_document_config( + metadata, + asset_version=version, + ), + "storage_key": metadata.storage_key, + }, + } + else: + metadata = manager.store_rule_library_spreadsheet_snapshot( + library=FINANCE_RULES_LIBRARY, + asset_id=asset.id, + version=version, + file_name=file_name, + content=content, + actor_name="系统初始化", + source="rule-library-version", + ) + + self._ensure_asset_version( + asset, + version=version, + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=asset.name, + version=version, + metadata=metadata, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note=f"初始化{asset.name} Excel 规则表。", + created_by="系统初始化", + ) + self._ensure_asset_review( + asset, + version=version, + reviewer=reviewer, + review_status=AgentReviewStatus.APPROVED.value, + review_note="首版 Excel 规则表已确认,可作为基础规则使用。", + reviewed_at=None, + ) + def _hide_deprecated_finance_rule_assets(self) -> None: for code in DEPRECATED_FINANCE_RULE_CODES: asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code)) @@ -105,16 +384,16 @@ class AgentFoundationSpreadsheetMixin: replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code) if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE: deprecated_reason = ( - "交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。" + "交通/住宿细分并入公司差旅费报销规则,不再作为独立基础规则展示。" ) elif replacement == COMPANY_PREAPPROVAL_RULE_CODE: deprecated_reason = ( - "申请审批阈值已并入公司费用申请审批规则,不再作为独立财务规则展示。" + "申请审批阈值已并入公司费用申请审批规则,不再作为独立基础规则展示。" ) else: deprecated_reason = ( "该费用类型没有独立职务金额分档,额度控制转入预算中心," - "不再作为独立财务规则表展示。" + "不再作为独立基础规则表展示。" ) asset.config_json = { **(asset.config_json or {}), @@ -196,7 +475,10 @@ class AgentFoundationSpreadsheetMixin: "detail_mode": "spreadsheet", - "tag": "财务规则", + "tag": "基础规则", + "rule_tag": "基础规则", + "tags": ["基础规则"], + "rule_tags": ["基础规则"], "rule_library": FINANCE_RULES_LIBRARY, @@ -224,7 +506,10 @@ class AgentFoundationSpreadsheetMixin: "detail_mode": "spreadsheet", - "tag": "财务规则", + "tag": "基础规则", + "rule_tag": "基础规则", + "tags": ["基础规则"], + "rule_tags": ["基础规则"], "rule_library": FINANCE_RULES_LIBRARY, @@ -299,65 +584,9 @@ class AgentFoundationSpreadsheetMixin: file_name=COMPANY_PREAPPROVAL_RULE_FILENAME, fallback_sheet_name="费用申请审批规则", + tag="申请规则", - workbook_sheets=[ - ( - "费用申请审批规则", - [ - [ - "费用类型代码", - "费用类型", - "触发条件", - "阈值金额", - "前置要求", - "审批要求", - "风险动作", - "备注", - ], - [ - "meal/entertainment", - "业务招待费", - "单次费用金额大于 500 元", - 500, - "必须先提交费用申请单,并说明客户、参与人和招待事由", - "申请单需按审批链完成审批后方可报销", - "报销阶段未关联已通过申请单时标记高风险", - "适配 meal 与 entertainment 两个本体费用类型", - ], - [ - "office", - "办公用品费", - "单次或批量采购金额大于 2000 元", - 2000, - "必须先提交办公采购或费用申请单", - "申请单需经直属领导审批;如触发预算管控则继续预算复核", - "报销阶段未关联已通过申请单时标记高风险", - "覆盖办公用品、办公耗材、低值易耗品等场景", - ], - [ - "all", - "通用大额费用", - "任意费用金额大于 2000 元", - 2000, - "必须进入费用申请和审批流程", - "至少完成直属领导审批;按预算和财务规则继续流转", - "报销阶段未关联已通过申请单时标记高风险", - "差旅、通信等已有专项规则时可同时适用专项规则", - ], - ], - ), - ( - "字段说明", - [ - ["字段", "说明"], - ["费用类型代码", "使用系统本体费用类型,不新增非本体字段"], - ["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"], - ["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"], - ["审批要求", "说明申请单进入审批链后的最低审批要求"], - ["风险动作", "说明报销阶段未满足规则时的系统处理"], - ], - ), - ], + workbook_sheets=build_preapproval_rule_workbook_sheets(), ) @@ -385,7 +614,7 @@ class AgentFoundationSpreadsheetMixin: return live_path.read_bytes() - return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则") + return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template() def _ensure_finance_rule_spreadsheet_seed( @@ -404,6 +633,7 @@ class AgentFoundationSpreadsheetMixin: fallback_sheet_name: str, workbook_sheets: list[tuple[str, list[list[object]]]] | None = None, + tag: str = "基础规则", ): @@ -473,7 +703,10 @@ class AgentFoundationSpreadsheetMixin: "detail_mode": "spreadsheet", - "tag": "财务规则", + "tag": tag, + "rule_tag": tag, + "tags": [tag], + "rule_tags": [tag], "rule_library": FINANCE_RULES_LIBRARY, @@ -501,7 +734,10 @@ class AgentFoundationSpreadsheetMixin: "detail_mode": "spreadsheet", - "tag": "财务规则", + "tag": tag, + "rule_tag": tag, + "tags": [tag], + "rule_tags": [tag], "rule_library": FINANCE_RULES_LIBRARY, diff --git a/server/src/app/services/expense_claim_platform_risk_flag.py b/server/src/app/services/expense_claim_platform_risk_flag.py index 7f62cec..ef3e6c4 100644 --- a/server/src/app/services/expense_claim_platform_risk_flag.py +++ b/server/src/app/services/expense_claim_platform_risk_flag.py @@ -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( manifest: dict[str, Any], *, @@ -55,6 +82,42 @@ def build_platform_risk_flag( metadata.get("actionability") or manifest.get("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( { @@ -63,8 +126,11 @@ def build_platform_risk_flag( "rule_type": "risk", "rule_code": str(manifest.get("rule_code") or "").strip(), "rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(), - "finance_rule_code": str(manifest.get("finance_rule_code") or "").strip(), - "finance_rule_sheet": str(manifest.get("finance_rule_sheet") or "").strip(), + "basic_rule_code": basic_rule_code, + "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, "action": action, "label": label, diff --git a/server/src/app/services/expense_rule_runtime.py b/server/src/app/services/expense_rule_runtime.py index 441d3fc..0bf950c 100644 --- a/server/src/app/services/expense_rule_runtime.py +++ b/server/src/app/services/expense_rule_runtime.py @@ -14,7 +14,9 @@ from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetT from app.models.agent_asset import AgentAsset, AgentAssetVersion from app.services.agent_asset_spreadsheet import ( COMPANY_TRAVEL_EXPENSE_RULE_CODE, + COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE, AgentAssetSpreadsheetManager, + TRAVEL_SPREADSHEET_RULE_CODES, ) from app.services.expense_rule_runtime_defaults import ( DEFAULT_SCENE_MATRIX_CONFIG, @@ -39,6 +41,14 @@ from app.services.expense_rule_runtime_standards import ( build_scene_submission_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: def __init__(self, db: Session) -> None: @@ -59,16 +69,18 @@ class ExpenseRuleRuntimeService: assets = [] asset_ids = {asset.id for asset in assets} - travel_spreadsheet_asset = self.db.scalar( - select(AgentAsset) - .where(AgentAsset.asset_type == AgentAssetType.RULE.value) - .where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value) - .where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) - .order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc()) - .limit(1) + travel_spreadsheet_assets = list( + self.db.scalars( + select(AgentAsset) + .where(AgentAsset.asset_type == AgentAssetType.RULE.value) + .where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value) + .where(AgentAsset.code.in_(TRAVEL_SPREADSHEET_RULE_CODES)) + .order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc()) + ).all() ) - if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids: - assets.append(travel_spreadsheet_asset) + for travel_spreadsheet_asset in travel_spreadsheet_assets: + if travel_spreadsheet_asset.id not in asset_ids: + assets.append(travel_spreadsheet_asset) spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = [] for asset in assets: @@ -76,7 +88,7 @@ class ExpenseRuleRuntimeService: if version is None: continue 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" ) runtime_payload = self._extract_runtime_payload( @@ -173,7 +185,7 @@ class ExpenseRuleRuntimeService: asset: AgentAsset, version: AgentAssetVersion, ) -> 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 if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet": return @@ -183,7 +195,9 @@ class ExpenseRuleRuntimeService: rule_document = (asset.config_json or {}).get("rule_document") if not isinstance(rule_document, dict): 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: try: workbook_path = manager.resolve_storage_path(storage_key) @@ -217,24 +231,48 @@ class ExpenseRuleRuntimeService: try: standards = self._extract_travel_amount_standards_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) transport_limits = self._extract_transport_class_limits_from_workbook(workbook) + transport_estimates = self._extract_transport_estimates_from_workbook(workbook) finally: workbook.close() standard_rule_version = str( rule_document.get("asset_version") or asset.current_version or version.version ).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["standard_rule_code"] = asset.code - payload["standard_rule_name"] = asset.name - payload["standard_rule_version"] = standard_rule_version + if str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE: + payload["standard_rule_code"] = asset.code + payload["standard_rule_name"] = asset.name + 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: payload["hotel_city_limits"] = { **payload.get("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: payload["allowance_limits"] = { **payload.get("allowance_limits", {}), @@ -245,6 +283,12 @@ class ExpenseRuleRuntimeService: **payload.get("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) for expense_type, amount in standards.items(): @@ -317,6 +361,10 @@ class ExpenseRuleRuntimeService: continue for column_index, header in enumerate(values): 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", "其他员工")): band_indexes["junior"] = column_index if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")): @@ -347,6 +395,17 @@ class ExpenseRuleRuntimeService: city_entry[band] = amount 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 def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]: allowance_limits: dict[str, dict[str, Decimal]] = {} @@ -355,6 +414,12 @@ class ExpenseRuleRuntimeService: if not rows: 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 type_index = -1 region_indexes: dict[str, int] = {} @@ -393,6 +458,19 @@ class ExpenseRuleRuntimeService: allowance_limits[allowance_key] = entry 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 def _map_allowance_type_to_key(value: str) -> str: normalized = re.sub(r"\s+", "", str(value or "")) @@ -412,6 +490,12 @@ class ExpenseRuleRuntimeService: if not rows: 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 flight_index = -1 train_index = -1 @@ -434,11 +518,11 @@ class ExpenseRuleRuntimeService: for row in rows: 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: continue flight_level = ( - ExpenseRuleRuntimeService._transport_class_level_for_text( + transport_class_level_for_text( row[flight_index] if len(row) > flight_index else None, kind="flight", ) @@ -446,7 +530,7 @@ class ExpenseRuleRuntimeService: else None ) train_level = ( - ExpenseRuleRuntimeService._transport_class_level_for_text( + transport_class_level_for_text( row[train_index] if len(row) > train_index else None, kind="train", ) @@ -462,39 +546,121 @@ class ExpenseRuleRuntimeService: return limits @staticmethod - 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 [] - 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及以上", "中层经理", "高层经理", "公司级")): - bands.extend(["mid", "senior", "manager", "executive"]) - return list(dict.fromkeys(bands)) + def _extract_transport_estimates_from_workbook(workbook: Any) -> list[dict[str, object]]: + estimates: list[dict[str, object]] = [] + for sheet in workbook.worksheets: + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + continue + + header_index = -1 + indexes: dict[str, int] = {} + 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 - 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 "")) 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 + return "" + if any(keyword in normalized for keyword in ("高频", "一线", "核心", "重点")): + return "premium" + if any(keyword in normalized for keyword in ("远途", "偏远", "新疆", "西藏", "海南", "港澳台", "海外")): + return "remote" + if any(keyword in normalized for keyword in ("沿海", "海滨", "港口")): + return "coastal" + if any(keyword in normalized for keyword in ("普通", "默认", "其他")): + return "default" + return normalized @staticmethod def _extract_city_names_from_cell(value: str) -> list[str]: diff --git a/server/src/app/services/expense_rule_runtime_defaults.py b/server/src/app/services/expense_rule_runtime_defaults.py index 3e4a481..5ba4cd1 100644 --- a/server/src/app/services/expense_rule_runtime_defaults.py +++ b/server/src/app/services/expense_rule_runtime_defaults.py @@ -230,11 +230,15 @@ DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = { "晚到店", ], "band_labels": { - "junior": "P1-P3", - "mid": "P4-P5", - "senior": "P6-P7", - "manager": "M1-M2", - "executive": "M3及以上 / D序列", + "P0": "P0 实习/见习", + "P1": "P1 基础员工", + "P2": "P2 初级员工", + "P3": "P3 普通员工", + "P4": "P4 资深员工/主管", + "P5": "P5 基层经理", + "P6": "P6 中层经理", + "P7": "P7 高层经理", + "P8": "P8 董事会", }, "city_tiers": { "北京": "tier_1", @@ -267,18 +271,26 @@ DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = { "佛山": "tier_2", }, "hotel_limits": { - "junior": {"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"}, - "senior": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"}, - "manager": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"}, - "executive": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"}, + "P0": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"}, + "P1": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"}, + "P2": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"}, + "P3": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.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": { - "junior": {"flight": 1, "train": 1}, - "mid": {"flight": 1, "train": 1}, - "senior": {"flight": 2, "train": 2}, - "manager": {"flight": 3, "train": 3}, - "executive": {"flight": 4, "train": 3}, + "P0": {"flight": 1, "train": 1}, + "P1": {"flight": 1, "train": 1}, + "P2": {"flight": 1, "train": 1}, + "P3": {"flight": 1, "train": 1}, + "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": [ {"keyword": "头等舱", "level": 4}, diff --git a/server/src/app/services/expense_rule_runtime_models.py b/server/src/app/services/expense_rule_runtime_models.py index 4a1d834..dea23e8 100644 --- a/server/src/app/services/expense_rule_runtime_models.py +++ b/server/src/app/services/expense_rule_runtime_models.py @@ -46,6 +46,17 @@ class TravelClassConfig(BaseModel): 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): kind: Literal["travel_policy"] version: int = 1 @@ -57,11 +68,17 @@ class TravelPolicyConfig(BaseModel): city_tiers: dict[str, str] = 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_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) standard_rule_code: str = "" standard_rule_name: str = "" standard_rule_version: str = "" 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) train_classes: list[TravelClassConfig] = Field(default_factory=list) diff --git a/server/src/app/services/expense_rule_runtime_spreadsheet_extractors.py b/server/src/app/services/expense_rule_runtime_spreadsheet_extractors.py new file mode 100644 index 0000000..6540417 --- /dev/null +++ b/server/src/app/services/expense_rule_runtime_spreadsheet_extractors.py @@ -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(",") diff --git a/server/src/app/services/ontology_field_registry.py b/server/src/app/services/ontology_field_registry.py index a34748e..0a65795 100644 --- a/server/src/app/services/ontology_field_registry.py +++ b/server/src/app/services/ontology_field_registry.py @@ -34,6 +34,57 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = { "applicationAmount", "application_amount_label", "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_type", @@ -42,6 +93,15 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = { "application_transport_mode", "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"), "customer_name": ("customerName",), "merchant_name": ("merchantName",), @@ -54,6 +114,10 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = { "manager_name": ("managerName", "direct_manager_name", "directManagerName"), "finance_owner_name": ("financeOwnerName",), "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( @@ -69,14 +133,36 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset( "employee_location", "employee_risk_profile", "document_id", + "invoice_no", + "invoice_date", + "ticket_no", + "ticket_type", + "origin_location", + "destination_location", + "hotel_name", + "hotel_nights", "application_claim_id", "application_claim_no", + "application_status", + "application_amount", + "application_approved_amount", + "application_budget_occupied_amount", + "application_reimbursement_amount", + "application_expense_type", "application_days", "application_date", + "application_required", + "preapproval_required", "application_lodging_daily_cap", "application_subsidy_daily_cap", "application_transport_policy", "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_version", "original_amount", diff --git a/server/src/app/services/risk_ontology_bridge.py b/server/src/app/services/risk_ontology_bridge.py index 95b119c..f0f0442 100644 --- a/server/src/app/services/risk_ontology_bridge.py +++ b/server/src/app/services/risk_ontology_bridge.py @@ -2,6 +2,18 @@ from __future__ import annotations 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]] = { "location_mismatch": ["risk.travel.destination_receipt_location"], "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"], "consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"], "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, ...]] = { @@ -32,6 +47,29 @@ TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = { "meal_as_travel": ("餐费", "差旅餐", "本地餐"), "consecutive_transport_receipts": ("连续交通", "多张车票", "打车"), "reason_too_brief": ("事由", "说明太短", "理由不足"), + "application_required": ( + "前置申请", + "事前申请", + "事前审批", + "费用申请", + "申请审批", + "无申请", + "未申请", + "缺少申请", + "没有申请", + "未审批", + ), + "preapproval_absent": ( + "无前置申请", + "未做申请", + "未提交申请", + "未走审批", + "大额费用", + "业务招待", + "招待费", + "办公采购", + "办公用品", + ), } diff --git a/server/src/app/services/steward_flow_state.py b/server/src/app/services/steward_flow_state.py new file mode 100644 index 0000000..36cdd72 --- /dev/null +++ b/server/src/app/services/steward_flow_state.py @@ -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 :] diff --git a/server/src/app/services/steward_intent_agent.py b/server/src/app/services/steward_intent_agent.py index 1ac60ba..85849b3 100644 --- a/server/src/app/services/steward_intent_agent.py +++ b/server/src/app/services/steward_intent_agent.py @@ -108,6 +108,9 @@ class StewardIntentAgent: "用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时," "即使没有出现“申请”两个字,也必须优先识别为 expense_application。" "用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。" + "如果用户只描述出差时间、地点和事由,但没有明确申请、报销、提交、保存草稿等动作," + "且无法从上下文判断流程方向,必须返回 pending_flow_confirmation.status=pending," + "candidate_flows 同时给出 travel_application 和 travel_reimbursement,tasks 保持空数组。" "所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields;" "如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。" "相对日期必须以 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": { "type": "array", "items": { diff --git a/server/src/app/services/steward_model_plan_builder.py b/server/src/app/services/steward_model_plan_builder.py index 1d6b939..e6236cb 100644 --- a/server/src/app/services/steward_model_plan_builder.py +++ b/server/src/app/services/steward_model_plan_builder.py @@ -8,6 +8,8 @@ from typing import Any from app.schemas.steward import ( StewardAttachmentGroup, StewardAttachmentInput, + StewardCandidateFlow, + StewardPendingFlowConfirmation, StewardPlanRequest, StewardPlanResponse, StewardTask, @@ -31,7 +33,18 @@ class StewardModelPlanBuilder: request: StewardPlanRequest, base_date: date, ) -> 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) + 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: return None @@ -54,11 +67,33 @@ class StewardModelPlanBuilder: plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}", plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate", planning_source="llm_function_call", + next_action="confirm_task" if confirmation_groups else "delegate_task", summary=self.planner._build_summary(tasks, attachment_groups), thinking_events=thinking_events, tasks=tasks, attachment_groups=attachment_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, ) @@ -144,6 +179,134 @@ class StewardModelPlanBuilder: 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( self, raw_fields: Any, diff --git a/server/src/app/services/steward_planner.py b/server/src/app/services/steward_planner.py index af4251e..299b270 100644 --- a/server/src/app/services/steward_planner.py +++ b/server/src/app/services/steward_planner.py @@ -9,7 +9,9 @@ from typing import Any from app.schemas.steward import ( StewardAttachmentGroup, StewardAttachmentInput, + StewardCandidateFlow, StewardConfirmationAction, + StewardPendingFlowConfirmation, StewardPlanRequest, StewardPlanResponse, StewardTask, @@ -107,7 +109,7 @@ class StewardPlannerService: base_date = self._resolve_base_date(request.client_now_iso, request.context_json) model_call_traces: list[dict[str, Any]] = [] 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: intent_result = self.intent_agent.detect( request, @@ -122,6 +124,17 @@ class StewardPlannerService: base_date=base_date, ) if llm_plan is not None: + if self._looks_like_ambiguous_travel_flow(message, base_date, request): + return self._build_pending_flow_fallback_plan( + request, + base_date=base_date, + model_call_traces=model_call_traces, + fallback_reason=( + "主模型返回了直接任务,但当前话术没有明确申请或报销动作;" + "服务端已改为候选流程确认,避免误入申请流程。" + ), + planning_source="llm_function_call", + ) return llm_plan model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。" @@ -136,6 +149,16 @@ class StewardPlannerService: 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( self, request: StewardPlanRequest, @@ -145,6 +168,13 @@ class StewardPlannerService: fallback_reason: str = "", ) -> StewardPlanResponse: 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) tasks = [self._build_task(draft, base_date, request) for draft in task_drafts] if not tasks: @@ -169,6 +199,7 @@ class StewardPlannerService: plan_id=plan_id, plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate", planning_source="rule_fallback", + next_action="confirm_task" if confirmation_groups else "delegate_task", summary=self._build_summary(tasks, attachment_groups), thinking_events=thinking_events, tasks=tasks, @@ -177,6 +208,91 @@ class StewardPlannerService: 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]: drafts: list[PlannedTaskDraft] = [] first_reimbursement = self._find_first_reimbursement_index(message) @@ -202,6 +318,24 @@ class StewardPlannerService: 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 def _find_first_reimbursement_index(message: str) -> int: 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)) + 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( self, draft: PlannedTaskDraft, diff --git a/server/src/app/services/steward_runtime_decision_agent.py b/server/src/app/services/steward_runtime_decision_agent.py index bb810d3..b6bf95c 100644 --- a/server/src/app/services/steward_runtime_decision_agent.py +++ b/server/src/app/services/steward_runtime_decision_agent.py @@ -1,19 +1,23 @@ from __future__ import annotations import json +import re from typing import Any from app.schemas.steward import ( + StewardFlowStatePatch, StewardRuntimeDecisionRequest, StewardRuntimeDecisionResponse, ) from app.services.runtime_chat import RuntimeChatService +from app.services.steward_flow_state import StewardFlowStateService STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision" RUNTIME_NEXT_ACTIONS = { "plan_new_tasks", + "continue_selected_flow", "submit_current_application", "continue_next_task", "fill_current_slot", @@ -22,6 +26,16 @@ RUNTIME_NEXT_ACTIONS = { "no_op", } +FIELD_LABELS = { + "transport_mode": "出行方式", + "expense_type": "费用类型", + "time_range": "时间", + "location": "地点", + "reason": "事由", + "amount": "金额", + "attachments": "附件", +} + class StewardRuntimeDecisionAgent: """用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。""" @@ -31,6 +45,9 @@ class StewardRuntimeDecisionAgent: def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse: 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( self._build_messages(normalized_request), 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: response = self._build_response_from_model_payload(result.tool_call.arguments, normalized_request, traces) if response is not None: - return response - return self._build_rule_fallback(normalized_request, traces) + return self._attach_updated_steward_state(response, normalized_request) + 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 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( user_message=str(request.user_message or "").strip(), session_type=str(request.session_type or "steward").strip() or "steward", - runtime_state=request.runtime_state if isinstance(request.runtime_state, dict) else {}, - context_json=request.context_json if isinstance(request.context_json, dict) else {}, + runtime_state=StewardRuntimeDecisionAgent._hydrate_runtime_state(runtime_state, context_json), + 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 def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]: payload = { @@ -177,6 +280,34 @@ class StewardRuntimeDecisionAgent: rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。", 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: return StewardRuntimeDecisionResponse( decision_source="rule_fallback", @@ -192,6 +323,104 @@ class StewardRuntimeDecisionAgent: 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 def _clean_text(value: Any) -> str: 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 {} diff --git a/server/src/app/services/travel_policy_grades.py b/server/src/app/services/travel_policy_grades.py new file mode 100644 index 0000000..79850d4 --- /dev/null +++ b/server/src/app/services/travel_policy_grades.py @@ -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"(? 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" diff --git a/server/src/app/services/travel_reimbursement_calculator.py b/server/src/app/services/travel_reimbursement_calculator.py index 951f350..3d339c0 100644 --- a/server/src/app/services/travel_reimbursement_calculator.py +++ b/server/src/app/services/travel_reimbursement_calculator.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from datetime import date from decimal import Decimal 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.expense_claims import ExpenseClaimService from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService - -OTHER_REGION_LOCATION_KEYWORDS = { - "河北", - "石家庄", - "唐山", - "秦皇岛", - "邯郸", - "邢台", - "保定", - "张家口", - "承德", - "沧州", - "廊坊", - "衡水", - "山西", - "太原", - "大同", - "长治", - "晋城", - "晋中", - "运城", - "临汾", - "吕梁", - "内蒙古", - "呼和浩特", - "包头", - "赤峰", - "通辽", - "鄂尔多斯", - "辽宁", - "鞍山", - "抚顺", - "本溪", - "丹东", - "锦州", - "营口", - "盘锦", - "吉林", - "长春", - "吉林市", - "四平", - "通化", - "白山", - "松原", - "延边", - "黑龙江", - "哈尔滨", - "齐齐哈尔", - "牡丹江", - "佳木斯", - "大庆", - "江苏", - "常州", - "南通", - "连云港", - "淮安", - "盐城", - "扬州", - "镇江", - "泰州", - "宿迁", - "浙江", - "温州", - "嘉兴", - "湖州", - "绍兴", - "金华", - "衢州", - "舟山", - "台州", - "丽水", - "安徽", - "芜湖", - "蚌埠", - "淮南", - "马鞍山", - "淮北", - "铜陵", - "安庆", - "黄山", - "滁州", - "阜阳", - "宿州", - "六安", - "亳州", - "池州", - "宣城", - "福建", - "泉州", - "漳州", - "莆田", - "三明", - "南平", - "龙岩", - "宁德", - "江西", - "南昌", - "景德镇", - "萍乡", - "九江", - "新余", - "鹰潭", - "赣州", - "吉安", - "宜春", - "抚州", - "上饶", - "山东", - "淄博", - "枣庄", - "东营", - "烟台", - "潍坊", - "济宁", - "泰安", - "威海", - "日照", - "临沂", - "德州", - "聊城", - "滨州", - "菏泽", - "河南", - "洛阳", - "开封", - "平顶山", - "安阳", - "鹤壁", - "新乡", - "焦作", - "濮阳", - "许昌", - "漯河", - "三门峡", - "南阳", - "商丘", - "信阳", - "周口", - "驻马店", - "湖北", - "黄石", - "十堰", - "宜昌", - "襄阳", - "鄂州", - "荆门", - "孝感", - "荆州", - "黄冈", - "咸宁", - "随州", - "恩施", - "湖南", - "株洲", - "湘潭", - "衡阳", - "邵阳", - "岳阳", - "常德", - "张家界", - "益阳", - "郴州", - "永州", - "怀化", - "娄底", - "湘西", - "广东", - "惠州", - "江门", - "湛江", - "茂名", - "肇庆", - "梅州", - "汕尾", - "河源", - "阳江", - "清远", - "潮州", - "揭阳", - "云浮", - "广西", - "南宁", - "柳州", - "桂林", - "梧州", - "北海", - "防城港", - "钦州", - "贵港", - "玉林", - "百色", - "贺州", - "河池", - "来宾", - "崇左", - "海南", - "儋州", - "四川", - "自贡", - "攀枝花", - "泸州", - "德阳", - "绵阳", - "广元", - "遂宁", - "内江", - "乐山", - "南充", - "眉山", - "宜宾", - "广安", - "达州", - "雅安", - "巴中", - "资阳", - "阿坝", - "甘孜", - "凉山", - "贵州", - "贵阳", - "遵义", - "六盘水", - "安顺", - "毕节", - "铜仁", - "黔东南", - "黔南", - "黔西南", - "云南", - "曲靖", - "玉溪", - "保山", - "昭通", - "丽江", - "普洱", - "临沧", - "楚雄", - "红河", - "文山", - "西双版纳", - "大理", - "德宏", - "怒江", - "迪庆", - "陕西", - "宝鸡", - "咸阳", - "铜川", - "渭南", - "延安", - "汉中", - "榆林", - "安康", - "商洛", - "甘肃", - "兰州", - "嘉峪关", - "金昌", - "白银", - "天水", - "武威", - "张掖", - "平凉", - "酒泉", - "庆阳", - "定西", - "陇南", - "临夏", - "甘南", - "青海", - "西宁", - "海东", - "海北", - "黄南", - "海南州", - "果洛", - "玉树", - "海西", - "宁夏", - "银川", - "石嘴山", - "吴忠", - "固原", - "中卫", -} - -OTHER_REGION_PROVINCE_KEYWORDS = { - "河北", - "山西", - "内蒙古", - "辽宁", - "吉林", - "黑龙江", - "江苏", - "浙江", - "安徽", - "福建", - "江西", - "山东", - "河南", - "湖北", - "湖南", - "广东", - "广西", - "海南", - "四川", - "贵州", - "云南", - "陕西", - "甘肃", - "青海", - "宁夏", - "新疆", - "西藏", - "台湾", - "香港", - "澳门", -} - -AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"} +from app.services.travel_policy_grades import travel_policy_grade_key_candidates +from app.services.travel_reimbursement_regions import ( + AMBIGUOUS_PROVINCE_CITY_NAMES, + OTHER_REGION_LOCATION_KEYWORDS, + OTHER_REGION_PROVINCE_KEYWORDS, +) class TravelReimbursementCalculatorService: @@ -359,40 +46,76 @@ class TravelReimbursementCalculatorService: grade_band = ExpenseClaimService._resolve_travel_policy_band(grade) if not grade_band: - raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位。") + raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销职级。") matched_city = self._resolve_city(location, policy) matched_other_region = "" if matched_city else self._resolve_other_region(location) if not matched_city and not matched_other_region: raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。") 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) meal_rate = self._resolve_allowance_rate(policy, "meal", 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) + 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) 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) rule_name = policy.standard_rule_name or policy.rule_name 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) - formula_text = ( - f"住宿 {self._format_money(hotel_rate)} × {days} 天 + " - f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = " - f"{self._format_money(total_amount)}" - ) + 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 = ( + f"住宿 {self._format_money(hotel_rate)} × {days} 天 + " + f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = " + f"{self._format_money(total_amount)}" + ) + summary_tail = ( + f"按 {days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元," + f"补贴合计 {self._format_money(allowance_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"当前职级 {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"补贴合计 {self._format_money(allowance_amount)} 元," - f"参考可报销总金额为 {self._format_money(total_amount)} 元。" + f"{summary_tail}" ) return TravelReimbursementCalculatorResponse( @@ -410,6 +133,19 @@ class TravelReimbursementCalculatorService: basic_allowance_rate=basic_rate, total_allowance_rate=total_allowance_rate, 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, rule_name=rule_name, rule_version=rule_version, @@ -510,6 +246,152 @@ class TravelReimbursementCalculatorService: return matches[0] 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 def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str: normalized = str(location or "").strip() @@ -536,17 +418,67 @@ class TravelReimbursementCalculatorService: grade_band: str, matched_city: str, city_tier: str, + travel_date: date | None = None, ) -> Decimal: city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {} - if city_limits.get(grade_band) is not None: - return Decimal(city_limits[grade_band]) + base_rate = Decimal("0") + 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 band_limits.get(city_tier) is not None: - return Decimal(band_limits[city_tier]) - if band_limits.get("tier_3") is not None: - return Decimal(band_limits["tier_3"]) - return Decimal("0") + 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: + base_rate = Decimal(band_limits[city_tier]) + break + if band_limits.get("tier_3") is not None: + 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") + 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 def _resolve_allowance_region(location: str, matched_city: str) -> str: diff --git a/server/src/app/services/travel_reimbursement_regions.py b/server/src/app/services/travel_reimbursement_regions.py new file mode 100644 index 0000000..0f21ad9 --- /dev/null +++ b/server/src/app/services/travel_reimbursement_regions.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +OTHER_REGION_LOCATION_KEYWORDS = { + "河北", + "石家庄", + "唐山", + "秦皇岛", + "邯郸", + "邢台", + "保定", + "张家口", + "承德", + "沧州", + "廊坊", + "衡水", + "山西", + "太原", + "大同", + "长治", + "晋城", + "晋中", + "运城", + "临汾", + "吕梁", + "内蒙古", + "呼和浩特", + "包头", + "赤峰", + "通辽", + "鄂尔多斯", + "辽宁", + "鞍山", + "抚顺", + "本溪", + "丹东", + "锦州", + "营口", + "盘锦", + "吉林", + "长春", + "吉林市", + "四平", + "通化", + "白山", + "松原", + "延边", + "黑龙江", + "哈尔滨", + "齐齐哈尔", + "牡丹江", + "佳木斯", + "大庆", + "江苏", + "常州", + "南通", + "连云港", + "淮安", + "盐城", + "扬州", + "镇江", + "泰州", + "宿迁", + "浙江", + "温州", + "嘉兴", + "湖州", + "绍兴", + "金华", + "衢州", + "舟山", + "台州", + "丽水", + "安徽", + "芜湖", + "蚌埠", + "淮南", + "马鞍山", + "淮北", + "铜陵", + "安庆", + "黄山", + "滁州", + "阜阳", + "宿州", + "六安", + "亳州", + "池州", + "宣城", + "福建", + "泉州", + "漳州", + "莆田", + "三明", + "南平", + "龙岩", + "宁德", + "江西", + "南昌", + "景德镇", + "萍乡", + "九江", + "新余", + "鹰潭", + "赣州", + "吉安", + "宜春", + "抚州", + "上饶", + "山东", + "淄博", + "枣庄", + "东营", + "烟台", + "潍坊", + "济宁", + "泰安", + "威海", + "日照", + "临沂", + "德州", + "聊城", + "滨州", + "菏泽", + "河南", + "洛阳", + "开封", + "平顶山", + "安阳", + "鹤壁", + "新乡", + "焦作", + "濮阳", + "许昌", + "漯河", + "三门峡", + "南阳", + "商丘", + "信阳", + "周口", + "驻马店", + "湖北", + "黄石", + "十堰", + "宜昌", + "襄阳", + "鄂州", + "荆门", + "孝感", + "荆州", + "黄冈", + "咸宁", + "随州", + "恩施", + "湖南", + "株洲", + "湘潭", + "衡阳", + "邵阳", + "岳阳", + "常德", + "张家界", + "益阳", + "郴州", + "永州", + "怀化", + "娄底", + "湘西", + "广东", + "惠州", + "江门", + "湛江", + "茂名", + "肇庆", + "梅州", + "汕尾", + "河源", + "阳江", + "清远", + "潮州", + "揭阳", + "云浮", + "广西", + "南宁", + "柳州", + "桂林", + "梧州", + "北海", + "防城港", + "钦州", + "贵港", + "玉林", + "百色", + "贺州", + "河池", + "来宾", + "崇左", + "海南", + "儋州", + "四川", + "自贡", + "攀枝花", + "泸州", + "德阳", + "绵阳", + "广元", + "遂宁", + "内江", + "乐山", + "南充", + "眉山", + "宜宾", + "广安", + "达州", + "雅安", + "巴中", + "资阳", + "阿坝", + "甘孜", + "凉山", + "贵州", + "贵阳", + "遵义", + "六盘水", + "安顺", + "毕节", + "铜仁", + "黔东南", + "黔南", + "黔西南", + "云南", + "曲靖", + "玉溪", + "保山", + "昭通", + "丽江", + "普洱", + "临沧", + "楚雄", + "红河", + "文山", + "西双版纳", + "大理", + "德宏", + "怒江", + "迪庆", + "陕西", + "宝鸡", + "咸阳", + "铜川", + "渭南", + "延安", + "汉中", + "榆林", + "安康", + "商洛", + "甘肃", + "兰州", + "嘉峪关", + "金昌", + "白银", + "天水", + "武威", + "张掖", + "平凉", + "酒泉", + "庆阳", + "定西", + "陇南", + "临夏", + "甘南", + "青海", + "西宁", + "海东", + "海北", + "黄南", + "海南州", + "果洛", + "玉树", + "海西", + "宁夏", + "银川", + "石嘴山", + "吴忠", + "固原", + "中卫", +} + +OTHER_REGION_PROVINCE_KEYWORDS = { + "河北", + "山西", + "内蒙古", + "辽宁", + "吉林", + "黑龙江", + "江苏", + "浙江", + "安徽", + "福建", + "江西", + "山东", + "河南", + "湖北", + "湖南", + "广东", + "广西", + "海南", + "四川", + "贵州", + "云南", + "陕西", + "甘肃", + "青海", + "宁夏", + "新疆", + "西藏", + "台湾", + "香港", + "澳门", +} + +AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"} diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index 2b10b59..53b8225 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -38,8 +38,14 @@ from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_PREAPPROVAL_RULE_CODE, 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_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, ) 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 for file_name in ( 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_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["ai_review_category"] == "通信费" 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_sheet"] == "费用申请审批规则" assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"] 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: 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.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE - assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则" - assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450 - assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450 - assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500 + assert catalog.travel_policy.standard_rule_name == "差旅住宿报销标准" + assert catalog.travel_policy.hotel_city_limits["北京"]["P0"] == 450 + assert catalog.travel_policy.hotel_city_limits["北京"]["P4"] == 450 + assert catalog.travel_policy.hotel_city_limits["北京"]["P8"] == 500 assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65 assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55 assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90 - assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1 - assert catalog.travel_policy.transport_limits["executive"]["train"] == 1 + assert catalog.travel_policy.transport_limits["P7"]["flight"] == 1 + assert catalog.travel_policy.transport_limits["P8"]["train"] == 2 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_band == "mid" + assert result.grade_band == "P4" assert result.matched_city == "北京" assert result.hotel_rate == 450 assert result.hotel_amount == 1350 assert result.allowance_region == "直辖市/特区" assert result.total_allowance_rate == 100 assert result.allowance_amount == 300 - assert result.total_amount == 1650 - assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text - assert "参考可报销总金额为 1650.00 元" in result.summary_text + assert result.transport_estimated_amount == 1040 + assert result.transport_estimate_source == "basic_rule_transport_estimate" + 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: @@ -821,7 +859,8 @@ def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_lo assert result.allowance_region == "其他地区" assert result.total_allowance_rate == 90 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: diff --git a/server/tests/test_steward_flow_state.py b/server/tests/test_steward_flow_state.py new file mode 100644 index 0000000..d532fda --- /dev/null +++ b/server/tests/test_steward_flow_state.py @@ -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" diff --git a/server/tests/test_steward_intent_agent.py b/server/tests/test_steward_intent_agent.py new file mode 100644 index 0000000..0dcad50 --- /dev/null +++ b/server/tests/test_steward_intent_agent.py @@ -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", + ] diff --git a/server/tests/test_steward_planner.py b/server/tests/test_steward_planner.py index f6ff29d..1e3a4df 100644 --- a/server/tests/test_steward_planner.py +++ b/server/tests/test_steward_planner.py @@ -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: def detect(self, request, *, base_date, canonical_fields): 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: 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", attachments=[ 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: 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", ) @@ -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: 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", ) @@ -184,19 +285,114 @@ def test_steward_planner_enforces_application_transport_gap_after_function_calli 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: 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", ) result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload) 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" +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: payload = StewardPlanRequest( message=( @@ -326,3 +522,28 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None: assert events[0]["data"]["stage"] == "stream_start" assert events[-1]["event"] == "plan" 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()) diff --git a/server/tests/test_steward_runtime_decision_agent.py b/server/tests/test_steward_runtime_decision_agent.py index f745531..8a5b961 100644 --- a/server/tests/test_steward_runtime_decision_agent.py +++ b/server/tests/test_steward_runtime_decision_agent.py @@ -94,3 +94,154 @@ def test_steward_runtime_decision_fallback_keeps_current_context(): assert result.next_action == "continue_next_task" assert result.target_message_id == "msg-next-task" 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" diff --git a/shared/ontology_business_contract.json b/shared/ontology_business_contract.json new file mode 100644 index 0000000..17c9749 --- /dev/null +++ b/shared/ontology_business_contract.json @@ -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": "请重新描述你的财务业务需求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。" + } +} diff --git a/web/src/assets/styles/components/travel-reimbursement-message-item.css b/web/src/assets/styles/components/travel-reimbursement-message-item.css index 93b3b81..b223fde 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-item.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-item.css @@ -532,11 +532,10 @@ .message-answer-markdown :deep(table) { width: 100%; - min-width: 560px; + min-width: 460px; border: 0; border-collapse: separate; border-spacing: 0; - table-layout: fixed; background: #ffffff; font-size: inherit; } @@ -548,25 +547,6 @@ text-align: left; vertical-align: top; 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) { @@ -806,6 +786,30 @@ 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 { cursor: pointer; } diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 186837b..e7954d6 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -1,4 +1,4 @@ -.approval-page { +.approval-page { width: 100%; height: 100%; min-height: 0; @@ -861,6 +861,9 @@ } .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; overflow-x: auto; } @@ -940,10 +943,10 @@ .detail-expense-table .col-time { width: 10%; } .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-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-action { width: 9%; } @@ -1000,61 +1003,162 @@ grid-template-columns: minmax(0, 1fr); } -.editor-input, -.editor-select, -.editor-textarea { +.editor-control, +.editor-select { width: 100%; - min-height: 34px; - padding: 0 10px; - border: 1px solid #d7e0ea; - border-radius: 4px; - background: #fff; + min-width: 0; + --el-component-size: var(--expense-editor-control-height) !important; + --el-component-size-small: var(--expense-editor-control-height) !important; + --el-input-height: var(--expense-editor-control-height) !important; + 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; font-size: 12px; + line-height: var(--expense-editor-control-line-height) !important; } -.editor-textarea { - min-height: 68px; - padding: 8px 10px; - resize: vertical; - line-height: 1.45; -} - -.risk-note-editor-textarea { - min-height: 34px; - max-height: 78px; - overflow-y: auto; - resize: none; -} - -.currency-editor { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); +.editor-control :deep(.el-input__prefix), +.editor-control :deep(.el-input__suffix), +.editor-date-picker :deep(.el-input__prefix), +.editor-date-picker :deep(.el-input__suffix) { + display: inline-flex; align-items: center; - gap: 8px; } -.currency-editor span { - min-height: 34px; - display: grid; - place-items: center; - border: 1px solid #d7e0ea; - border-radius: 4px; - background: #f8fafc; +.editor-select :deep(.el-select__wrapper) { + min-height: var(--expense-editor-control-height); + height: var(--expense-editor-control-height); +} + +.editor-amount-input :deep(.el-input__prefix) { + min-height: var(--expense-editor-control-height); + height: var(--expense-editor-control-height); color: #334155; + display: inline-flex; + align-items: center; font-size: 12px; font-weight: 800; } -.editor-input:focus, -.editor-select:focus, -.editor-textarea:focus { - border-color: var(--theme-primary); - box-shadow: 0 0 0 3px var(--theme-focus-ring); - outline: none; +.editor-amount-input :deep(.el-input__prefix-inner) { + display: inline-flex; + align-items: center; + height: var(--expense-editor-control-line-height); + line-height: var(--expense-editor-control-line-height); } -.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; font-size: 11px; line-height: 1.45; @@ -1432,6 +1536,16 @@ flex-wrap: wrap; gap: 6px; 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 { diff --git a/web/src/components/travel/TravelReimbursementMessageItem.vue b/web/src/components/travel/TravelReimbursementMessageItem.vue index 33496a6..18c5738 100644 --- a/web/src/components/travel/TravelReimbursementMessageItem.vue +++ b/web/src/components/travel/TravelReimbursementMessageItem.vue @@ -207,6 +207,7 @@