diff --git a/document/development/rules/rule-version-center-ui-plan.md b/document/development/rules/rule-version-center-ui-plan.md
new file mode 100644
index 0000000..aa2064f
--- /dev/null
+++ b/document/development/rules/rule-version-center-ui-plan.md
@@ -0,0 +1,453 @@
+# 规则版本中心 UI 方案
+
+## 1. 背景
+
+当前“任务规则中心 > 财务规则 > 公司差旅费报销规则”已经具备:
+
+- 在线 Excel 编辑
+- 工作版本 / 线上版本分离
+- 最近 5 个版本展示
+- 审核、上线、恢复能力
+
+但页面仍然存在一个明显问题:
+**版本治理能力已经有了,用户却很难第一眼看见。**
+
+当前版本列表更像“历史记录”,不是一个明确的“版本操作中心”。
+用户无法快速判断:
+
+1. 当前真正生效的是哪个版本
+2. 当前正在编辑的是哪个版本
+3. 从哪里进入版本切换
+4. 从哪里发起版本对比
+5. 某个版本经历了哪些流转动作
+
+因此,需要把现有“版本列表”升级为一个真正可用的 **版本中心**。
+
+---
+
+## 2. 设计目标
+
+### 2.1 用户一眼能看懂
+
+进入规则详情页后,用户无需点击就能立即识别:
+
+- 当前线上版本
+- 当前工作版本
+- 是否存在未上线工作稿
+- 最近版本是否处于待审 / 已通过 / 已驳回状态
+
+### 2.2 关键操作显性化
+
+以下操作不能再隐藏在不明显的位置:
+
+- 切换查看版本
+- 与线上版本对比
+- 查看完整流转
+- 从历史版本恢复
+
+### 2.3 保持 OnlyOffice 是主角
+
+该页面的核心仍然是 Excel 规则表。
+版本中心必须增强治理能力,但不能把主表格压缩成附属内容。
+
+---
+
+## 3. 推荐方案
+
+采用:
+
+> **左侧 OnlyOffice 主工作区 + 右侧版本中心 + 顶部显性入口 + 抽屉式详情**
+
+这是比“单独开二级页签”更适合当前页面的方案,因为用户经常需要:
+
+- 一边看表
+- 一边知道自己看的是什么版本
+- 一边进入版本对比或恢复
+
+---
+
+## 4. 页面整体布局
+
+```text
+┌────────────────────────────────────────────────────────────────────┐
+│ 标题区:公司差旅费报销规则 │
+│ 线上版本 v1.0.5 已上线 工作版本 v1.0.6 待审核 │
+│ [下载 Excel] [上传表格] [版本对比] [查看流转] │
+├───────────────────────────────────────────────┬────────────────────┤
+│ │ 版本中心 │
+│ │ │
+│ │ ┌──────────────┐ │
+│ │ │ 线上版本 │ │
+│ │ │ v1.0.5 │ │
+│ │ └──────────────┘ │
+│ OnlyOffice │ ┌──────────────┐ │
+│ 规则表主工作区 │ │ 工作版本 │ │
+│ │ │ v1.0.6 │ │
+│ │ └──────────────┘ │
+│ │ │
+│ │ 最近版本 │
+│ │ v1.0.6 待审核 │
+│ │ v1.0.5 已上线 │
+│ │ v1.0.4 历史版本 │
+│ │ │
+│ │ 最近流转 │
+│ │ [查看完整流转] │
+└───────────────────────────────────────────────┴────────────────────┘
+```
+
+---
+
+## 5. 顶部操作区设计
+
+顶部必须保留并强化四个动作:
+
+| 按钮 | 用途 |
+| --- | --- |
+| 下载 Excel | 下载当前预览版本 |
+| 上传表格 | 导入内容生成新工作稿 |
+| 版本对比 | 打开对比抽屉 |
+| 查看流转 | 打开流转抽屉 |
+
+### 5.1 版本对比按钮
+
+这是一级入口,不能只藏在版本列表里。
+默认行为:
+
+- 基准版本:当前线上版本
+- 对比版本:当前工作版本
+
+如果两者相同,则按钮仍可用,但进入后提示:
+
+> 当前工作版本与线上版本一致,可选择其他历史版本进行比较。
+
+### 5.2 查看流转按钮
+
+用于进入当前规则的完整生命周期视图。
+不应只展示审计日志,而要展示“版本业务履历”。
+
+---
+
+## 6. 右侧版本中心设计
+
+### 6.1 顶部双版本卡片
+
+```text
+线上版本
+v1.0.5
+已上线
+
+工作版本
+v1.0.6
+待审核
+```
+
+#### 设计目的
+
+用户进入页面后,最先要知道的是:
+
+- **谁在线上**
+- **谁正在被编辑**
+
+而不是先看一个无上下文的历史列表。
+
+### 6.2 最近版本列表
+
+每个版本项包含:
+
+- 版本号
+- 生命周期状态
+- 创建时间
+- 变更说明
+- 操作入口
+
+建议样式:
+
+```text
+v1.0.6 待审核
+2026-05-18 09:12
+补充出差补助标准
+[查看] [与线上比]
+
+v1.0.5 已上线
+2026-05-18 08:40
+新增补助页签
+[查看]
+
+v1.0.4 历史版本
+2026-05-17 17:20
+修正住宿标准
+[查看] [恢复]
+```
+
+#### 规则
+
+- `查看`:切换当前预览版本
+- `与线上比`:直接以线上版本为基准进入对比
+- `恢复`:仅高级管理人员可见
+- 当前 `working_version` 不显示“恢复”
+
+### 6.3 最近流转摘要
+
+右侧版本中心底部展示最近 3 条流转:
+
+```text
+最近流转
+09:12 曹笑竹 保存工作稿
+09:25 曹笑竹 提交审核
+10:08 顾承宇 审核通过
+[查看完整流转]
+```
+
+---
+
+## 7. 版本流转时间线设计
+
+## 7.1 入口
+
+两个入口:
+
+1. 顶部 `查看流转`
+2. 右侧版本中心底部 `查看完整流转`
+
+## 7.2 容器
+
+使用右侧宽抽屉,不使用小弹窗。
+原因:
+
+- 时间线内容会逐步增长
+- 审核意见需要足够宽度展示
+- 后续可能接入版本说明、操作人、来源版本
+
+## 7.3 时间线内容
+
+时间线按时间倒序或正序展示,推荐默认正序:
+
+```text
+● 2026-05-18 09:12
+ v1.0.6 工作稿创建
+ 曹笑竹 保存工作稿
+ 变更说明:补充出差补助标准
+
+● 2026-05-18 09:25
+ 提交审核
+ 曹笑竹 提交当前工作版本
+
+● 2026-05-18 10:08
+ 审核通过
+ 顾承宇:口径已核对,可上线
+
+○ 待正式上线
+```
+
+如果版本来自恢复:
+
+```text
+● 基于 v1.0.3 恢复生成 v1.0.7
+```
+
+## 7.4 时间线事件类型
+
+| 事件类型 | 说明 |
+| --- | --- |
+| `created` | 创建版本 |
+| `submitted` | 提交审核 |
+| `approved` | 审核通过 |
+| `rejected` | 驳回 |
+| `published` | 正式上线 |
+| `restored` | 基于历史版本恢复 |
+
+---
+
+## 8. 版本差异对比设计
+
+## 8.1 入口
+
+版本对比必须有两个入口:
+
+1. 顶部一级按钮:`版本对比`
+2. 每个历史版本行内操作:`与线上比`
+
+这样既满足“主动进入”,也满足“看到某个版本就顺手比较”。
+
+## 8.2 容器
+
+使用宽抽屉,推荐宽度:
+
+- 桌面:页面宽度的 70% ~ 80%
+- 小屏:全屏
+
+不建议用普通弹窗,因为:
+
+- Excel 差异需要足够展示宽度
+- 版本选择器、摘要、表格都要共存
+
+## 8.3 顶部区域
+
+```text
+版本对比
+
+基准版本 [v1.0.5 已上线 ▼]
+对比版本 [v1.0.6 待审核 ▼]
+```
+
+默认值:
+
+- `baseVersion = published_version`
+- `targetVersion = working_version`
+
+## 8.4 差异摘要
+
+优先先给决策信息,再给底层明细。
+
+```text
+差异摘要
+- 修改 2 个工作表
+- 新增 1 个工作表
+- 修改 12 个单元格
+- 删除 2 行
+```
+
+如果无差异:
+
+```text
+两个版本内容一致,没有发现表格差异。
+```
+
+## 8.5 差异详情
+
+第一阶段优先支持 Excel 规则表:
+
+| 工作表 | 位置 | 旧值 | 新值 | 类型 |
+| --- | --- | --- | --- | --- |
+| 出差补助标准 | B4 | 75 | 90 | 修改 |
+| 差旅住宿费标准 | A106 | - | 新增城市 | 新增 |
+
+后续可扩展:
+
+- 仅看新增
+- 仅看删除
+- 仅看数值变化
+- 按工作表筛选
+
+## 8.6 对比结果的业务语气
+
+不要把页面做成“程序员 diff 工具”。
+它应该像制度审核页面:
+
+- 先讲影响
+- 再讲位置
+- 最后给证据
+
+---
+
+## 9. 数据接口设计
+
+## 9.1 时间线接口
+
+建议新增:
+
+```http
+GET /agent-assets/{asset_id}/version-timeline
+```
+
+返回:
+
+- 版本号
+- 事件类型
+- 操作人
+- 操作时间
+- 审核意见
+- 来源版本(如有)
+
+## 9.2 对比接口
+
+建议新增:
+
+```http
+GET /agent-assets/{asset_id}/versions/compare?base_version=v1.0.5&target_version=v1.0.6
+```
+
+返回:
+
+- 基准版本
+- 对比版本
+- 工作表差异摘要
+- 单元格级差异明细
+
+---
+
+## 10. 视觉规范
+
+### 10.1 颜色
+
+沿用当前系统已有色彩,不引入新风格:
+
+| 状态 | 建议色 |
+| --- | --- |
+| 已上线 | 绿色 |
+| 工作稿 | 蓝色 |
+| 待审核 | 橙色 |
+| 已驳回 | 红色 |
+| 历史版本 | 灰色 |
+
+### 10.2 密度
+
+- 右侧版本中心应为紧凑型信息面板
+- 不要使用过大的卡片间距
+- 不能明显压缩 OnlyOffice 主区域
+
+### 10.3 交互反馈
+
+- 可点击元素必须有 hover
+- 当前预览版本必须有 active 高亮
+- 抽屉打开后保留明确关闭按钮
+- 恢复操作必须二次确认
+
+---
+
+## 11. 推荐实施顺序
+
+### 第一阶段
+
+1. 顶部新增 `版本对比`、`查看流转`
+2. Excel 详情页改成:
+ - 左侧 OnlyOffice
+ - 右侧版本中心
+3. 右侧展示:
+ - 线上版本
+ - 工作版本
+ - 最近 5 个版本
+ - 最近 3 条流转
+
+### 第二阶段
+
+1. 实现版本流转抽屉
+2. 实现版本对比抽屉
+3. 补齐真实后端接口
+
+### 第三阶段
+
+1. 增加更细的工作表筛选
+2. 增加更多 diff 维度
+3. 增加版本差异导出能力
+
+---
+
+## 12. 本次开发目标
+
+本次开发直接完成以下内容:
+
+1. 规则详情页出现明确的版本中心
+2. 页面上出现明确的:
+ - `版本对比`
+ - `查看流转`
+3. 最近版本列表增加:
+ - `查看`
+ - `与线上比`
+ - `恢复为工作稿`
+4. 版本流转抽屉可用
+5. 版本对比抽屉可用
+6. 对比结果至少支持 Excel 表格的:
+ - 工作表新增 / 删除
+ - 单元格新增 / 删除 / 修改
+
diff --git a/document/development/rules/rule-version-governance-plan.md b/document/development/rules/rule-version-governance-plan.md
new file mode 100644
index 0000000..c196b98
--- /dev/null
+++ b/document/development/rules/rule-version-governance-plan.md
@@ -0,0 +1,237 @@
+# 规则版本治理方案
+
+## 1. 背景
+
+当前“任务规则中心”的规则资产只有一个 `current_version` 指针。
+它同时承担了三种含义:
+
+1. 财务人员正在编辑的版本
+2. 审核中的候选版本
+3. 系统运行时真正生效的线上版本
+
+这会直接带来三个问题:
+
+- 财务人员一旦修改 Excel,最新内容就会立刻成为 `current_version`,容易被误解为已经正式生效
+- 审核、上线、回滚都围绕同一个指针转,权限边界不清晰
+- 如果误上线,虽然能切换历史版本,但缺少“线上版本”和“工作版本”分离后的安全缓冲
+
+## 2. 设计目标
+
+这次改造要解决的不是“多存几个历史版本”,而是建立一套可长期使用的规则治理机制:
+
+1. 财务人员可以编辑规则,但编辑结果默认只是草稿
+2. 只有高级管理人员审核通过后,规则才能成为正式线上版本
+3. 系统运行时只能读取正式线上版本,不能读取草稿
+4. 前台要能清楚区分:
+ - 当前线上版本
+ - 当前工作版本
+ - 最近 5 个历史版本
+5. 如果误操作上线,可以快速恢复,但恢复动作仍然要留下完整审计链
+
+## 3. 核心模型
+
+### 3.1 双指针版本模型
+
+在规则资产上新增两个版本指针:
+
+| 字段 | 含义 |
+| --- | --- |
+| `published_version` | 当前正式在线上生效的版本 |
+| `working_version` | 当前最新的工作稿 / 待审稿 |
+
+兼容策略:
+
+- 现有 `current_version` 暂时保留,用于兼容历史代码
+- 对规则资产来说,后续它只承担“当前工作版本”的兼容语义
+- 运行时逻辑不再读取 `current_version`,而是优先读取 `published_version`
+
+### 3.2 版本状态
+
+不额外在版本表中硬存一套容易失真的状态,而是根据“版本指针 + 最新审核记录”动态推导:
+
+| 条件 | 版本状态 |
+| --- | --- |
+| `version == published_version` | 已上线 |
+| `version == working_version` 且无审核记录 | 草稿 |
+| `version == working_version` 且最新审核为 `pending` | 待审核 |
+| `version == working_version` 且最新审核为 `approved` | 已通过待上线 |
+| `version == working_version` 且最新审核为 `rejected` | 已驳回 |
+| 其他历史版本 | 历史版本 |
+
+这样可以避免“版本状态”和“审核记录”两套数据互相打架。
+
+## 4. 权限边界
+
+| 角色 | 能力 |
+| --- | --- |
+| 财务人员 `finance` | 编辑工作稿、上传/导入 Excel、提交审核 |
+| 高级管理人员 `manager` / `admin` | 审核通过、驳回、正式发布、恢复历史版本 |
+| 其他普通员工 | 只读 |
+
+### 4.1 财务人员
+
+- 可以直接编辑当前 `working_version`
+- 每次保存自动生成新版本,并把它设为新的 `working_version`
+- 不能把草稿直接变成 `published_version`
+
+### 4.2 高级管理人员
+
+- 可以对 `working_version` 发起:
+ - 审核通过
+ - 驳回
+ - 正式发布
+- 只有 `approved` 的工作版本才能发布
+
+## 5. 发布与回滚流程
+
+### 5.1 正常发布
+
+1. 财务人员编辑并保存
+2. 系统生成新版本,例如 `v1.0.6`
+3. `working_version = v1.0.6`
+4. 财务人员提交审核
+5. 高级管理人员审核通过
+6. 高级管理人员点击“正式上线”
+7. `published_version = v1.0.6`
+8. 系统运行时切换到新版本
+
+### 5.2 驳回
+
+1. 财务人员提交审核
+2. 高级管理人员驳回
+3. 当前工作版本保留,但状态显示为“已驳回”
+4. 财务人员继续编辑,形成新的工作版本
+
+### 5.3 恢复历史版本
+
+不直接把 `published_version` 指回旧版本,而是采用“复制恢复”的方式:
+
+1. 管理员在最近 5 个版本中选择一个历史版本
+2. 系统基于该历史版本内容生成一个新的恢复版本,例如 `v1.0.7`
+3. 新版本写入 `working_version`
+4. 审核通过后再正式发布
+
+这么做的好处:
+
+- 不会破坏历史链路
+- 每一次恢复都有明确的责任人与时间
+- 既能快速回滚,又保留审计闭环
+
+## 6. 版本保留策略
+
+### 6.1 前台展示
+
+- 详情页固定展示最近 5 个版本
+- 每个版本显示:
+ - 版本号
+ - 状态
+ - 创建人
+ - 创建时间
+ - 变更说明
+
+### 6.2 后台保存
+
+后台不能机械地“只保留 5 个版本”,否则可能把关键线上版本挤掉。
+建议策略:
+
+1. 始终保留当前 `published_version`
+2. 始终保留当前 `working_version`
+3. 额外保留最近 5 个历史版本
+
+这样既满足前台简洁,也能避免误删关键版本。
+
+## 7. 前端交互
+
+### 7.1 规则详情页顶部
+
+展示两个醒目的版本标签:
+
+- 当前线上版本
+- 当前工作版本
+
+如果两者不同,需要明确提示:
+
+> 当前存在尚未上线的工作稿,系统运行仍以线上版本为准。
+
+### 7.2 编辑区
+
+- 财务人员看到“可编辑工作稿”
+- 普通用户只读
+- 管理员可编辑,但主要职责仍是审核与发布
+
+### 7.3 版本区
+
+最近 5 个版本中每条都显示状态:
+
+- 已上线
+- 草稿
+- 待审核
+- 已通过待上线
+- 已驳回
+- 历史版本
+
+可执行操作:
+
+- 查看
+- 基于该版本恢复
+- 对当前工作版本提交审核 / 审核 / 发布
+
+## 8. 后端改造清单
+
+1. `agent_assets`
+ - 新增 `published_version`
+ - 新增 `working_version`
+2. 兼容旧数据
+ - 历史规则资产初始化时:
+ - `published_version = current_version`
+ - `working_version = current_version`
+3. 版本保存
+ - 保存新版本后:
+ - 只更新 `working_version`
+ - `current_version` 同步为 `working_version` 以兼容旧逻辑
+4. 审核
+ - 审核只针对 `working_version`
+5. 发布
+ - 只允许把已审核通过的 `working_version` 推到 `published_version`
+6. 运行时
+ - 只读取 `published_version`
+7. 回滚
+ - 新增“基于历史版本恢复为新工作稿”的接口
+
+## 9. 前端改造清单
+
+1. 资产详情模型增加:
+ - `publishedVersion`
+ - `workingVersion`
+ - 每个历史版本的派生状态
+2. 规则详情页展示:
+ - 当前线上版本
+ - 当前工作版本
+ - 最近 5 个版本
+3. 操作权限拆分:
+ - finance:编辑、上传、提交审核
+ - manager/admin:审核、上线、恢复
+4. OnlyOffice 编辑逻辑:
+ - 默认编辑工作版本
+ - 历史版本只读
+5. 正式上线按钮:
+ - 只有工作版本已审核通过时可用
+
+## 10. 本次实现边界
+
+本轮优先完成以下能力:
+
+1. 规则版本双指针
+2. 财务角色可编辑工作稿
+3. 正式上线只切换 `published_version`
+4. 运行时只读取 `published_version`
+5. 最近 5 个版本展示
+6. 基于历史版本快速恢复为新工作稿
+
+后续如需要,再继续补:
+
+- 版本差异对比
+- 审核意见流转面板
+- 发布说明 / 审批单号
+- 定时生效
+
diff --git a/server/pyproject.toml b/server/pyproject.toml
index 239a768..4ffe96f 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -18,8 +18,9 @@ dependencies = [
"pydantic-settings>=2.6.0,<3.0.0",
"python-dotenv>=1.0.1,<2.0.0",
"email-validator>=2.2.0,<3.0.0",
- "python-multipart>=0.0.20,<1.0.0",
- "lightrag-hku>=1.4.16,<1.5.0",
+ "python-multipart>=0.0.20,<1.0.0",
+ "openpyxl>=3.1.5,<4.0.0",
+ "lightrag-hku>=1.4.16,<1.5.0",
"qdrant-client>=1.18.0,<2.0.0",
]
diff --git a/server/rules/finance-rules/公司差旅费报销规则.xlsx b/server/rules/finance-rules/公司差旅费报销规则.xlsx
new file mode 100644
index 0000000..57a7033
Binary files /dev/null and b/server/rules/finance-rules/公司差旅费报销规则.xlsx differ
diff --git a/server/rules/finance-rules/远光软件2026费用报销说明手册.pdf b/server/rules/finance-rules/远光软件2026费用报销说明手册.pdf
new file mode 100644
index 0000000..a603a6e
Binary files /dev/null and b/server/rules/finance-rules/远光软件2026费用报销说明手册.pdf differ
diff --git a/server/scripts/build_company_travel_default_workbook.py b/server/scripts/build_company_travel_default_workbook.py
new file mode 100644
index 0000000..d8ec6ca
--- /dev/null
+++ b/server/scripts/build_company_travel_default_workbook.py
@@ -0,0 +1,259 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from openpyxl import Workbook
+from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
+from openpyxl.utils import get_column_letter
+
+OUTPUT_PATH = Path(__file__).resolve().parents[1] / "rules" / "finance-rules" / "公司差旅费报销规则.xlsx"
+
+ROWS = [
+ (1, "北京", "北京", "", "", 500, 450, 450, 500, ""),
+ (2, "天津", "6 个中心城区、滨海新区、东丽区、西青区、津南区、北辰区、武清区、宝坻区、静海区、蓟县", "", "", 380, 360, 350, 380, ""),
+ (2, "天津", "宁河区", "", "", 320, 300, 280, 320, ""),
+ (3, "河北", "石家庄", "", "", 350, 330, 300, 350, ""),
+ (3, "河北", "张家口、秦皇岛、廊坊、承德、保定", "张家口市;秦皇岛市;承德市", "张家口市:7-9 月、11-3 月;秦皇岛市:7-8 月;承德市:7-9 月", 350, 300, 250, 350, 420),
+ (3, "河北", "雄安新区(不含雄县、安新县、容城县)", "", "", 450, 400, 350, 450, ""),
+ (3, "河北", "其他地区", "", "", 310, 290, 250, 310, ""),
+ (4, "山西", "太原", "", "", 350, 330, 300, 350, ""),
+ (4, "山西", "大同、晋城", "", "", 350, 300, 250, 350, ""),
+ (4, "山西", "临汾", "", "", 330, 300, 250, 330, ""),
+ (4, "山西", "阳泉、长治、晋中", "", "", 310, 290, 250, 310, ""),
+ (4, "山西", "其他地区", "", "", 240, 220, 200, 240, ""),
+ (5, "内蒙古", "呼和浩特", "", "", 350, 330, 300, 350, ""),
+ (5, "内蒙古", "海拉尔市、满洲里市、阿尔山市", "海拉尔市、满洲里市、阿尔山市", "7-9 月", 320, 300, 250, 320, 380),
+ (5, "内蒙古", "二连浩特市", "二连浩特市", "7-9 月", 320, 300, 250, 320, 380),
+ (5, "内蒙古", "额济纳市", "额济纳市", "9-10 月", 320, 300, 250, 320, 380),
+ (5, "内蒙古", "其他地区", "", "", 320, 300, 250, 320, ""),
+ (6, "辽宁", "沈阳", "", "", 350, 330, 300, 350, ""),
+ (6, "辽宁", "其他地区", "", "", 330, 300, 250, 330, ""),
+ (7, "大连", "大连", "大连", "7-9 月", 350, 300, 300, 350, 420),
+ (8, "吉林", "长春", "长春", "7-9 月", 350, 330, 300, 350, 420),
+ (8, "吉林", "吉林、延边州、长白山管理区", "吉林、延边州、长白山管理区", "7-9 月", 350, 300, 250, 350, 420),
+ (8, "吉林", "其他地区", "", "", 300, 280, 250, 300, ""),
+ (9, "黑龙江", "哈尔滨", "哈尔滨", "7-9 月", 350, 330, 300, 350, 420),
+ (9, "黑龙江", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "6-8 月", 300, 280, 250, 300, 360),
+ (9, "黑龙江", "其他地区", "", "", 300, 280, 250, 300, ""),
+ (10, "上海", "上海", "", "", 500, 450, 450, 500, ""),
+ (11, "江苏", "南京", "", "", 380, 350, 350, 380, ""),
+ (11, "江苏", "苏州、无锡、常州、镇江", "", "", 350, 300, 250, 380, ""),
+ (11, "江苏", "其他地区", "", "", 350, 300, 250, 360, ""),
+ (12, "浙江", "杭州", "", "", 400, 350, 350, 400, ""),
+ (12, "浙江", "其他地区", "", "", 340, 300, 250, 340, ""),
+ (13, "宁波", "宁波", "", "", 350, 300, 250, 350, ""),
+ (14, "安徽", "合肥", "", "", 350, 330, 300, 350, ""),
+ (14, "安徽", "其他地区", "", "", 350, 300, 250, 350, ""),
+ (15, "福建", "福州", "", "", 380, 350, 300, 380, ""),
+ (15, "福建", "泉州、平潭综合实验区", "", "", 350, 300, 250, 380, ""),
+ (15, "福建", "其他地区", "", "", 350, 300, 250, 350, ""),
+ (16, "厦门", "厦门", "", "", 400, 380, 350, 400, ""),
+ (17, "江西", "南昌", "", "", 350, 330, 300, 350, ""),
+ (17, "江西", "其他地区", "", "", 350, 300, 250, 350, ""),
+ (18, "山东", "济南", "", "", 380, 350, 300, 380, ""),
+ (18, "山东", "烟台、威海、日照", "烟台、威海、日照", "7-9 月", 350, 300, 250, 380, 450),
+ (18, "山东", "淄博、枣庄、东营、潍坊、济宁、泰安", "", "", 350, 300, 250, 380, ""),
+ (18, "山东", "其他地区", "", "", 350, 300, 250, 360, ""),
+ (19, "青岛", "青岛", "青岛", "7-9 月", 350, 300, 250, 380, 450),
+ (20, "河南", "郑州", "", "", 380, 350, 300, 380, ""),
+ (20, "河南", "洛阳", "洛阳", "4-5 月上旬", 330, 300, 250, 330, 390),
+ (20, "河南", "其他地区", "", "", 330, 300, 250, 330, ""),
+ (21, "湖北", "武汉", "", "", 350, 330, 300, 350, ""),
+ (21, "湖北", "其他地区", "", "", 320, 300, 250, 320, ""),
+ (22, "湖南", "长沙", "", "", 350, 330, 300, 350, ""),
+ (22, "湖南", "其他地区", "", "", 330, 300, 250, 330, ""),
+ (23, "广东", "广州", "", "", 450, 400, 400, 450, ""),
+ (23, "广东", "珠海", "", "", 450, 400, 350, 450, ""),
+ (23, "广东", "佛山、东莞、中山、江门", "", "", 350, 300, 250, 450, ""),
+ (23, "广东", "其他地区", "", "", 350, 300, 250, 420, ""),
+ (24, "深圳", "深圳", "", "", 450, 400, 400, 450, ""),
+ (25, "广西", "南宁", "", "", 350, 330, 300, 350, ""),
+ (25, "广西", "桂林、北海", "桂林、北海", "1-2 月、7-9 月", 330, 300, 250, 330, 390),
+ (25, "广西", "其他地区", "", "", 330, 300, 250, 330, ""),
+ (26, "海南", "海口、文昌、澄迈县", "海口、文昌、澄迈县", "11-2 月", 350, 330, 310, 350, 420),
+ (26, "海南", "琼海、万宁、陵水县、保亭县", "琼海、万宁、陵水县、保亭县", "11-3 月", 350, 330, 310, 350, 420),
+ (26, "海南", "三沙、儋州、五指山、东方、安定县、屯昌县、临高县、白沙县、昌江县、乐东县、琼中县、洋浦开发区", "", "", 350, 330, 310, 350, ""),
+ (26, "海南", "三亚", "三亚", "10-4 月", 400, 380, 350, 400, 480),
+ (27, "重庆", "9 个中心城区、北部新区", "", "", 370, 350, 330, 370, ""),
+ (27, "重庆", "其他地区", "", "", 300, 280, 260, 300, ""),
+ (28, "四川", "成都", "", "", 370, 350, 330, 370, ""),
+ (28, "四川", "阿坝州、甘孜州", "", "", 330, 300, 250, 330, ""),
+ (28, "四川", "绵阳、乐山、雅安", "", "", 320, 300, 250, 320, ""),
+ (28, "四川", "宜宾", "", "", 300, 280, 250, 300, ""),
+ (28, "四川", "凉山州", "", "", 330, 300, 250, 330, ""),
+ (28, "四川", "德阳、遂宁、巴中", "", "", 310, 290, 250, 310, ""),
+ (28, "四川", "其他地区", "", "", 300, 280, 250, 300, ""),
+ (29, "贵州", "贵阳", "", "", 370, 350, 300, 370, ""),
+ (29, "贵州", "其他地区", "", "", 300, 280, 250, 300, ""),
+ (30, "云南", "昆明", "", "", 380, 350, 300, 380, ""),
+ (30, "云南", "大理州、丽江市、迪庆州、西双版纳州", "", "", 350, 300, 250, 380, ""),
+ (30, "云南", "其他地区", "", "", 330, 300, 250, 330, ""),
+ (31, "西藏", "拉萨", "拉萨", "5-10 月", 350, 330, 300, 350, 420),
+ (31, "西藏", "其他地区", "其他地区", "5-10 月", 300, 280, 250, 300, 360),
+ (32, "陕西", "西安", "", "", 350, 330, 300, 350, ""),
+ (32, "陕西", "榆林、延安", "", "", 300, 280, 250, 300, ""),
+ (32, "陕西", "杨凌区", "", "", 260, 240, 220, 260, ""),
+ (32, "陕西", "咸阳、宝鸡", "", "", 260, 240, 220, 260, ""),
+ (32, "陕西", "渭南、韩城", "", "", 260, 240, 220, 260, ""),
+ (32, "陕西", "其他地区", "", "", 230, 210, 200, 230, ""),
+ (33, "甘肃", "兰州", "", "", 350, 330, 300, 350, ""),
+ (33, "甘肃", "其他地区", "", "", 310, 290, 250, 310, ""),
+ (34, "青海", "西宁", "西宁", "6-9 月", 350, 330, 300, 350, 420),
+ (34, "青海", "玉树州", "玉树州", "5-9 月", 300, 280, 250, 300, 360),
+ (34, "青海", "果洛州", "", "", 300, 280, 250, 300, ""),
+ (34, "青海", "海北州、黄南州", "海北州、黄南州", "5-9 月", 250, 230, 210, 250, 300),
+ (34, "青海", "海东、海南州", "海东、海南州", "5-9 月", 250, 230, 210, 250, 300),
+ (34, "青海", "海西州", "海西州", "5-9 月", 200, 200, 200, 200, 240),
+ (35, "宁夏", "银川", "", "", 350, 330, 300, 350, ""),
+ (35, "宁夏", "其他地区", "", "", 330, 300, 250, 330, ""),
+ (36, "新疆", "乌鲁木齐", "", "", 350, 330, 300, 350, ""),
+ (36, "新疆", "石河子、克拉玛依、昌吉州、伊犁州、阿勒泰地区、博州、吐鲁番、哈密地区、巴州、和田地区", "", "", 340, 300, 250, 340, ""),
+ (36, "新疆", "克州", "", "", 320, 300, 250, 320, ""),
+ (36, "新疆", "喀什地区", "", "", 300, 280, 250, 300, ""),
+ (36, "新疆", "阿克苏地区", "", "", 300, 280, 250, 300, ""),
+ (36, "新疆", "塔城地区", "", "", 300, 280, 250, 300, ""),
+ (37, "港澳台", "香港、澳门、台湾", "", "", 450, 400, 350, 500, ""),
+ (38, "国外", "国外", "", "", 700, 600, 500, 700, ""),
+]
+
+
+def build_workbook() -> Workbook:
+ workbook = Workbook()
+ worksheet = workbook.active
+ worksheet.title = "差旅住宿费标准"
+ headers = [
+ "序号",
+ "地区",
+ "地区(城市)",
+ "旺季地区",
+ "旺季期间",
+ "公司级管理人员、高层经理(P7及以上)",
+ "中层经理、基层经理(P4-P6、外聘专家)",
+ "其他员工",
+ "超标限额",
+ "旺季超标限额",
+ ]
+
+ worksheet.append(["差旅住宿费标准"])
+ worksheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
+ worksheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
+ worksheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
+ worksheet["A1"].alignment = Alignment(horizontal="center")
+ worksheet.append(headers)
+ for row in ROWS:
+ worksheet.append(row)
+
+ header_fill = PatternFill("solid", fgColor="D9EAF7")
+ thin = Side(style="thin", color="B7C9D6")
+ for cell in worksheet[2]:
+ cell.font = Font(bold=True)
+ cell.fill = header_fill
+ cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+ cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
+ for row in worksheet.iter_rows(min_row=3, max_row=worksheet.max_row):
+ for cell in row:
+ cell.alignment = Alignment(vertical="center", wrap_text=True)
+ cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
+ for cell in row[5:]:
+ cell.alignment = Alignment(horizontal="center", vertical="center")
+
+ worksheet.freeze_panes = "A3"
+ worksheet.auto_filter.ref = f"A2:J{worksheet.max_row}"
+ widths = [8, 12, 42, 28, 28, 22, 26, 12, 12, 14]
+ for index, width in enumerate(widths, start=1):
+ worksheet.column_dimensions[get_column_letter(index)].width = width
+ worksheet.row_dimensions[1].height = 26
+ worksheet.row_dimensions[2].height = 42
+ for index in range(3, worksheet.max_row + 1):
+ worksheet.row_dimensions[index].height = 36
+
+ subsidy_sheet = workbook.create_sheet("出差补助标准")
+ subsidy_headers = [
+ "补助类型",
+ "项目",
+ "港澳台",
+ "直辖市/特区",
+ "西藏",
+ "新疆-乌鲁木齐",
+ "新疆-其他",
+ "其他地区",
+ "国外",
+ ]
+ subsidy_rows = [
+ ("伙食补助", "自行解决餐食", 75, 65, 65, 55, 55, 55, 140),
+ ("基本补助", "基本出差补贴", 35, 35, 105, 75, 135, 35, 35),
+ ("合计", "", 110, 100, 170, 130, 190, 90, 175),
+ ]
+ subsidy_sheet.append(["出差补助标准"])
+ subsidy_sheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(subsidy_headers))
+ subsidy_sheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
+ subsidy_sheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
+ subsidy_sheet["A1"].alignment = Alignment(horizontal="center")
+ subsidy_sheet.append(subsidy_headers)
+ for row in subsidy_rows:
+ subsidy_sheet.append(row)
+ subsidy_sheet.append(["备注", "注 1:新疆分公司同事出差至乌鲁木齐外的其他新疆地区,基本补助标准为 95 元。"])
+ subsidy_sheet.append(["备注", "注 2:西藏分公司同事出差至拉萨市外的其他西藏地区,基本补助标准为 35 元。"])
+ subsidy_sheet.merge_cells(start_row=6, start_column=2, end_row=6, end_column=len(subsidy_headers))
+ subsidy_sheet.merge_cells(start_row=7, start_column=2, end_row=7, end_column=len(subsidy_headers))
+ for cell in subsidy_sheet[2]:
+ cell.font = Font(bold=True)
+ cell.fill = header_fill
+ cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+ cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
+ for row in subsidy_sheet.iter_rows(min_row=3, max_row=7):
+ for cell in row:
+ cell.alignment = Alignment(vertical="center", wrap_text=True)
+ cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
+ for cell in subsidy_sheet[5]:
+ cell.font = Font(bold=True)
+ cell.fill = PatternFill("solid", fgColor="E2F0D9")
+ subsidy_sheet.freeze_panes = "A3"
+ subsidy_sheet.auto_filter.ref = "A2:I5"
+ subsidy_widths = [14, 18, 12, 16, 12, 18, 16, 14, 12]
+ for index, width in enumerate(subsidy_widths, start=1):
+ subsidy_sheet.column_dimensions[get_column_letter(index)].width = width
+ subsidy_sheet.row_dimensions[1].height = 26
+ subsidy_sheet.row_dimensions[2].height = 36
+ for index in range(3, 8):
+ subsidy_sheet.row_dimensions[index].height = 28
+
+ source_sheet = workbook.create_sheet("来源说明")
+ source_sheet.append(["来源文件", "页码", "说明"])
+ source_sheet.append(
+ [
+ "远光软件2026费用报销说明手册.pdf",
+ "第 13-19 页",
+ "依据 PDF 附件 3《差旅住宿费标准》整理为默认支撑表。",
+ ]
+ )
+ source_sheet.append(
+ [
+ "远光软件2026费用报销说明手册.pdf",
+ "第 20 页",
+ "依据 PDF 附件 4《出差补助标准》整理为默认支撑表。",
+ ]
+ )
+ for row in source_sheet.iter_rows():
+ for cell in row:
+ cell.alignment = Alignment(wrap_text=True, vertical="center")
+ cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
+ for cell in source_sheet[1]:
+ cell.font = Font(bold=True)
+ cell.fill = header_fill
+ source_sheet.column_dimensions["A"].width = 34
+ source_sheet.column_dimensions["B"].width = 14
+ source_sheet.column_dimensions["C"].width = 56
+ return workbook
+
+
+def main() -> None:
+ OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
+ workbook = build_workbook()
+ workbook.save(OUTPUT_PATH)
+ print(OUTPUT_PATH)
+ print(f"rows={len(ROWS)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/server_start.sh b/server/server_start.sh
index a796171..6696113 100755
--- a/server/server_start.sh
+++ b/server/server_start.sh
@@ -240,7 +240,7 @@ run_bootstrap_python() {
}
dependencies_ready() {
- "$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
+ "$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, openpyxl, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
}
pip_ready() {
diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py
index 9e9de14..7036be4 100644
--- a/server/src/app/api/deps.py
+++ b/server/src/app/api/deps.py
@@ -62,13 +62,39 @@ def get_current_user(
)
-def require_admin_user(
- current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
-) -> CurrentUserContext:
+def require_admin_user(
+ current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
+) -> CurrentUserContext:
if current_user.is_admin or "manager" in current_user.role_codes:
return current_user
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="只有管理员可以上传、删除或修改知识库文件。",
- )
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="只有管理员可以上传、删除或修改知识库文件。",
+ )
+
+
+def require_rule_editor_user(
+ current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
+) -> CurrentUserContext:
+ role_codes = {item.strip() for item in current_user.role_codes}
+ if current_user.is_admin or "manager" in role_codes or "finance" in role_codes:
+ return current_user
+
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="只有财务人员或高级管理人员可以编辑规则草稿。",
+ )
+
+
+def require_rule_reviewer_user(
+ current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
+) -> CurrentUserContext:
+ role_codes = {item.strip() for item in current_user.role_codes}
+ if current_user.is_admin or "manager" in role_codes:
+ return current_user
+
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="只有高级管理人员可以审核、发布或恢复正式规则。",
+ )
diff --git a/server/src/app/repositories/agent_run.py b/server/src/app/repositories/agent_run.py
index 2a4aa99..b1afdc7 100644
--- a/server/src/app/repositories/agent_run.py
+++ b/server/src/app/repositories/agent_run.py
@@ -50,6 +50,16 @@ class AgentRunRepository:
self.db.refresh(tool_call)
return tool_call
+ def get_tool_call(self, tool_call_id: str) -> AgentToolCall | None:
+ stmt = select(AgentToolCall).where(AgentToolCall.id == tool_call_id)
+ return self.db.scalar(stmt)
+
+ def save_tool_call(self, tool_call: AgentToolCall) -> AgentToolCall:
+ self.db.add(tool_call)
+ self.db.commit()
+ self.db.refresh(tool_call)
+ return tool_call
+
def create_semantic_parse(self, semantic_parse: SemanticParseLog) -> SemanticParseLog:
self.db.add(semantic_parse)
self.db.commit()
diff --git a/server/src/app/services/agent_asset_spreadsheet.py b/server/src/app/services/agent_asset_spreadsheet.py
new file mode 100644
index 0000000..4bc33eb
--- /dev/null
+++ b/server/src/app/services/agent_asset_spreadsheet.py
@@ -0,0 +1,478 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import mimetypes
+import re
+from dataclasses import asdict, dataclass
+from datetime import UTC, datetime
+from io import BytesIO
+from pathlib import Path
+from xml.sax.saxutils import escape
+from zipfile import ZIP_DEFLATED, ZipFile
+
+from openpyxl import load_workbook
+
+from app.core.config import SERVER_DIR, get_settings
+
+RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
+ r"```rule-spreadsheet\s*(\{.*?\})\s*```",
+ re.DOTALL,
+)
+
+COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
+COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
+FINANCE_RULES_LIBRARY = "finance-rules"
+RISK_RULES_LIBRARY = "risk-rules"
+RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
+SPREADSHEET_MIME_TYPE = (
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+)
+
+
+@dataclass(slots=True)
+class RuleSpreadsheetMeta:
+ file_name: str
+ storage_key: str
+ mime_type: str
+ size_bytes: int
+ checksum: str
+ updated_at: str
+ updated_by: str
+ source: str = "upload"
+
+
+class AgentAssetSpreadsheetManager:
+ def __init__(
+ self,
+ storage_root: Path | None = None,
+ rule_root: Path | None = None,
+ ) -> None:
+ settings = get_settings()
+ self.storage_root = Path(storage_root or settings.resolved_storage_root_dir).resolve()
+ self.asset_root = (self.storage_root / "agent_assets").resolve()
+ self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve()
+
+ def ensure_rule_library_dirs(self) -> None:
+ for library in sorted(RULE_LIBRARY_NAMES):
+ (self.rule_root / library).mkdir(parents=True, exist_ok=True)
+
+ def store_spreadsheet(
+ self,
+ *,
+ asset_id: str,
+ version: str,
+ file_name: str,
+ content: bytes,
+ actor_name: str,
+ source: str = "upload",
+ ) -> RuleSpreadsheetMeta:
+ normalized_name = Path(str(file_name or "").strip()).name.strip()
+ if not normalized_name:
+ raise ValueError("规则表文件名不能为空。")
+ if not content:
+ raise ValueError("规则表文件内容不能为空。")
+
+ relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name
+ target_path = (self.storage_root / relative_path).resolve()
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+ target_path.write_bytes(content)
+
+ mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
+ return RuleSpreadsheetMeta(
+ file_name=normalized_name,
+ storage_key=relative_path.as_posix(),
+ mime_type=mime_type,
+ size_bytes=len(content),
+ checksum=hashlib.sha256(content).hexdigest(),
+ updated_at=datetime.now(UTC).isoformat(),
+ updated_by=str(actor_name or "system").strip() or "system",
+ source=source,
+ )
+
+ def store_rule_library_spreadsheet(
+ self,
+ *,
+ library: str,
+ file_name: str,
+ content: bytes,
+ actor_name: str,
+ source: str = "rule-library",
+ ) -> RuleSpreadsheetMeta:
+ normalized_library = str(library or "").strip()
+ if normalized_library not in RULE_LIBRARY_NAMES:
+ raise ValueError("规则库目录不合法。")
+
+ normalized_name = Path(str(file_name or "").strip()).name.strip()
+ if not normalized_name:
+ raise ValueError("规则表文件名不能为空。")
+ if not content:
+ raise ValueError("规则表文件内容不能为空。")
+
+ self.ensure_rule_library_dirs()
+ relative_path = Path("rules") / normalized_library / normalized_name
+ target_path = (SERVER_DIR / relative_path).resolve()
+ try:
+ target_path.relative_to(self.rule_root)
+ except ValueError:
+ raise ValueError("规则库文件路径不合法。")
+
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+ target_path.write_bytes(content)
+
+ mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
+ return RuleSpreadsheetMeta(
+ file_name=normalized_name,
+ storage_key=relative_path.as_posix(),
+ mime_type=mime_type,
+ size_bytes=len(content),
+ checksum=hashlib.sha256(content).hexdigest(),
+ updated_at=datetime.now(UTC).isoformat(),
+ updated_by=str(actor_name or "system").strip() or "system",
+ source=source,
+ )
+
+ def resolve_storage_path(self, storage_key: str) -> Path:
+ normalized = Path(str(storage_key or "").strip())
+ if not normalized.parts:
+ raise FileNotFoundError("规则表文件不存在。")
+
+ if normalized.parts[0] == "rules":
+ resolved = (SERVER_DIR / normalized).resolve()
+ allowed_root = self.rule_root
+ else:
+ resolved = (self.storage_root / normalized).resolve()
+ allowed_root = self.storage_root
+
+ try:
+ resolved.relative_to(allowed_root)
+ except ValueError:
+ raise FileNotFoundError("规则表文件不存在。")
+ return resolved
+
+ @staticmethod
+ def parse_version_markdown(markdown: str) -> RuleSpreadsheetMeta | None:
+ match = RULE_SPREADSHEET_BLOCK_PATTERN.search(str(markdown or ""))
+ if match is None:
+ return None
+
+ try:
+ payload = json.loads(match.group(1))
+ except json.JSONDecodeError:
+ return None
+
+ if not isinstance(payload, dict):
+ return None
+
+ return RuleSpreadsheetMeta(
+ file_name=str(payload.get("file_name") or "").strip(),
+ storage_key=str(payload.get("storage_key") or "").strip(),
+ mime_type=str(payload.get("mime_type") or SPREADSHEET_MIME_TYPE).strip()
+ or SPREADSHEET_MIME_TYPE,
+ size_bytes=int(payload.get("size_bytes") or 0),
+ checksum=str(payload.get("checksum") or "").strip(),
+ updated_at=str(payload.get("updated_at") or "").strip(),
+ updated_by=str(payload.get("updated_by") or "system").strip() or "system",
+ source=str(payload.get("source") or "upload").strip() or "upload",
+ )
+
+ @staticmethod
+ def build_version_markdown(
+ *,
+ rule_name: str,
+ version: str,
+ metadata: RuleSpreadsheetMeta,
+ ) -> str:
+ sections = [
+ f"# {rule_name}",
+ "",
+ "## 规则载体",
+ "",
+ "- 详情类型:Excel 表格",
+ f"- 当前规则版本:`{version}`",
+ f"- 表格文件:`{metadata.file_name}`",
+ f"- 最近更新人:{metadata.updated_by}",
+ f"- 最近更新时间:{metadata.updated_at}",
+ "",
+ "## 使用说明",
+ "",
+ "- 管理员可直接在规则中心内联编辑 Excel 表格,并通过 ONLYOFFICE 回写新版本。",
+ "- 上传新的 Excel 文件后,会自动生成新的规则版本快照。",
+ "- 切换到历史版本时仅提供预览,不允许直接覆盖历史快照。",
+ "",
+ "```rule-spreadsheet",
+ json.dumps(asdict(metadata), ensure_ascii=False, indent=2),
+ "```",
+ ]
+ return "\n".join(sections)
+
+ @staticmethod
+ def build_rule_document_config(
+ metadata: RuleSpreadsheetMeta,
+ *,
+ asset_version: str,
+ ) -> dict[str, object]:
+ return {
+ "kind": "spreadsheet",
+ "file_name": metadata.file_name,
+ "mime_type": metadata.mime_type,
+ "size_bytes": metadata.size_bytes,
+ "checksum": metadata.checksum,
+ "updated_at": metadata.updated_at,
+ "updated_by": metadata.updated_by,
+ "source": metadata.source,
+ "asset_version": asset_version,
+ }
+
+ @staticmethod
+ def build_company_travel_rule_template() -> bytes:
+ standard_rows = [
+ ["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
+ ["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"],
+ ["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"],
+ ["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"],
+ ["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"],
+ ["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"],
+ ]
+ instruction_rows = [
+ ["字段", "填写说明"],
+ ["费用分类", "建议保持固定选项,避免审批口径漂移。"],
+ ["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
+ ["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
+ ["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
+ ["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
+ ["备注", "记录豁免条件、灰度口径或制度来源。"],
+ ["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
+ ]
+ return _build_xlsx_bytes(
+ [
+ ("差旅报销标准", standard_rows),
+ ("填表说明", instruction_rows),
+ ]
+ )
+
+ @staticmethod
+ def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
+ return _build_xlsx_bytes([(sheet_name, [[""]])])
+
+ @staticmethod
+ def rebuild_from_uploaded_content(content: bytes) -> bytes:
+ if not content:
+ raise ValueError("待导入的表格内容不能为空。")
+
+ try:
+ workbook = load_workbook(
+ filename=BytesIO(content),
+ read_only=True,
+ 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)
+
+
+def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
+ created_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
+ workbook_buffer = BytesIO()
+
+ with ZipFile(workbook_buffer, "w", ZIP_DEFLATED) as archive:
+ archive.writestr("[Content_Types].xml", _build_content_types_xml(sheets))
+ archive.writestr("_rels/.rels", _build_root_rels_xml())
+ archive.writestr("docProps/app.xml", _build_app_xml(sheets))
+ archive.writestr("docProps/core.xml", _build_core_xml(created_at))
+ archive.writestr("xl/workbook.xml", _build_workbook_xml(sheets))
+ archive.writestr("xl/_rels/workbook.xml.rels", _build_workbook_rels_xml(sheets))
+ archive.writestr("xl/styles.xml", _build_styles_xml())
+
+ for index, (_, rows) in enumerate(sheets, start=1):
+ archive.writestr(
+ f"xl/worksheets/sheet{index}.xml",
+ _build_sheet_xml(rows),
+ )
+
+ return workbook_buffer.getvalue()
+
+
+def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
+ overrides = [
+ '',
+ '',
+ '',
+ '',
+ ]
+ overrides.extend(
+ [
+ f''
+ for index, _ in enumerate(sheets, start=1)
+ ]
+ )
+ return (
+ ''
+ ''
+ ''
+ ''
+ f'{"".join(overrides)}'
+ ""
+ )
+
+
+def _build_root_rels_xml() -> str:
+ return (
+ ''
+ ''
+ ''
+ ''
+ ''
+ ""
+ )
+
+
+def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
+ titles = "".join(
+ [f'{escape(name)}' for name, _ in sheets]
+ )
+ sheet_count = len(sheets)
+ return (
+ ''
+ ''
+ 'Microsoft Excel'
+ f"Worksheets{sheet_count}"
+ f"{titles}"
+ ""
+ )
+
+
+def _build_core_xml(created_at: str) -> str:
+ return (
+ ''
+ ''
+ "X-Financial"
+ "X-Financial"
+ f'{created_at}'
+ f'{created_at}'
+ ""
+ )
+
+
+def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
+ sheet_items = "".join(
+ [
+ f''
+ for index, (name, _) in enumerate(sheets, start=1)
+ ]
+ )
+ return (
+ ''
+ ''
+ ""
+ f"{sheet_items}"
+ ""
+ )
+
+
+def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
+ relationships = "".join(
+ [
+ f''
+ for index, _ in enumerate(sheets, start=1)
+ ]
+ )
+ relationships += (
+ f''
+ )
+ return (
+ ''
+ ''
+ f"{relationships}"
+ ""
+ )
+
+
+def _build_styles_xml() -> str:
+ return (
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+
+
+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)
+ worksheet_rows: list[str] = []
+
+ for row_index, row in enumerate(normalized_rows, start=1):
+ cells: list[str] = []
+ for column_index, cell in enumerate(row, start=1):
+ ref = f"{_column_letter(column_index)}{row_index}"
+ text = "" if cell is None else str(cell)
+ preserve = ' xml:space="preserve"' if text.strip() != text or "\n" in text else ""
+ cells.append(
+ f'{escape(text)}'
+ )
+ worksheet_rows.append(f'{"".join(cells)}
')
+
+ dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
+ return (
+ ''
+ ''
+ f''
+ ""
+ ""
+ f"{''.join(worksheet_rows)}"
+ ""
+ )
+
+
+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
+
+
+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]):
+ normalized_rows.pop()
+
+ if not normalized_rows:
+ return [[""]]
+
+ max_column = 0
+ for row in normalized_rows:
+ for index, cell in enumerate(row, start=1):
+ if cell not in (None, ""):
+ max_column = max(max_column, index)
+
+ if max_column <= 0:
+ return [[""]]
+
+ return [row[:max_column] for row in normalized_rows]
diff --git a/server/src/app/services/agent_runs.py b/server/src/app/services/agent_runs.py
index 26795ca..bb5bff1 100644
--- a/server/src/app/services/agent_runs.py
+++ b/server/src/app/services/agent_runs.py
@@ -1,12 +1,12 @@
from __future__ import annotations
import uuid
-from datetime import UTC, datetime
+from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy.orm import Session
-from app.core.agent_enums import AgentPermissionLevel, AgentRunStatus
+from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunStatus
from app.core.logging import get_logger
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.repositories.agent_run import AgentRunRepository
@@ -15,6 +15,8 @@ from app.services.agent_foundation import AgentFoundationService
logger = get_logger("app.services.agent_runs")
+KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
+
class AgentRunService:
def __init__(self, db: Session) -> None:
@@ -30,11 +32,13 @@ class AgentRunService:
limit: int = 20,
) -> list[AgentRunRead]:
self._ensure_ready()
+ self._reconcile_stale_knowledge_index_runs()
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
return [self._serialize_run(item) for item in runs]
def get_run(self, run_id: str) -> AgentRunRead | None:
self._ensure_ready()
+ self._reconcile_stale_knowledge_index_runs(target_run_id=run_id)
run = self.repository.get_by_run_id(run_id)
if run is None:
return None
@@ -174,6 +178,35 @@ class AgentRunService:
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
return AgentToolCallRead.model_validate(created)
+ def update_tool_call(
+ self,
+ tool_call_id: str,
+ *,
+ request_json: dict[str, Any] | None = None,
+ response_json: dict[str, Any] | None = None,
+ status: str | None = None,
+ duration_ms: int | None = None,
+ error_message: str | None = None,
+ ) -> AgentToolCallRead:
+ self._ensure_ready()
+ tool_call = self.repository.get_tool_call(tool_call_id)
+ if tool_call is None:
+ raise LookupError("Tool call not found")
+
+ if request_json is not None:
+ tool_call.request_json = request_json
+ if response_json is not None:
+ tool_call.response_json = response_json
+ if status is not None:
+ tool_call.status = status
+ if duration_ms is not None:
+ tool_call.duration_ms = duration_ms
+ tool_call.error_message = error_message
+
+ updated = self.repository.save_tool_call(tool_call)
+ logger.info("Updated tool call id=%s status=%s", updated.id, updated.status)
+ return AgentToolCallRead.model_validate(updated)
+
def record_semantic_parse(
self,
*,
@@ -214,6 +247,73 @@ class AgentRunService:
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
+ def _reconcile_stale_knowledge_index_runs(self, *, target_run_id: str | None = None) -> None:
+ runs = self.repository.list(
+ agent=AgentName.HERMES.value,
+ status=AgentRunStatus.RUNNING.value,
+ limit=200,
+ )
+ now = datetime.now(UTC)
+
+ for run in runs:
+ if target_run_id is not None and run.run_id != target_run_id:
+ continue
+
+ route_json = dict(run.route_json or {})
+ if str(route_json.get("job_type") or "").strip() != "knowledge_index_sync":
+ continue
+
+ heartbeat_at = self._parse_heartbeat_time(
+ str(route_json.get("heartbeat_at") or "").strip()
+ )
+ last_seen_at = heartbeat_at or run.started_at
+ if last_seen_at.tzinfo is None:
+ last_seen_at = last_seen_at.replace(tzinfo=UTC)
+
+ if now - last_seen_at <= KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT:
+ continue
+
+ stale_document_ids = [
+ str(document_id).strip()
+ for document_id in list(route_json.get("requested_document_ids") or [])
+ if str(document_id).strip()
+ ]
+ if stale_document_ids:
+ from app.services.knowledge import (
+ KNOWLEDGE_INGEST_STATUS_FAILED,
+ KnowledgeService,
+ )
+
+ KnowledgeService(db=self.db).set_document_ingest_statuses(
+ stale_document_ids,
+ KNOWLEDGE_INGEST_STATUS_FAILED,
+ agent_run_id=run.run_id,
+ )
+
+ route_json.update(
+ {
+ "phase": "stale_failed",
+ "heartbeat_at": now.isoformat(),
+ }
+ )
+ run.route_json = route_json
+ run.status = AgentRunStatus.FAILED.value
+ run.result_summary = "知识归纳任务长时间无心跳,系统已自动标记失败。"
+ run.error_message = "Knowledge index heartbeat timed out."
+ run.finished_at = now
+ self.repository.save_run(run)
+ logger.warning("Marked stale knowledge index run as failed run_id=%s", run.run_id)
+
+ @staticmethod
+ def _parse_heartbeat_time(raw_value: str) -> datetime | None:
+ normalized = str(raw_value or "").strip()
+ if not normalized:
+ return None
+ try:
+ return datetime.fromisoformat(normalized)
+ except ValueError:
+ return None
+
@staticmethod
def _serialize_run(run: AgentRun) -> AgentRunRead:
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None
diff --git a/server/src/app/services/expense_rule_runtime.py b/server/src/app/services/expense_rule_runtime.py
index bc09740..a96a249 100644
--- a/server/src/app/services/expense_rule_runtime.py
+++ b/server/src/app/services/expense_rule_runtime.py
@@ -598,13 +598,13 @@ class ExpenseRuleRuntimeService:
return catalog
def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None:
- current_version = str(asset.current_version or "").strip()
- if not current_version:
+ published_version = str(asset.published_version or asset.current_version or "").strip()
+ if not published_version:
return None
return self.db.scalar(
select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset.id,
- AgentAssetVersion.version == current_version,
+ AgentAssetVersion.version == published_version,
)
)
diff --git a/server/src/x_financial_server.egg-info/PKG-INFO b/server/src/x_financial_server.egg-info/PKG-INFO
index 2f6bcad..c3fb74a 100644
--- a/server/src/x_financial_server.egg-info/PKG-INFO
+++ b/server/src/x_financial_server.egg-info/PKG-INFO
@@ -14,6 +14,7 @@ Requires-Dist: pydantic-settings<3.0.0,>=2.6.0
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
Requires-Dist: email-validator<3.0.0,>=2.2.0
Requires-Dist: python-multipart<1.0.0,>=0.0.20
+Requires-Dist: openpyxl<4.0.0,>=3.1.5
Requires-Dist: lightrag-hku<1.5.0,>=1.4.16
Requires-Dist: qdrant-client<2.0.0,>=1.18.0
Provides-Extra: dev
diff --git a/server/src/x_financial_server.egg-info/SOURCES.txt b/server/src/x_financial_server.egg-info/SOURCES.txt
index 3a0c711..1a0f5a3 100644
--- a/server/src/x_financial_server.egg-info/SOURCES.txt
+++ b/server/src/x_financial_server.egg-info/SOURCES.txt
@@ -76,6 +76,7 @@ src/app/schemas/settings.py
src/app/schemas/system_log.py
src/app/schemas/user_agent.py
src/app/services/__init__.py
+src/app/services/agent_asset_spreadsheet.py
src/app/services/agent_assets.py
src/app/services/agent_conversations.py
src/app/services/agent_foundation.py
@@ -90,7 +91,10 @@ src/app/services/expense_rule_runtime.py
src/app/services/hermes_sync.py
src/app/services/knowledge.py
src/app/services/knowledge_index_tasks.py
+src/app/services/knowledge_normalizer.py
src/app/services/knowledge_rag.py
+src/app/services/knowledge_scheduler.py
+src/app/services/knowledge_sync.py
src/app/services/model_connectivity.py
src/app/services/ocr.py
src/app/services/ontology.py
@@ -106,8 +110,11 @@ src/x_financial_server.egg-info/SOURCES.txt
src/x_financial_server.egg-info/dependency_links.txt
src/x_financial_server.egg-info/requires.txt
src/x_financial_server.egg-info/top_level.txt
+tests/test_agent_asset_onlyoffice_key.py
tests/test_agent_asset_service.py
+tests/test_agent_asset_spreadsheet_import.py
tests/test_agent_foundation_endpoints.py
+tests/test_agent_runs_service.py
tests/test_auth_service.py
tests/test_config_settings_reload.py
tests/test_document_intelligence.py
@@ -115,12 +122,16 @@ tests/test_employee_service.py
tests/test_env_file_precedence.py
tests/test_expense_claim_service.py
tests/test_imports.py
+tests/test_knowledge_normalizer.py
tests/test_knowledge_onlyoffice_config.py
+tests/test_knowledge_rag_service.py
+tests/test_knowledge_service.py
tests/test_ocr_endpoints.py
tests/test_ocr_service.py
tests/test_ontology_service.py
tests/test_openapi_schema.py
tests/test_reimbursement_endpoints.py
+tests/test_runtime_chat_service.py
tests/test_server_start_dependencies.py
tests/test_settings_persistence.py
tests/test_settings_service.py
diff --git a/server/src/x_financial_server.egg-info/requires.txt b/server/src/x_financial_server.egg-info/requires.txt
index aeb0502..7dbbbd2 100644
--- a/server/src/x_financial_server.egg-info/requires.txt
+++ b/server/src/x_financial_server.egg-info/requires.txt
@@ -8,6 +8,7 @@ pydantic-settings<3.0.0,>=2.6.0
python-dotenv<2.0.0,>=1.0.1
email-validator<3.0.0,>=2.2.0
python-multipart<1.0.0,>=0.0.20
+openpyxl<4.0.0,>=3.1.5
lightrag-hku<1.5.0,>=1.4.16
qdrant-client<2.0.0,>=1.18.0