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