feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

6
.gitignore vendored
View File

@@ -16,3 +16,9 @@ __pycache__/
server/.venv/ server/.venv/
server/.venv-ocr312 server/.venv-ocr312
server/.secrets/ server/.secrets/
server/logs/
server/storage/expense_claims/
server/storage/finance_reports/
test-results/
.codex-remote-attachments/
tmp-*.png

View File

@@ -32,7 +32,8 @@ services:
- > - >
apt-get update && apt-get update &&
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra && python3 python3-pip python3-venv fontconfig &&
if ! fc-match 'Noto Sans CJK SC' | grep -qi 'Noto'; then if ! timeout "${CJK_FONT_INSTALL_TIMEOUT_SECONDS:-45}" sh -lc 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra'; then printf '%s\n' '[WARN] CJK font installation timed out or failed; continuing startup without blocking the app.'; fi; fi &&
printf '%s\n' printf '%s\n'
'<?xml version="1.0"?>' '<?xml version="1.0"?>'
'<!DOCTYPE fontconfig SYSTEM "fonts.dtd">' '<!DOCTYPE fontconfig SYSTEM "fonts.dtd">'

View File

@@ -1,5 +1,11 @@
# 财务规则表补齐开发记录 # 财务规则表补齐开发记录
## 2026-06-05 口径调整
用户明确要求业务招待费超过 500 元、大额办公用品以及金额超过 2000 元的费用申请审批要求进入财务规则中心。因此新增《公司费用申请审批规则》作为申请前置和审批阈值的财务规则依据;风险规则负责引用该财务规则并执行命中判断。
本次调整不恢复旧的单项《业务招待费报销规则》或《办公用品费报销规则》,而是使用统一规则表维护申请审批阈值,避免规则中心再次出现多个口径型规则表。
## 目标 ## 目标
财务规则中心只维护真正具备制度标准、且需要按职级/职务或明确人均标准执行的规则表。没有实际金额分档的费用类型,不在财务规则中心单独生成 Excel 表;其额度控制进入预算中心,申请前置和材料完整性进入风险规则。 财务规则中心只维护真正具备制度标准、且需要按职级/职务或明确人均标准执行的规则表。没有实际金额分档的费用类型,不在财务规则中心单独生成 Excel 表;其额度控制进入预算中心,申请前置和材料完整性进入风险规则。

View File

@@ -1,5 +1,9 @@
# 风险规则补齐开发记录 # 风险规则补齐开发记录
## 2026-06-05 口径调整
业务招待费超过 500 元、办公用品超过 2000 元、通用费用超过 2000 元的申请前置要求,制度依据统一改为财务规则《公司费用申请审批规则》。风险规则继续承担执行判断,但 `finance_rule_code` 统一指向 `expense.preapproval.policy`
## 目标 ## 目标
补齐预算、申请前置、报销偏差、费用标准、材料完整性类风险规则,让后续 demo 数据可以形成“预算-申请-报销-风控”的闭环。 补齐预算、申请前置、报销偏差、费用标准、材料完整性类风险规则,让后续 demo 数据可以形成“预算-申请-报销-风控”的闭环。

View File

@@ -150,6 +150,27 @@
- 申请任务完成后,再把剩余报销任务作为后续任务引导到报销助手。 - 申请任务完成后,再把剩余报销任务作为后续任务引导到报销助手。
- 附件归集不作为第一屏主动作抢占申请流程;当进入报销任务时,相关附件随报销上下文带入。 - 附件归集不作为第一屏主动作抢占申请流程;当进入报销任务时,相关附件随报销上下文带入。
小财管家必须维护运行时任务上下文,而不是把每次用户输入都当成新的独立意图。上下文至少包含:
- 当前任务:正在处理的申请或报销任务。
- 剩余任务:已拆解但尚未处理的任务队列。
- 已完成任务:已经形成申请单、报销草稿、附件归集或提交动作的任务。
- 等待动作:当前正在等待用户补字段、确认核对表、确认提交审批或继续下一项。
- 最近结构化结果:当前申请核对表、报销核对结果、附件归集建议等。
用户输入“确认”“无误”“可以提交”等文本时,小财管家必须先匹配当前等待动作;如果当前等待的是申请单提交确认,就提交当前申请单;如果当前等待的是继续下一项,就进入剩余任务队列中的下一项;如果当前核对表仍有缺字段,则提示补字段。只有没有可匹配上下文时,才重新进入任务规划。
上述匹配不应主要依赖前端关键词规则。第一版应新增“小财管家运行时决策智能体”,由后端 function calling 接收 `runtime_state` 和用户当前输入,返回结构化 `next_action`
- `submit_current_application`:确认当前申请核对表并提交至审批。
- `continue_next_task`:当前任务已完成,继续剩余任务队列中的下一项。
- `fill_current_slot`:用户补充了当前等待字段。
- `ask_user`:当前信息不足,需要继续追问。
- `plan_new_tasks`:当前没有可匹配上下文,重新进入任务规划。
- `cancel_current_action` / `no_op`:取消或不执行当前动作。
前端只执行模型返回的结构化动作,并做安全校验:例如申请核对表必须 `readyToSubmit` 才能提交,已提交消息必须标记避免重复提交,缺字段时必须追问。本地规则只允许作为模型失败后的保守兜底,不作为主判断来源。
可以自动执行的动作: 可以自动执行的动作:
- 任务拆解。 - 任务拆解。
@@ -161,7 +182,7 @@
### 4. 流式过程摘要 ### 4. 流式过程摘要
前端展示的是“意图识别智能体”的过程摘要,不是模型内部推理链;过程摘要必须独立于最终回复正文展示。 前端展示的是“意图识别智能体”的过程摘要,不是模型内部推理链;过程摘要必须独立于最终回复正文展示。过程摘要必须围绕业务理解展开,例如用户说了什么、被拆成哪些申请/报销任务、已识别哪些业务要素、还缺少哪些关键条件、为什么需要向用户追问。不能只展示“接收确认、协调能力、准备输出”等系统执行日志。
示例: 示例:
@@ -188,7 +209,7 @@
计划完成后的最终正文也必须流式输出。前端不能把完整正文一次性替换到消息气泡里,而应进入 `typing` 状态按字符逐步追加正文;正文输出完成后,再把状态改为“等待用户确认”并展示确认按钮。 计划完成后的最终正文也必须流式输出。前端不能把完整正文一次性替换到消息气泡里,而应进入 `typing` 状态按字符逐步追加正文;正文输出完成后,再把状态改为“等待用户确认”并展示确认按钮。
用户确认当前步骤后,小财管家隐式委派给申请能力或报销能力时,也必须保持同一套流式体验:先在系统气泡上方的小财管家思考折叠气泡中逐字展示“接收确认、协调能力、等待结果”等过程摘要;拿到申请核对表或报销核对结果后,再逐字输出正文。结构化表格、核对卡片、确认按钮可以在正文输出完成后一次性展示,但正文不能一次性替换进消息气泡。 用户确认当前步骤后,小财管家隐式委派给申请能力或报销能力时,也必须保持同一套流式体验:先在系统气泡上方的小财管家思考折叠气泡中逐字展示当前业务任务、已识别信息、待补充条件和下一步动作;拿到申请核对表或报销核对结果后,再逐字输出正文。结构化表格、核对卡片、确认按钮可以在正文输出完成后一次性展示,但正文不能一次性替换进消息气泡。
小财管家委派期间不得打开右侧单助手执行流程面板,也不得把“申请助手 / 报销助手”的执行步骤显示成独立助手思考框。用户可见身份保持“小财管家”,具体调用哪个能力只作为小财管家自己的过程摘要,不切换为“财务助手”或单独助手会话。 小财管家委派期间不得打开右侧单助手执行流程面板,也不得把“申请助手 / 报销助手”的执行步骤显示成独立助手思考框。用户可见身份保持“小财管家”,具体调用哪个能力只作为小财管家自己的过程摘要,不切换为“财务助手”或单独助手会话。
@@ -208,6 +229,12 @@
后续步骤如果需要展示“还需要补充”,必须是结构化列表,每个待补充项独立成行,包含字段业务名称和填写说明;不得把多个待补充项拼接成一行连续文本。 后续步骤如果需要展示“还需要补充”,必须是结构化列表,每个待补充项独立成行,包含字段业务名称和填写说明;不得把多个待补充项拼接成一行连续文本。
当后续步骤发现关键条件缺失时,小财管家不能只展示“模型复核不稳定”或“下方表格待补充”。它必须把缺口转成下一轮对话问题,并优先给出可直接选择的业务选项。例如差旅申请缺少 `transport_mode` 时,用户界面展示为“请问你打算怎么出行?火车、飞机或轮船”,不得先展示申请核对表,也不得默认补成火车;用户选择后再生成申请核对表、写回出行方式、重新测算费用,并继续判断是否可以提交申请。这是“思考 -> 行动 -> 再思考 -> 再行动”循环的一部分。
用户补齐关键字段也不是终态动作。以“出行方式”为例,用户选择火车后,小财管家必须先进入下一轮业务思考,基于已识别的时间、地点、事由和出行方式模拟查询交通票据或票价口径,完成系统预估金额测算,再流式输出正文并展示申请核对表;不能在用户点击选项后直接把旧核对表补字段后闪现出来。
费用申请核对表阶段不得把系统档案字段或非阻塞归档字段当作用户待补充项。`employee_no``employee_name``department_name` 应从当前登录用户档案和组织上下文读取;`attachments` 在差旅申请阶段不阻塞核对表生成,可在后续报销、归档或审批材料补充阶段处理;`amount` 在申请阶段由系统规则估算。字段决策模型即使返回这些字段为缺失,服务端也必须过滤,不能向用户展示“附件/凭证和员工编号为合规必需字段”这类错误追问。
任务卡片和正文不得直接暴露本体字段名,例如 `transport_mode``amount``attachments`。本体字段只允许作为内部结构化数据进入后端、助手委派和持久化链路;用户界面必须翻译为业务中文,并提供可理解的填写说明: 任务卡片和正文不得直接暴露本体字段名,例如 `transport_mode``amount``attachments`。本体字段只允许作为内部结构化数据进入后端、助手委派和持久化链路;用户界面必须翻译为业务中文,并提供可理解的填写说明:
- `transport_mode` 展示为“出行方式”,说明可填写高铁、飞机、自驾、出租车等。 - `transport_mode` 展示为“出行方式”,说明可填写高铁、飞机、自驾、出租车等。
@@ -311,6 +338,13 @@
规则置信度评分仅用于模型不可用或模型返回结构不可用时的兜底路径。 规则置信度评分仅用于模型不可用或模型返回结构不可用时的兜底路径。
任务拆解之后还需要第二层“任务字段决策智能体”。这一步不能由前端关键词或固定 required 字段直接决定而要把当前任务类型、用户原话、上游任务拆解结果、canonical ontology fields、已抽取字段、缺失字段、附件和申请/报销上下文交给模型,通过 function calling 返回下一步动作:
- `ask_user`:当前信息不足,必须先把缺口转成业务问题和可选项。
- `render_preview`:当前信息足够生成可核对结果,但提交、入库、绑定附件前仍需用户确认。
字段决策规则只能作为模型不可用或结构化结果非法时的兜底,兜底结果必须标记为 `rule_fallback`,不能伪装成智能体判断。字段名必须来自 ontology registryUI 只展示中文业务名称,不展示 canonical 字段名。
任务置信度: 任务置信度:
$$ $$

View File

@@ -44,6 +44,14 @@
- [x] 支持小财管家隐藏委派申请/报销能力,执行后保留小财管家会话并继续引导剩余任务。[CONCEPT: 执行流] 证据:`sessionTypeOverride``stewardContinuation` 已接入前端提交流程。 - [x] 支持小财管家隐藏委派申请/报销能力,执行后保留小财管家会话并继续引导剩余任务。[CONCEPT: 执行流] 证据:`sessionTypeOverride``stewardContinuation` 已接入前端提交流程。
- [x] 支持小财管家确认后的隐式委派继续流式输出,正文完成后再展示申请核对表、报销核对卡片和确认按钮。[CONCEPT: 流式过程摘要] 证据:`useTravelReimbursementSubmitComposer.js` 新增 `typeStewardDelegatedMessage`,申请预览与 orchestrator 结果均先流式思考、再逐字正文、最后挂载结构化 payload`npm.cmd --prefix web run build` 成功。 - [x] 支持小财管家确认后的隐式委派继续流式输出,正文完成后再展示申请核对表、报销核对卡片和确认按钮。[CONCEPT: 流式过程摘要] 证据:`useTravelReimbursementSubmitComposer.js` 新增 `typeStewardDelegatedMessage`,申请预览与 orchestrator 结果均先流式思考、再逐字正文、最后挂载结构化 payload`npm.cmd --prefix web run build` 成功。
- [x] 小财管家委派申请/报销能力期间不打开右侧单助手执行流程面板,用户可见身份保持“小财管家”。[CONCEPT: 流式过程摘要] 证据:`stewardDelegated` 分支跳过 flow step 与 review panel scope并在最终消息设置 `assistantName: '小财管家'``stewardPlanModel.js` 助手标签兜底不再显示“财务助手”。 - [x] 小财管家委派申请/报销能力期间不打开右侧单助手执行流程面板,用户可见身份保持“小财管家”。[CONCEPT: 流式过程摘要] 证据:`stewardDelegated` 分支跳过 flow step 与 review panel scope并在最终消息设置 `assistantName: '小财管家'``stewardPlanModel.js` 助手标签兜底不再显示“财务助手”。
- [x] 小财管家在后续步骤发现关键缺口时,主动转成可回答的问题和选项,而不是只展示待补充表格。[CONCEPT: 用户可见结果展示] 证据:`useTravelReimbursementSubmitComposer.js` 在申请核对缺少“出行方式”时只输出主动追问和火车/飞机/轮船选项,不提前挂载 `applicationPreview``stewardPlanModel.js` 的内部 `carry_text` 不再把“高铁、飞机”等示例写进缺字段提示,避免本地抽取误当成用户已选择;`TravelReimbursementCreateView.js` 在用户选择后不再直接补旧表格,而是重新进入小财管家的委派流;`web/tests/expense-application-fast-preview.test.mjs` 覆盖该回归。
- [x] 用户补齐出行方式后,小财管家必须先思考、模拟查询票据和测算金额,再展示申请核对表。[CONCEPT: 用户可见结果展示] 证据:`stewardFieldCompletionModel.js` 将补齐字段后的当前任务、本体字段和旧预览重组为续跑输入;`TravelReimbursementCreateView.js``continueStewardApplicationFieldCompletion` 调用 `submitComposerInternal` 触发流式思考、申请复核和费用测算,不再调用 `commitApplicationPreviewEditor` 直接闪现表格。
- [x] 防止残留预算上下文抢占小财管家的申请续跑链路。[CONCEPT: 执行流] 证据:`budgetAssistantReportModel.js` 不再因存在 `initialBudgetContext` 就无条件进入预算编制报告;`useTravelReimbursementSubmitComposer.js``stewardDelegated` 显式跳过预算编制分支;`expense-application-fast-preview.test.mjs` 覆盖“申请续跑 + 残留预算上下文”不得进入预算编制。
- [x] 支持用户直接输入“确认/无误/可以提交”命中当前申请核对表提交动作,而不是重新规划。[CONCEPT: 用户确认] 证据:`TravelReimbursementCreateView.js` 通过 `handleStewardRuntimeDecision` 优先请求运行时决策智能体;模型返回 `submit_current_application` 后复用 `confirmApplicationSubmit`;本地 `handleApplicationSubmitConfirmationText` 仅作为模型不可用时的兜底;提交成功后标记 `applicationSubmitConfirmed`,避免后续重复提交;测试 `text confirmation submits pending application preview before replanning steward task` 覆盖该优先级。
- [x] 增加小财管家运行时决策智能体,把“确认、继续下一项、补字段、重新规划”的上下文判断迁到后端 function calling。[CONCEPT: 用户确认] 证据:`POST /steward/runtime-decisions` 调用 `StewardRuntimeDecisionAgent`,通过 `submit_steward_runtime_decision` 返回 `submit_current_application``continue_next_task``fill_current_slot``plan_new_tasks` 等动作;前端 `handleStewardRuntimeDecision` 先提交 `runtime_state`,再执行模型返回的结构化动作,本地规则仅兜底。
- [x] 增加第二层任务字段决策智能体,动态判断当前任务应追问用户还是展示核对结果。[CONCEPT: 算法与公式] 证据:`POST /steward/slot-decisions` 调用 `StewardSlotDecisionAgent`,通过 `submit_steward_slot_decision` function calling 输出 `ask_user` / `render_preview`、canonical 缺失字段、问题和选项;前端 `useTravelReimbursementSubmitComposer.js` 在小财管家委派申请时消费该决策。
- [x] 防止字段决策模型把申请阶段非阻塞字段误判为用户必填项。[CONCEPT: 用户可见结果展示] 证据:`StewardSlotDecisionAgent` 过滤 `amount``attachments``employee_no``department_name``employee_name`,模型误返 `ask_user` 且过滤后无缺口时改为 `render_preview`;前端 `APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS` 同步过滤兜底缺口和选项;测试覆盖附件/员工编号误判。
- [x] 小财管家思考气泡必须体现业务意图和关键缺口,不能退化为系统执行日志。[CONCEPT: 流式过程摘要] 证据:`steward_planner.py` 将差旅申请缺少“出行方式”纳入计划缺口并追加业务缺口思考事件;`useTravelReimbursementSubmitComposer.js``TravelReimbursementCreateView.js` 的确认后思考改为读取任务摘要、已识别信息和待补充项。
- [x] 确认申请任务后,将任务摘要分派到现有申请助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=application``auto_submit=true` - [x] 确认申请任务后,将任务摘要分派到现有申请助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=application``auto_submit=true`
- [x] 确认报销任务后,将任务摘要和附件带入现有报销助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=expense``carry_files=true``auto_submit=true` - [x] 确认报销任务后,将任务摘要和附件带入现有报销助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=expense``carry_files=true``auto_submit=true`
- [x] 附件归集建议确认后,将选中附件作为报销助手上下文继续处理。[CONCEPT: 附件归集] 证据:附件归集确认动作携带归集附件名称和排除附件名称。 - [x] 附件归集建议确认后,将选中附件作为报销助手上下文继续处理。[CONCEPT: 附件归集] 证据:附件归集确认动作携带归集附件名称和排除附件名称。

View File

@@ -0,0 +1,88 @@
# 申请单关联归档状态概念文档
## 功能一句话
申请单审批完成后先进入关联单据状态,只有关联的报销单完成付款归档后,申请单才同步归档。
## 背景与问题
当前费用申请单审批完成后,部分列表和进度展示会把申请单视为归档;但业务上申请单只是完成了事前审批,还需要等待后续报销单关联、报销审批、付款完成后,申请单生命周期才真正闭环。
这会导致用户看到报销单仍在处理、申请单却已归档,或者报销单已完成但申请单还停留在进行中的割裂状态。
## 目标
1. 申请单审批完成不直接进入归档中心。
2. 申请单进度在归档前增加“关联单据状态”节点。
3. 已有关联报销单但未付款完成时,该节点显示“关联中”。
4. 没有关联报销单时,该节点显示“未关联”。
5. 关联报销单付款完成后,申请单同步进入“申请归档”。
## 非目标
1. 不新增数据库表。
2. 不改变报销单本身的审批、付款权限。
3. 不改变申请单审批通过自动生成报销草稿的现有能力。
## 用户与场景
涉及角色:
- 申请人:查看申请单是否已经关联后续报销单。
- 审批人:审批申请单后不再误以为该申请已经归档。
- 财务人员:付款完成报销单时,同步闭环关联申请单。
关键场景:
1. 申请单审批通过,但未生成或未关联报销单:显示“关联单据状态 / 未关联”。
2. 申请单审批通过,并已生成报销草稿或报销单仍在流程中:显示“关联单据状态 / 关联中”。
3. 关联报销单已付款:报销单进入已付款,申请单进入“申请归档”。
## 方案设计
后端:
- 申请单 `approved + 审批完成` 不再被归档查询命中。
- 申请单只有 `approved + 申请归档` 才属于归档。
- 报销单付款完成时,从 `application_handoff``application_link` 风险事件中读取关联申请单。
- 找到关联申请单后,追加同步归档事件,并将申请单阶段置为“申请归档”。
前端:
- 申请单进度增加“关联单据状态”和“已归档”节点。
- 审批完成但未归档的申请单,当前节点停留在“关联单据状态”。
- 根据申请单自身的 `generated_draft_claim_no` 或报销单侧关联事件显示“关联中 / 未关联”。
- 只有“申请归档”阶段才展示归档完成。
## 算法与公式
当前功能不涉及显式数学公式。
关联状态判断:
```text
has_linked_reimbursement = exists(application.generated_draft_claim_no)
or exists(reimbursement.risk_flags.application_claim_id/no == application.id/no)
application_archived = application.status in {approved, completed}
and application.approval_stage == "申请归档"
```
## 测试方案
1. 后端状态测试:审批完成申请单不归档,申请归档才归档。
2. 后端付款测试:关联报销单付款后,申请单同步进入“申请归档”。
3. 前端进度测试:审批完成申请单显示“关联单据状态”和“已归档”。
4. 前端归档判断测试:`审批完成` 申请单不算归档,`申请归档` 才算归档。
## 验收标准
1. 单据中心普通视图仍能看到审批完成但未归档的申请单。
2. 归档中心不会提前出现仅审批完成的申请单。
3. 申请单进度在审批完成后能看到“关联单据状态”。
4. 报销单付款完成后,关联申请单同步显示为归档。
## 风险与开放问题
- 旧数据中可能存在已经把申请单审批完成当作归档的数据,本次按新业务规则修正展示与查询口径。
- 如果历史申请单缺少关联报销事件,只能展示“未关联”,不做自动猜测。

View File

@@ -0,0 +1,8 @@
# 申请单关联归档状态开发 TODO
- [x] 梳理申请单审批完成、报销单关联、报销单付款、归档查询的现有链路。[CONCEPT: 背景与问题] 证据:已确认 `expense_claim_status_registry.py``expense_claim_access_policy.py``expense_claim_approval_flow.py``useRequests.js` 的当前行为。
- [x] 调整后端归档查询口径:申请单 `审批完成` 不再视为归档,仅 `申请归档` 才归档。[CONCEPT: 方案设计] 证据:`ExpenseClaimAccessPolicy.build_archived_claim_condition()` 仅将 `APPLICATION_ARCHIVE_STAGE` 视为申请归档。
- [x] 调整报销单付款完成逻辑:根据关联事件同步推进申请单到 `申请归档`。[CONCEPT: 方案设计] 证据:`mark_claim_paid()` 调用 `_archive_linked_applications_after_reimbursement_paid()`,新增付款同步测试通过。
- [x] 调整前端申请单进度:增加 `关联单据状态``已归档` 节点,并显示 `关联中/未关联`。[CONCEPT: 方案设计] 证据:`useRequests.js` 新增申请单进度节点和关联状态计算。
- [x] 补充前后端回归测试,覆盖未关联、关联中、已归档三类申请单状态。[CONCEPT: 测试方案] 证据:`requestProgressSteps.test.mjs``document-center-archived-scope.test.mjs``expense-claim-archive.test.mjs``test_expense_claim_service.py` 已覆盖。
- [x] 在容器或前端定向测试中完成验证,并记录命令结果。[CONCEPT: 验收标准] 证据:前端 Node 定向测试、容器内 py_compile、状态/路由/归档/付款同步 pytest、`npm.cmd --prefix web run build` 均通过。

View File

@@ -0,0 +1,131 @@
# 费用申请审批财务规则概念文档
## 功能一句话
在财务规则中心新增《公司费用申请审批规则》,统一维护业务招待、办公用品和通用大额费用的事前申请与审批阈值,并让报销风险规则引用该规则执行。
## 背景与问题
现有系统已经有“业务招待无申请”“办公采购无申请”“大额费用无申请”等风险规则,但制度依据主要以风险规则 JSON 的口径字段存在,财务规则中心缺少一张可被制度管理员查看、编辑和追溯的规则表。
用户明确要求:
- 业务招待费超过 500 元需要申请。
- 大额办公用品需要申请。
- 金额超过 2000 元的费用都需要走审批。
- 这些要求最好形成财务规则,而不是散落在代码或前端提示中。
## 目标与非目标
目标:
- 新增一张财务规则资产《公司费用申请审批规则》。
- 规则资产以 Excel 形式进入 `finance-rules` 规则库,并在规则中心按“财务规则”展示。
- 风险规则引用统一的 `finance_rule_code`,不再使用零散口径 code。
- 报销阶段按结构化金额规则判断,而不是只靠关键词命中。
- 关联有效申请单后不触发“缺少申请”风险。
非目标:
- 本轮不新增数据库字段。
- 本轮不新增非本体业务字段。
- 本轮不改造完整审批流节点,只补充申请前置与风险执行依据。
## 用户与场景
- 报销人:上传或录入业务招待、办公用品、大额费用报销时,系统自动识别是否缺少事前申请。
- 直属领导和财务审核人:审核单据时能看到风险来自财务规则。
- 财务制度管理员:能在规则中心看到并维护《公司费用申请审批规则》。
## 功能能力
### 财务规则表
规则表包含以下行:
- 业务招待费:单次费用金额大于 500 元时,必须先提交费用申请单。
- 办公用品费:单次或批量采购金额大于 2000 元时,必须先提交办公采购或费用申请单。
- 通用大额费用:任意费用金额大于 2000 元时,必须进入审批流程。
### 风险规则执行
- `meal``entertainment` 都视为业务招待费。
- `office` 视为办公用品费。
- `all` 视为通用大额费用。
- 报销阶段没有关联有效申请单时,超过阈值命中高风险。
- 已有关联申请单时,不命中缺少申请风险。
## 方案设计
### 后端
-`agent_asset_spreadsheet.py` 中新增费用申请审批规则 code 与文件名常量。
- 在财务规则同步中新增该资产的 metadata、Excel 工作簿生成和版本快照。
- 在初始化和补齐逻辑中创建该财务规则资产,确保老库和新库都能看到。
- 将三条风险规则改为 `composite_rule_v1`,使用金额阈值和申请单存在性执行。
-`risk_rule_template_executor.py` 中补齐 `application.*` 字段解析,桥接现有 `application_link` / `application_handoff` / `application_detail` 风险上下文。
### 前端
本轮不新增前端页面。规则中心已有财务规则和 JSON 风险规则展示能力,后端资产同步后前端可直接展示。
### 数据与本体
本轮只使用现有本体字段:
- `expense_type`
- `amount`
- `reason`
- `application_claim_id`
- `application_claim_no`
- `application_detail`
不新增非本体字段。
## 算法与公式
业务招待费规则:
$$
hit = expenseType \in \{meal, entertainment\} \land amount > 500 \land \neg hasApplication
$$
办公用品规则:
$$
hit = expenseType = office \land amount > 2000 \land \neg hasApplication
$$
通用大额规则:
$$
hit = amount > 2000 \land \neg hasApplication
$$
其中:
- `amount` 来自 `claim.amount`
- `hasApplication` 来自 `application.id``application.claim_no` 或等价申请单上下文。
## 测试方案
- 单元测试:验证 `application.*` 字段能从已有申请关联上下文解析。
- 规则执行测试:超过 500 元业务招待费且无申请命中风险。
- 规则执行测试:超过 2000 元办公用品费且无申请命中风险。
- 规则执行测试:超过 2000 元通用费用且无申请命中风险。
- 规则执行测试:已关联申请单的超额费用不命中缺少申请风险。
- 资产测试:规则中心种子数据包含《公司费用申请审批规则》,且 `config_json.tag` 为“财务规则”。
## 指标与验收
- 财务规则中心能看到新增规则资产。
- 新增资产 `finance_rule_code` 统一为 `expense.preapproval.policy`
- 三条风险规则均引用该财务规则 code。
- 容器内后端定向测试通过。
- 不新增非本体业务字段。
## 风险与开放问题
- “大额办公用品”的金额阈值按用户同句“大额/超过 2000 都需要审批”落为 2000 元。
- 当前申请单上下文主要存在 `risk_flags_json` 的申请关联 flag 中,本轮先补执行器解析,不新增外键字段。
- 后续如果要支持不同部门或不同职级阈值,可以在同一张财务规则表中扩展分档行。

View File

@@ -0,0 +1,23 @@
# 费用申请审批财务规则 TODO
## 调研与契约
- [x] 盘点现有财务规则资产、风险规则 JSON 与规则同步链路。[CONCEPT: 背景与问题] 证据:确认现有 `finance-rules` 仅差旅和通信两张核心规则表,前置申请规则当前在 `risk-rules` 中。
- [x] 明确本轮不新增非本体业务字段。[CONCEPT: 数据与本体] 证据:规则只使用 `expense_type``amount``reason` 和申请单上下文。
## 后端实现
- [x] 新增《公司费用申请审批规则》财务规则资产常量与 Excel 工作簿内容。[CONCEPT: 财务规则表] 证据:`COMPANY_PREAPPROVAL_RULE_CODE``COMPANY_PREAPPROVAL_RULE_FILENAME``_ensure_company_preapproval_rule_spreadsheet_seed()` 已实现。
- [x] 初始化种子和老库补齐逻辑都能创建该财务规则资产。[CONCEPT: 方案设计] 证据:`agent_foundation_asset_seed.py``agent_foundation_asset_topup.py` 均接入该资产。
- [x] 将大额费用、业务招待、办公用品三条前置申请风险规则改为结构化金额判断。[CONCEPT: 风险规则执行] 证据:三条 `risk.application.*without_preapproval.json` 已改为 `composite_rule_v1`
- [x] 补齐 `application.*` 字段解析,支持从现有关联申请上下文判断是否已有申请。[CONCEPT: 后端] 证据:`risk_rule_template_executor.py` 新增 `_resolve_application_values()`
## 测试与验证
- [x] 新增执行器测试:申请单上下文存在时 `application.id` 可解析。[CONCEPT: 测试方案] 证据:`test_application_context_values_are_available_to_composite_rules` 通过。
- [x] 新增风险规则执行测试:业务招待费超过 500 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 meal。
- [x] 新增风险规则执行测试:办公用品超过 2000 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 office。
- [x] 新增风险规则执行测试:通用费用超过 2000 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 software。
- [x] 新增资产同步测试:财务规则中心包含新增规则资产。[CONCEPT: 指标与验收] 证据:`test_finance_rules_use_risk_rule_scenario_categories` 断言新增财务规则资产和规则文档。
- [x] Docker `x-financial-main` 容器内定向测试通过。[CONCEPT: 指标与验收] 证据:新增与相邻回归共 15 个后端测试通过。
- [x] 重启后端并验证运行时健康状态。[CONCEPT: 指标与验收] 证据:`x-financial-main` 已重启并进入 healthy真实库可查到 `rule.expense.company_preapproval_requirement`

View File

@@ -1,17 +1,17 @@
{ {
"schema_version": "2.0", "schema_version": "2.0",
"rule_code": "risk.application.large_expense_without_preapproval", "rule_code": "risk.application.large_expense_without_preapproval",
"name": "大额费用未事前申请", "name": "?????????",
"description": "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。", "description": "???????? 2000 ?????????????",
"enabled": true, "enabled": true,
"requires_attachment": false, "requires_attachment": false,
"risk_dimension": "expense_control_demo", "risk_dimension": "expense_control_demo",
"risk_category": "申请前置", "risk_category": "????",
"ontology_signal": "application_required", "ontology_signal": "application_required",
"evaluator": "template_rule", "evaluator": "template_rule",
"template_key": "keyword_match_v1", "template_key": "composite_rule_v1",
"finance_rule_code": "finance.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则", "finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -34,68 +34,75 @@
"fields": [ "fields": [
{ {
"key": "claim.amount", "key": "claim.amount",
"label": "报销金额", "label": "????",
"type": "number", "type": "number",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.expense_type", "key": "claim.expense_type",
"label": "费用类型", "label": "????",
"type": "enum", "type": "enum",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.department_name", "key": "claim.department_name",
"label": "部门", "label": "??",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.reason", "key": "claim.reason",
"label": "事由", "label": "??",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "item.item_reason", "key": "item.item_reason",
"label": "明细说明", "label": "????",
"type": "text", "type": "text",
"source": "item" "source": "item"
}, },
{ {
"key": "application.id", "key": "application.id",
"label": "申请单", "label": "???ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.status", "key": "application.status",
"label": "申请状态", "label": "????",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.approved_amount", "key": "application.approved_amount",
"label": "申请审批金额", "label": "??????",
"type": "number", "type": "number",
"source": "application" "source": "application"
}, },
{ {
"key": "application.expense_type", "key": "application.expense_type",
"label": "申请费用类型", "label": "??????",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.department_name", "key": "application.department_name",
"label": "申请部门", "label": "????",
"type": "text", "type": "text",
"source": "application" "source": "application"
} }
] ]
}, },
"params": { "params": {
"template_key": "keyword_match_v1", "template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [ "field_keys": [
"claim.amount", "claim.amount",
"claim.expense_type", "claim.expense_type",
@@ -103,31 +110,89 @@
"claim.reason", "claim.reason",
"item.item_reason", "item.item_reason",
"application.id", "application.id",
"application.claim_no",
"application.status", "application.status",
"application.approved_amount", "application.approved_amount",
"application.expense_type", "application.expense_type",
"application.department_name" "application.department_name"
], ],
"search_fields": [ "conditions": [
"claim.reason", {
"item.item_reason", "id": "amount_exceeds_preapproval_threshold",
"claim.expense_type" "operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 2000,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
},
{
"id": "not_specific_preapproval_type",
"operator": "not_contains_any",
"fields": [
"claim.expense_type"
],
"keywords": [
"meal",
"entertainment",
"office",
"????",
"??",
"????",
"??"
]
}
], ],
"keywords": [ "hit_logic": {
"大额费用", "all": [
"未申请", "amount_exceeds_preapproval_threshold",
"先申请后报销" {
], "not": "application_present"
"condition_summary": "金额达到大额阈值且缺少已通过申请单时触发。", },
"finance_rule_code": "finance.preapproval.policy", "not_specific_preapproval_type"
"finance_rule_sheet": "费用申请前置规则", ]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "?????????????????? 2000 ????????????????",
"message_template": "?????? 2000 ?????????????????????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"all" "all"
], ],
"budget_required": true "budget_required": true,
"threshold_amount": 2000,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "????",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
}, },
"outcomes": { "outcomes": {
"pass": { "pass": {
@@ -141,16 +206,16 @@
} }
}, },
"metadata": { "metadata": {
"owner": "风控与审计部", "owner": "??????",
"stability": "platform", "stability": "platform",
"source_ref": "费用管控 Demo 风险规则库", "source_ref": "??????????",
"created_at": "2026-05-31T00:10:41.805274+00:00", "created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system", "created_by": "system",
"risk_score": 86, "risk_score": 86,
"risk_level": "high", "risk_level": "high",
"rule_title": "大额费用未事前申请", "rule_title": "?????????",
"finance_rule_code": "finance.preapproval.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则", "finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],

View File

@@ -1,22 +1,23 @@
{ {
"schema_version": "2.0", "schema_version": "2.0",
"rule_code": "risk.application.meal_high_value_without_preapproval", "rule_code": "risk.application.meal_high_value_without_preapproval",
"name": "大额业务招待未申请", "name": "??????????",
"description": "业务招待金额或人均金额超过制度阈值但未事前审批。", "description": "????????? 500 ?????????????",
"enabled": true, "enabled": true,
"requires_attachment": false, "requires_attachment": false,
"risk_dimension": "expense_control_demo", "risk_dimension": "expense_control_demo",
"risk_category": "申请前置", "risk_category": "????",
"ontology_signal": "application_required", "ontology_signal": "application_required",
"evaluator": "template_rule", "evaluator": "template_rule",
"template_key": "keyword_match_v1", "template_key": "composite_rule_v1",
"finance_rule_code": "expense.application.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则", "finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"meal" "meal",
"entertainment"
], ],
"budget_required": true, "budget_required": true,
"applies_to": { "applies_to": {
@@ -24,7 +25,8 @@
"expense" "expense"
], ],
"expense_types": [ "expense_types": [
"meal" "meal",
"entertainment"
], ],
"business_stages": [ "business_stages": [
"reimbursement" "reimbursement"
@@ -34,74 +36,75 @@
"fields": [ "fields": [
{ {
"key": "claim.amount", "key": "claim.amount",
"label": "报销金额", "label": "????",
"type": "number", "type": "number",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.expense_type", "key": "claim.expense_type",
"label": "费用类型", "label": "????",
"type": "enum", "type": "enum",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.department_name", "key": "claim.department_name",
"label": "部门", "label": "??",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.reason", "key": "claim.reason",
"label": "事由", "label": "??",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "item.item_reason", "key": "item.item_reason",
"label": "明细说明", "label": "????",
"type": "text", "type": "text",
"source": "item" "source": "item"
}, },
{ {
"key": "application.id", "key": "application.id",
"label": "申请单", "label": "???ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.status", "key": "application.status",
"label": "申请状态", "label": "????",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.approved_amount", "key": "application.approved_amount",
"label": "申请审批金额", "label": "??????",
"type": "number", "type": "number",
"source": "application" "source": "application"
}, },
{ {
"key": "application.expense_type", "key": "application.expense_type",
"label": "申请费用类型", "label": "??????",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.department_name", "key": "application.department_name",
"label": "申请部门", "label": "????",
"type": "text", "type": "text",
"source": "application" "source": "application"
},
{
"key": "material.attendee_list_uploaded",
"label": "参与人清单已上传",
"type": "boolean",
"source": "material"
} }
] ]
}, },
"params": { "params": {
"template_key": "keyword_match_v1", "template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [ "field_keys": [
"claim.amount", "claim.amount",
"claim.expense_type", "claim.expense_type",
@@ -109,32 +112,73 @@
"claim.reason", "claim.reason",
"item.item_reason", "item.item_reason",
"application.id", "application.id",
"application.claim_no",
"application.status", "application.status",
"application.approved_amount", "application.approved_amount",
"application.expense_type", "application.expense_type",
"application.department_name", "application.department_name"
"material.attendee_list_uploaded"
], ],
"search_fields": [ "conditions": [
"claim.reason", {
"item.item_reason", "id": "amount_exceeds_preapproval_threshold",
"claim.expense_type" "operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 500,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
}
], ],
"keywords": [ "hit_logic": {
"业务招待", "all": [
"人均超标", "amount_exceeds_preapproval_threshold",
"未申请" {
], "not": "application_present"
"condition_summary": "业务招待金额超过申请阈值且没有通过申请时触发。", }
"finance_rule_code": "expense.application.policy", ]
"finance_rule_sheet": "费用申请前置规则", },
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "??????????? 500 ????????????????",
"message_template": "??????? 500 ?????????????????????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"meal" "meal",
"entertainment"
], ],
"budget_required": true "budget_required": true,
"threshold_amount": 500,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "????",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
}, },
"outcomes": { "outcomes": {
"pass": { "pass": {
@@ -144,29 +188,30 @@
"fail": { "fail": {
"severity": "high", "severity": "high",
"action": "manual_review", "action": "manual_review",
"risk_score": 84 "risk_score": 88
} }
}, },
"metadata": { "metadata": {
"owner": "风控与审计部", "owner": "??????",
"stability": "platform", "stability": "platform",
"source_ref": "费用管控 Demo 风险规则库", "source_ref": "??????????",
"created_at": "2026-05-31T00:10:41.818641+00:00", "created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system", "created_by": "system",
"risk_score": 84, "risk_score": 88,
"risk_level": "high", "risk_level": "high",
"rule_title": "大额业务招待未申请", "rule_title": "??????????",
"finance_rule_code": "expense.application.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则", "finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"meal" "meal",
"entertainment"
], ],
"budget_required": true "budget_required": true
}, },
"severity": "high", "severity": "high",
"risk_score": 84, "risk_score": 88,
"risk_level": "high" "risk_level": "high"
} }

View File

@@ -1,17 +1,17 @@
{ {
"schema_version": "2.0", "schema_version": "2.0",
"rule_code": "risk.application.office_bulk_without_purchase", "rule_code": "risk.application.office_bulk_without_purchase",
"name": "办公用品大额采购未申请", "name": "???????????",
"description": "批量办公用品或设备采购达到阈值但未走采购申请。", "description": "???????????????? 2000 ???????????",
"enabled": true, "enabled": true,
"requires_attachment": false, "requires_attachment": false,
"risk_dimension": "expense_control_demo", "risk_dimension": "expense_control_demo",
"risk_category": "申请前置", "risk_category": "????",
"ontology_signal": "application_required", "ontology_signal": "application_required",
"evaluator": "template_rule", "evaluator": "template_rule",
"template_key": "keyword_match_v1", "template_key": "composite_rule_v1",
"finance_rule_code": "expense.application.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则", "finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -34,68 +34,75 @@
"fields": [ "fields": [
{ {
"key": "claim.amount", "key": "claim.amount",
"label": "报销金额", "label": "????",
"type": "number", "type": "number",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.expense_type", "key": "claim.expense_type",
"label": "费用类型", "label": "????",
"type": "enum", "type": "enum",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.department_name", "key": "claim.department_name",
"label": "部门", "label": "??",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "claim.reason", "key": "claim.reason",
"label": "事由", "label": "??",
"type": "text", "type": "text",
"source": "claim" "source": "claim"
}, },
{ {
"key": "item.item_reason", "key": "item.item_reason",
"label": "明细说明", "label": "????",
"type": "text", "type": "text",
"source": "item" "source": "item"
}, },
{ {
"key": "application.id", "key": "application.id",
"label": "申请单", "label": "???ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"type": "text", "type": "text",
"source": "application" "source": "application"
}, },
{ {
"key": "application.status", "key": "application.status",
"label": "申请状态", "label": "????",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.approved_amount", "key": "application.approved_amount",
"label": "申请审批金额", "label": "??????",
"type": "number", "type": "number",
"source": "application" "source": "application"
}, },
{ {
"key": "application.expense_type", "key": "application.expense_type",
"label": "申请费用类型", "label": "??????",
"type": "enum", "type": "enum",
"source": "application" "source": "application"
}, },
{ {
"key": "application.department_name", "key": "application.department_name",
"label": "申请部门", "label": "????",
"type": "text", "type": "text",
"source": "application" "source": "application"
} }
] ]
}, },
"params": { "params": {
"template_key": "keyword_match_v1", "template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [ "field_keys": [
"claim.amount", "claim.amount",
"claim.expense_type", "claim.expense_type",
@@ -103,31 +110,72 @@
"claim.reason", "claim.reason",
"item.item_reason", "item.item_reason",
"application.id", "application.id",
"application.claim_no",
"application.status", "application.status",
"application.approved_amount", "application.approved_amount",
"application.expense_type", "application.expense_type",
"application.department_name" "application.department_name"
], ],
"search_fields": [ "conditions": [
"claim.reason", {
"item.item_reason", "id": "amount_exceeds_preapproval_threshold",
"claim.expense_type" "operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 2000,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
}
], ],
"keywords": [ "hit_logic": {
"办公采购", "all": [
"大额办公用品", "amount_exceeds_preapproval_threshold",
"采购申请" {
], "not": "application_present"
"condition_summary": "办公用品单次金额达到采购阈值且缺少采购申请时触发。", }
"finance_rule_code": "expense.application.policy", ]
"finance_rule_sheet": "费用申请前置规则", },
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "???????????????? 2000 ????????????????",
"message_template": "??????? 2000 ??????????????????????????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
"expense_types": [ "expense_types": [
"office" "office"
], ],
"budget_required": true "budget_required": true,
"threshold_amount": 2000,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "????",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
}, },
"outcomes": { "outcomes": {
"pass": { "pass": {
@@ -135,22 +183,22 @@
"action": "continue" "action": "continue"
}, },
"fail": { "fail": {
"severity": "medium", "severity": "high",
"action": "manual_review", "action": "manual_review",
"risk_score": 78 "risk_score": 84
} }
}, },
"metadata": { "metadata": {
"owner": "风控与审计部", "owner": "??????",
"stability": "platform", "stability": "platform",
"source_ref": "费用管控 Demo 风险规则库", "source_ref": "??????????",
"created_at": "2026-05-31T00:10:41.811910+00:00", "created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system", "created_by": "system",
"risk_score": 78, "risk_score": 84,
"risk_level": "medium", "risk_level": "high",
"rule_title": "办公用品大额采购未申请", "rule_title": "???????????",
"finance_rule_code": "expense.application.policy", "finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则", "finance_rule_sheet": "????????",
"business_stage": [ "business_stage": [
"reimbursement" "reimbursement"
], ],
@@ -159,7 +207,7 @@
], ],
"budget_required": true "budget_required": true
}, },
"severity": "medium", "severity": "high",
"risk_score": 78, "risk_score": 84,
"risk_level": "medium" "risk_level": "high"
} }

View File

@@ -4,6 +4,8 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312" OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312"
PYTHON_BIN="${PYTHON_BIN:-python3.12}" PYTHON_BIN="${PYTHON_BIN:-python3.12}"
PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.3.1}"
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "python3.12 不存在,请先安装 Python 3.12。" >&2 echo "python3.12 不存在,请先安装 Python 3.12。" >&2
@@ -15,6 +17,6 @@ apt-get install -y libgl1 libglib2.0-0
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}" "${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip "${OCR_VENV_DIR}/bin/pip" install --upgrade pip
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==3.2.0" "paddleocr==3.5.0" "${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==${PADDLEPADDLE_VERSION}" "paddleocr==${PADDLEOCR_VERSION}"
echo "PaddleOCR mobile runtime 已安装到 ${OCR_VENV_DIR}" echo "PaddleOCR mobile runtime ${PADDLEOCR_VERSION} / PaddlePaddle ${PADDLEPADDLE_VERSION} 已安装到 ${OCR_VENV_DIR}"

View File

@@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--lang", default="ch") parser.add_argument("--lang", default="ch")
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det") parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec") parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
parser.add_argument("--enable-mkldnn", action="store_true")
return parser.parse_args() return parser.parse_args()
@@ -106,6 +107,8 @@ def main() -> int:
use_doc_unwarping=False, use_doc_unwarping=False,
use_textline_orientation=False, use_textline_orientation=False,
lang=args.lang, lang=args.lang,
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
enable_mkldnn=args.enable_mkldnn,
) )
documents = [] documents = []

View File

@@ -188,6 +188,8 @@ if is_container; then
fi fi
SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}" SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}"
SERVER_WORKERS="${SERVER_WORKERS:-${WEB_CONCURRENCY:-1}}"
export SERVER_WORKERS
needs_windows_python() { needs_windows_python() {
is_msys || is_wsl is_msys || is_wsl
@@ -355,6 +357,12 @@ start_server() {
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
fi fi
if [ "$SERVER_WORKERS" -gt 1 ] 2>/dev/null; then
BACKGROUND_SCHEDULERS_ENABLED="${BACKGROUND_SCHEDULERS_ENABLED:-false}"
export BACKGROUND_SCHEDULERS_ENABLED
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" --workers "$SERVER_WORKERS"
fi
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
} }

View File

@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from app.api.deps import CurrentUserContext, get_current_user, get_db from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
@@ -50,7 +51,7 @@ async def recognize_ocr_documents(
upload.content_type, upload.content_type,
) )
) )
result = OcrService(db).recognize_files(payload) result = await run_in_threadpool(lambda: OcrService(db).recognize_files(payload))
return ReceiptFolderService().persist_ocr_batch( return ReceiptFolderService().persist_ocr_batch(
files=payload, files=payload,
result=result, result=result,

View File

@@ -11,10 +11,20 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse, StewardThinkingEvent from app.schemas.steward import (
StewardPlanRequest,
StewardPlanResponse,
StewardRuntimeDecisionRequest,
StewardRuntimeDecisionResponse,
StewardSlotDecisionRequest,
StewardSlotDecisionResponse,
StewardThinkingEvent,
)
from app.services.runtime_chat import RuntimeChatService from app.services.runtime_chat import RuntimeChatService
from app.services.steward_intent_agent import StewardIntentAgent from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_planner import StewardPlannerService from app.services.steward_planner import StewardPlannerService
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent
router = APIRouter(prefix="/steward") router = APIRouter(prefix="/steward")
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]
@@ -39,6 +49,32 @@ def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPl
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post(
"/slot-decisions",
response_model=StewardSlotDecisionResponse,
summary="判断小财管家当前任务字段缺口",
description="结合当前任务、本体字段和用户上下文,使用 function calling 判断下一步应先追问用户还是展示核对结果。",
)
def create_steward_slot_decision(
payload: StewardSlotDecisionRequest,
db: DbSession,
) -> StewardSlotDecisionResponse:
return StewardSlotDecisionAgent(RuntimeChatService(db)).decide(payload)
@router.post(
"/runtime-decisions",
response_model=StewardRuntimeDecisionResponse,
summary="判断小财管家运行时下一步动作",
description="结合任务队列、当前结构化结果和用户输入,使用 function calling 判断应提交当前单据、继续下一任务、补字段或重新规划。",
)
def create_steward_runtime_decision(
payload: StewardRuntimeDecisionRequest,
db: DbSession,
) -> StewardRuntimeDecisionResponse:
return StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(payload)
@router.post( @router.post(
"/plans/stream", "/plans/stream",
summary="流式生成小财管家任务计划", summary="流式生成小财管家任务计划",
@@ -60,8 +96,8 @@ async def _iter_steward_plan_events(
StewardThinkingEvent( StewardThinkingEvent(
event_id="intent_agent_stream_start", event_id="intent_agent_stream_start",
stage="stream_start", stage="stream_start",
title="意图识别智能体接管", title="读取用户输入",
content="已收到任务描述,正在调用小财管家意图识别智能体拆解申请、报销附件线索", content="我先判断这句话里是否同时包含申请、报销附件归集事项,再决定处理顺序",
status="running", status="running",
).model_dump(mode="json"), ).model_dump(mode="json"),
) )
@@ -75,7 +111,7 @@ async def _iter_steward_plan_events(
for event in plan.thinking_events: for event in plan.thinking_events:
yield _encode_stream_event("thinking", event.model_dump(mode="json")) yield _encode_stream_event("thinking", event.model_dump(mode="json"))
await asyncio.sleep(0.18) await asyncio.sleep(0.6)
yield _encode_stream_event("plan", plan.model_dump(mode="json")) yield _encode_stream_event("plan", plan.model_dump(mode="json"))

View File

@@ -38,10 +38,16 @@ class Settings(BaseSettings):
admin_email: str = Field(default="", alias="ADMIN_EMAIL") admin_email: str = Field(default="", alias="ADMIN_EMAIL")
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST") web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
web_port: int = Field(default=5173, alias="WEB_PORT") web_port: int = Field(default=5173, alias="WEB_PORT")
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST") app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT") app_port: int = Field(default=8000, alias="SERVER_PORT")
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX") server_workers: int = Field(default=1, alias="SERVER_WORKERS")
web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY")
background_schedulers_enabled: bool = Field(
default=True,
alias="BACKGROUND_SCHEDULERS_ENABLED",
)
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST") postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT") postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
@@ -49,8 +55,11 @@ class Settings(BaseSettings):
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER") postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD") postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
database_url: str | None = Field(default=None, alias="DATABASE_URL") database_url: str | None = Field(default=None, alias="DATABASE_URL")
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO") sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE")
sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW")
sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT")
redis_url: str | None = Field(default=None, alias="REDIS_URL") redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS") cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
@@ -70,6 +79,7 @@ class Settings(BaseSettings):
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN") ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS") ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB") ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
ocr_language: str = Field(default="ch", alias="OCR_LANGUAGE") ocr_language: str = Field(default="ch", alias="OCR_LANGUAGE")
seed_demo_financial_records: bool = Field( seed_demo_financial_records: bool = Field(
default=False, default=False,

View File

@@ -18,11 +18,20 @@ def configure_session_factory() -> None:
if _engine is not None: if _engine is not None:
_engine.dispose() _engine.dispose()
_engine = create_engine( engine_kwargs = {
settings.resolved_database_url, "echo": settings.sqlalchemy_echo,
echo=settings.sqlalchemy_echo, "pool_pre_ping": True,
pool_pre_ping=True, }
) if not settings.resolved_database_url.startswith("sqlite"):
engine_kwargs.update(
{
"pool_size": max(1, int(settings.sqlalchemy_pool_size or 10)),
"max_overflow": max(0, int(settings.sqlalchemy_max_overflow or 20)),
"pool_timeout": max(1, int(settings.sqlalchemy_pool_timeout or 30)),
}
)
_engine = create_engine(settings.resolved_database_url, **engine_kwargs)
_session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False) _session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False)

View File

@@ -25,6 +25,23 @@ from app.services.knowledge_rag import shutdown_knowledge_rag_runtime
from app.services.knowledge_scheduler import knowledge_index_scheduler from app.services.knowledge_scheduler import knowledge_index_scheduler
def _effective_server_workers(settings: object) -> int:
server_workers = getattr(settings, "server_workers", None)
web_concurrency = getattr(settings, "web_concurrency", None)
workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers
try:
return max(1, int(workers or 1))
except (TypeError, ValueError):
return 1
def _should_start_background_schedulers(settings: object) -> bool:
if not bool(getattr(settings, "background_schedulers_enabled", True)):
return False
return _effective_server_workers(settings) <= 1
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]: async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings = get_settings() settings = get_settings()
@@ -34,11 +51,19 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
prepare_agent_foundation() prepare_agent_foundation()
prepare_knowledge_library() prepare_knowledge_library()
sync_repository_hermes_skills() sync_repository_hermes_skills()
knowledge_index_scheduler.start() schedulers_started = _should_start_background_schedulers(settings)
finance_dashboard_scheduler.start() if schedulers_started:
employee_profile_scheduler.start() knowledge_index_scheduler.start()
digital_employee_reminder_scheduler.start() finance_dashboard_scheduler.start()
finance_report_scheduler.start() employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
finance_report_scheduler.start()
else:
logger.warning(
"Background schedulers skipped - workers=%s enabled=%s",
_effective_server_workers(settings),
settings.background_schedulers_enabled,
)
logger.info( logger.info(
"Server ready - host=%s port=%s prefix=%s", "Server ready - host=%s port=%s prefix=%s",
settings.app_host, settings.app_host,
@@ -46,11 +71,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.api_v1_prefix, settings.api_v1_prefix,
) )
yield yield
finance_report_scheduler.shutdown() if schedulers_started:
digital_employee_reminder_scheduler.shutdown() finance_report_scheduler.shutdown()
employee_profile_scheduler.shutdown() digital_employee_reminder_scheduler.shutdown()
finance_dashboard_scheduler.shutdown() employee_profile_scheduler.shutdown()
knowledge_index_scheduler.shutdown() finance_dashboard_scheduler.shutdown()
knowledge_index_scheduler.shutdown()
knowledge_index_task_manager.shutdown() knowledge_index_task_manager.shutdown()
shutdown_knowledge_rag_runtime() shutdown_knowledge_rag_runtime()

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -28,6 +30,74 @@ class AgentRunRepository:
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit) stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
def list_light(
self,
*,
agent: str | None = None,
status: str | None = None,
source: str | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
stmt = select(
AgentRun.id.label("id"),
AgentRun.run_id.label("run_id"),
AgentRun.agent.label("agent"),
AgentRun.source.label("source"),
AgentRun.user_id.label("user_id"),
AgentRun.task_id.label("task_id"),
AgentRun.permission_level.label("permission_level"),
AgentRun.status.label("status"),
AgentRun.result_summary.label("result_summary"),
AgentRun.error_message.label("error_message"),
AgentRun.started_at.label("started_at"),
AgentRun.finished_at.label("finished_at"),
AgentRun.route_json["job_type"].as_string().label("route_job_type"),
AgentRun.route_json["task_type"].as_string().label("route_task_type"),
AgentRun.route_json["task_code"].as_string().label("route_task_code"),
AgentRun.route_json["task_name"].as_string().label("route_task_name"),
AgentRun.route_json["task_title"].as_string().label("route_task_title"),
AgentRun.route_json["asset_name"].as_string().label("route_asset_name"),
AgentRun.route_json["selected_agent"].as_string().label("route_selected_agent"),
AgentRun.route_json["phase"].as_string().label("route_phase"),
AgentRun.route_json["stage"].as_string().label("route_stage"),
AgentRun.route_json["report_type"].as_string().label("route_report_type"),
AgentRun.route_json["snapshot_key"].as_string().label("route_snapshot_key"),
AgentRun.route_json["folder"].as_string().label("route_folder"),
AgentRun.route_json["heartbeat_at"].as_string().label("route_heartbeat_at"),
AgentRun.route_json["progress"].label("route_progress"),
AgentRun.ontology_json["scenario"].as_string().label("ontology_scenario"),
AgentRun.ontology_json["intent"].as_string().label("ontology_intent"),
AgentRun.ontology_json["parse_strategy"].as_string().label("ontology_parse_strategy"),
)
if agent:
stmt = stmt.where(AgentRun.agent == agent)
if status:
stmt = stmt.where(AgentRun.status == status)
if source:
stmt = stmt.where(AgentRun.source == source)
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
def list_light_tool_calls(self, run_ids: list[str]) -> list[dict[str, Any]]:
if not run_ids:
return []
stmt = (
select(
AgentToolCall.id.label("id"),
AgentToolCall.run_id.label("run_id"),
AgentToolCall.tool_type.label("tool_type"),
AgentToolCall.tool_name.label("tool_name"),
AgentToolCall.status.label("status"),
AgentToolCall.duration_ms.label("duration_ms"),
AgentToolCall.error_message.label("error_message"),
AgentToolCall.created_at.label("created_at"),
)
.where(AgentToolCall.run_id.in_(run_ids))
.order_by(AgentToolCall.created_at.asc())
)
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
def get_by_run_id(self, run_id: str) -> AgentRun | None: def get_by_run_id(self, run_id: str) -> AgentRun | None:
stmt = select(AgentRun).where(AgentRun.run_id == run_id) stmt = select(AgentRun).where(AgentRun.run_id == run_id)
return self.db.scalar(stmt) return self.db.scalar(stmt)

View File

@@ -28,7 +28,7 @@ class NotificationStatePatch(BaseModel):
class NotificationStateBatchPatch(BaseModel): class NotificationStateBatchPatch(BaseModel):
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100) states: list[NotificationStatePatch] = Field(default_factory=list, max_length=500)
class NotificationStateRead(BaseModel): class NotificationStateRead(BaseModel):

View File

@@ -8,6 +8,18 @@ from pydantic import BaseModel, Field
StewardTaskType = Literal["expense_application", "reimbursement"] StewardTaskType = Literal["expense_application", "reimbursement"]
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"] StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"] StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
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",
"submit_current_application",
"continue_next_task",
"fill_current_slot",
"ask_user",
"cancel_current_action",
"no_op",
]
StewardTaskStatus = Literal[ StewardTaskStatus = Literal[
"planned", "planned",
"needs_confirmation", "needs_confirmation",
@@ -88,3 +100,50 @@ class StewardPlanResponse(BaseModel):
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。") attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。") confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。") model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
class StewardSlotOption(BaseModel):
label: str = Field(description="用户可见选项文案。")
value: str = Field(description="写回本体字段的选项值。")
field_key: str = Field(description="对应 canonical ontology field。")
description: str = Field(default="", description="选项说明。")
class StewardSlotDecisionRequest(BaseModel):
task_type: StewardTaskType = Field(description="当前小财管家正在推进的任务类型。")
user_message: str = Field(description="用户原始话术或小财管家携带的任务上下文。")
ontology_fields: dict[str, str] = Field(default_factory=dict, description="当前已抽取的 canonical ontology 字段。")
missing_fields: list[str] = Field(default_factory=list, description="上游意图识别给出的 canonical 缺失字段。")
task_context: dict[str, Any] = Field(default_factory=dict, description="当前任务、附件、申请预览等上下文。")
class StewardSlotDecisionResponse(BaseModel):
decision_source: StewardSlotDecisionSource = Field(default="rule_fallback", description="字段决策来源。")
next_action: StewardSlotNextAction = Field(description="下一步应追问用户还是展示核对结果。")
required_fields: list[str] = Field(default_factory=list, description="模型认为当前业务需要的 canonical 字段。")
missing_fields: list[str] = Field(default_factory=list, description="当前仍缺失的 canonical 字段。")
question: str = Field(default="", description="需要追问时展示给用户的问题。")
options: list[StewardSlotOption] = Field(default_factory=list, description="可直接选择的补充选项。")
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
class StewardRuntimeDecisionRequest(BaseModel):
user_message: str = Field(description="用户当前输入。")
session_type: str = Field(default="steward", description="当前前端会话类型。")
runtime_state: dict[str, Any] = Field(default_factory=dict, description="小财管家运行时上下文。")
context_json: dict[str, Any] = Field(default_factory=dict, description="调用方补充上下文。")
class StewardRuntimeDecisionResponse(BaseModel):
decision_source: StewardRuntimeDecisionSource = Field(default="rule_fallback", description="运行时决策来源。")
next_action: StewardRuntimeNextAction = Field(description="小财管家下一步动作。")
target_task_id: str = Field(default="", description="关联的小财管家任务 ID。")
target_message_id: str = Field(default="", description="关联的前端消息 ID。")
field_key: str = Field(default="", description="补字段时对应 canonical ontology field。")
field_value: str = Field(default="", description="补字段时用户提供的字段值。")
confirmation_required: bool = Field(default=False, description="执行该动作前是否仍需要用户二次确认。")
question: str = Field(default="", description="需要追问用户时展示的问题。")
response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。")
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")

View File

@@ -24,6 +24,8 @@ COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimburs
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx" COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement" COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx" COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement"
COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx"
FINANCE_RULES_LIBRARY = "finance-rules" FINANCE_RULES_LIBRARY = "finance-rules"
RISK_RULES_LIBRARY = "risk-rules" RISK_RULES_LIBRARY = "risk-rules"
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY} RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}

View File

@@ -17,6 +17,7 @@ from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
FINANCE_RULES_LIBRARY, FINANCE_RULES_LIBRARY,
AgentAssetSpreadsheetManager, AgentAssetSpreadsheetManager,
@@ -26,6 +27,8 @@ from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_RUNTIME_CONFIG, ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
COMPANY_PREAPPROVAL_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION, COMPANY_TRAVEL_RULE_VERSION,
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
@@ -301,6 +304,35 @@ class AgentFoundationAssetSeedMixin:
"rule_template_label": "通信费报销 Excel 模板", "rule_template_label": "通信费报销 Excel 模板",
}, },
) )
company_preapproval_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_PREAPPROVAL_RULE_CODE,
name="公司费用申请审批规则",
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宣",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
published_version=COMPANY_PREAPPROVAL_RULE_VERSION,
working_version=COMPANY_PREAPPROVAL_RULE_VERSION,
config_json={
"severity": "high",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"expense_types": ["meal", "entertainment", "office", "all"],
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": "费用申请审批 Excel 模板",
},
)
skill_expense_asset = AgentAsset( skill_expense_asset = AgentAsset(
asset_type=AgentAssetType.SKILL.value, asset_type=AgentAssetType.SKILL.value,
code="skill.expense.summary_lookup", code="skill.expense.summary_lookup",
@@ -468,6 +500,7 @@ class AgentFoundationAssetSeedMixin:
*platform_risk_assets, *platform_risk_assets,
company_travel_rule, company_travel_rule,
company_communication_rule, company_communication_rule,
company_preapproval_rule,
skill_expense_asset, skill_expense_asset,
skill_ar_asset, skill_ar_asset,
invoice_mcp_asset, invoice_mcp_asset,
@@ -495,6 +528,11 @@ class AgentFoundationAssetSeedMixin:
version=COMPANY_COMMUNICATION_RULE_VERSION, version=COMPANY_COMMUNICATION_RULE_VERSION,
actor_name="系统初始化", actor_name="系统初始化",
) )
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
company_preapproval_rule,
version=COMPANY_PREAPPROVAL_RULE_VERSION,
actor_name="系统初始化",
)
self._hide_deprecated_finance_rule_assets() self._hide_deprecated_finance_rule_assets()
@@ -581,6 +619,18 @@ class AgentFoundationAssetSeedMixin:
change_note="初始化通信费报销 Excel 规则表。", change_note="初始化通信费报销 Excel 规则表。",
created_by="系统初始化", created_by="系统初始化",
), ),
AgentAssetVersion(
asset=company_preapproval_rule,
version=COMPANY_PREAPPROVAL_RULE_VERSION,
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=company_preapproval_rule.name,
version=COMPANY_PREAPPROVAL_RULE_VERSION,
metadata=company_preapproval_rule_meta,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化费用申请审批 Excel 规则表。",
created_by="系统初始化",
),
AgentAssetVersion( AgentAssetVersion(
asset=skill_expense_asset, asset=skill_expense_asset,
version="v1.0.0", version="v1.0.0",

View File

@@ -16,6 +16,7 @@ from app.models.agent_asset import AgentAsset
from app.models.agent_run import AgentRun from app.models.agent_run import AgentRun
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
FINANCE_RULES_LIBRARY, FINANCE_RULES_LIBRARY,
AgentAssetSpreadsheetManager, AgentAssetSpreadsheetManager,
@@ -25,6 +26,8 @@ from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_RUNTIME_CONFIG, ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
COMPANY_PREAPPROVAL_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION, COMPANY_TRAVEL_RULE_VERSION,
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
@@ -115,6 +118,10 @@ class AgentFoundationAssetTopUpMixin:
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE) select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
) )
company_preapproval_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_PREAPPROVAL_RULE_CODE)
)
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes: if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
attachment_rule = self._create_seed_asset( attachment_rule = self._create_seed_asset(
@@ -392,6 +399,36 @@ class AgentFoundationAssetTopUpMixin:
}, },
) )
if COMPANY_PREAPPROVAL_RULE_CODE not in existing_codes:
company_preapproval_rule = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_PREAPPROVAL_RULE_CODE,
name="公司费用申请审批规则",
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宣",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
config_json={
"severity": "high",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"expense_types": ["meal", "entertainment", "office", "all"],
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": "费用申请审批 Excel 模板",
},
)
if company_travel_rule is not None: if company_travel_rule is not None:
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON) company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
if not str(company_travel_rule.current_version or "").strip(): if not str(company_travel_rule.current_version or "").strip():
@@ -536,6 +573,77 @@ class AgentFoundationAssetTopUpMixin:
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
) )
if company_preapproval_rule is not None:
company_preapproval_rule.scenario_json = list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
if not str(company_preapproval_rule.current_version or "").strip():
company_preapproval_rule.current_version = COMPANY_PREAPPROVAL_RULE_VERSION
if not str(company_preapproval_rule.working_version or "").strip():
company_preapproval_rule.working_version = company_preapproval_rule.current_version
if not str(company_preapproval_rule.published_version or "").strip():
company_preapproval_rule.published_version = company_preapproval_rule.current_version
if not str(company_preapproval_rule.status or "").strip():
company_preapproval_rule.status = AgentAssetStatus.ACTIVE.value
company_preapproval_rule.description = (
"通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。"
)
company_preapproval_rule.config_json = {
**(company_preapproval_rule.config_json or {}),
"severity": "high",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"expense_types": ["meal", "entertainment", "office", "all"],
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": "费用申请审批 Excel 模板",
}
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
company_preapproval_rule,
version=str(
company_preapproval_rule.current_version
or COMPANY_PREAPPROVAL_RULE_VERSION
),
actor_name="系统初始化",
)
self._ensure_asset_version(
company_preapproval_rule,
version=str(
company_preapproval_rule.current_version
or COMPANY_PREAPPROVAL_RULE_VERSION
),
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=company_preapproval_rule.name,
version=str(
company_preapproval_rule.current_version
or COMPANY_PREAPPROVAL_RULE_VERSION
),
metadata=company_preapproval_rule_meta,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化费用申请审批 Excel 规则表。",
created_by="系统初始化",
)
if (
str(company_preapproval_rule.current_version or "").strip()
== COMPANY_PREAPPROVAL_RULE_VERSION
):
self._ensure_asset_review(
company_preapproval_rule,
version=COMPANY_PREAPPROVAL_RULE_VERSION,
reviewer="顾承宣",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版费用申请审批规则表已确认,可作为财务规则使用。",
reviewed_at=datetime.now(UTC),
)
self._hide_deprecated_finance_rule_assets() self._hide_deprecated_finance_rule_assets()
if "skill.ar.aging_summary" not in existing_codes: if "skill.ar.aging_summary" not in existing_codes:

View File

@@ -82,10 +82,14 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0" COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
COMPANY_PREAPPROVAL_RULE_VERSION = "v1.0.0"
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",) COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",)
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",) COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",)
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON = ("费用申请",)
DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估") DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize" DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"

View File

@@ -12,6 +12,8 @@ from app.models.agent_asset import AgentAsset
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY, FINANCE_RULES_LIBRARY,
@@ -19,6 +21,7 @@ from app.services.agent_asset_spreadsheet import (
) )
from app.services.agent_foundation_constants import ( from app.services.agent_foundation_constants import (
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_SCENARIO_JSON,
) )
from app.services.finance_rule_catalog import ( from app.services.finance_rule_catalog import (
@@ -54,6 +57,14 @@ class AgentFoundationSpreadsheetMixin:
expense_types=["communication"], expense_types=["communication"],
) )
) )
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_PREAPPROVAL_RULE_CODE,
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="费用申请审批规则",
expense_types=["meal", "entertainment", "office", "all"],
)
)
return synced_count return synced_count
def _ensure_core_finance_rule_asset( def _ensure_core_finance_rule_asset(
@@ -92,14 +103,19 @@ class AgentFoundationSpreadsheetMixin:
asset.status = AgentAssetStatus.DISABLED.value asset.status = AgentAssetStatus.DISABLED.value
asset.scenario_json = ["已废弃"] asset.scenario_json = ["已废弃"]
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code) replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
deprecated_reason = ( if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。" deprecated_reason = (
if replacement "交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
else ( )
elif replacement == COMPANY_PREAPPROVAL_RULE_CODE:
deprecated_reason = (
"申请审批阈值已并入公司费用申请审批规则,不再作为独立财务规则展示。"
)
else:
deprecated_reason = (
"该费用类型没有独立职务金额分档,额度控制转入预算中心," "该费用类型没有独立职务金额分档,额度控制转入预算中心,"
"不再作为独立财务规则表展示。" "不再作为独立财务规则表展示。"
) )
)
asset.config_json = { asset.config_json = {
**(asset.config_json or {}), **(asset.config_json or {}),
"enabled": False, "enabled": False,
@@ -258,6 +274,93 @@ class AgentFoundationSpreadsheetMixin:
) )
def _ensure_company_preapproval_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
):
return self._ensure_finance_rule_spreadsheet_seed(
asset,
version=version,
actor_name=actor_name,
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
fallback_sheet_name="费用申请审批规则",
workbook_sheets=[
(
"费用申请审批规则",
[
[
"费用类型代码",
"费用类型",
"触发条件",
"阈值金额",
"前置要求",
"审批要求",
"风险动作",
"备注",
],
[
"meal/entertainment",
"业务招待费",
"单次费用金额大于 500 元",
500,
"必须先提交费用申请单,并说明客户、参与人和招待事由",
"申请单需按审批链完成审批后方可报销",
"报销阶段未关联已通过申请单时标记高风险",
"适配 meal 与 entertainment 两个本体费用类型",
],
[
"office",
"办公用品费",
"单次或批量采购金额大于 2000 元",
2000,
"必须先提交办公采购或费用申请单",
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
"报销阶段未关联已通过申请单时标记高风险",
"覆盖办公用品、办公耗材、低值易耗品等场景",
],
[
"all",
"通用大额费用",
"任意费用金额大于 2000 元",
2000,
"必须进入费用申请和审批流程",
"至少完成直属领导审批;按预算和财务规则继续流转",
"报销阶段未关联已通过申请单时标记高风险",
"差旅、通信等已有专项规则时可同时适用专项规则",
],
],
),
(
"字段说明",
[
["字段", "说明"],
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
["审批要求", "说明申请单进入审批链后的最低审批要求"],
["风险动作", "说明报销阶段未满足规则时的系统处理"],
],
),
],
)
@staticmethod @staticmethod
def _read_or_build_company_travel_rule_file( def _read_or_build_company_travel_rule_file(

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
@@ -24,6 +25,34 @@ logger = get_logger("app.services.agent_runs")
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30) KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"} KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"}
LIST_ROUTE_FIELDS = (
("route_job_type", "job_type"),
("route_task_type", "task_type"),
("route_task_code", "task_code"),
("route_task_name", "task_name"),
("route_task_title", "task_title"),
("route_asset_name", "asset_name"),
("route_selected_agent", "selected_agent"),
("route_phase", "phase"),
("route_stage", "stage"),
("route_report_type", "report_type"),
("route_snapshot_key", "snapshot_key"),
("route_folder", "folder"),
("route_heartbeat_at", "heartbeat_at"),
)
LIST_ONTOLOGY_FIELDS = (
("ontology_scenario", "scenario"),
("ontology_intent", "intent"),
("ontology_parse_strategy", "parse_strategy"),
)
LIST_PROGRESS_FIELDS = {
"percent",
"total_documents",
"completed_documents",
"failed_documents",
"skipped_documents",
"current_stage",
}
class AgentRunService: class AgentRunService:
@@ -41,8 +70,22 @@ class AgentRunService:
) -> list[AgentRunRead]: ) -> list[AgentRunRead]:
self._ensure_ready() self._ensure_ready()
self._reconcile_stale_knowledge_index_runs() self._reconcile_stale_knowledge_index_runs()
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit) rows = self.repository.list_light(
return [self._serialize_run(item) for item in runs] agent=agent,
status=status,
source=source,
limit=limit,
)
tool_calls_by_run_id = self._group_light_tool_calls(
self.repository.list_light_tool_calls([str(item["run_id"]) for item in rows])
)
return [
self._serialize_run_list_item(
item,
tool_calls_by_run_id.get(str(item["run_id"]), []),
)
for item in rows
]
def get_run(self, run_id: str) -> AgentRunRead | None: def get_run(self, run_id: str) -> AgentRunRead | None:
self._ensure_ready() self._ensure_ready()
@@ -435,3 +478,99 @@ class AgentRunService:
if semantic_parse if semantic_parse
else None, else None,
) )
def _serialize_run_list_item(
self,
row: dict[str, Any],
tool_calls: list[dict[str, Any]],
) -> AgentRunRead:
return AgentRunRead(
id=str(row["id"]),
run_id=str(row["run_id"]),
agent=str(row["agent"]),
source=str(row["source"]),
user_id=row.get("user_id"),
task_id=row.get("task_id"),
ontology_json=self._build_list_ontology_json(row),
route_json=self._build_list_route_json(row),
permission_level=str(row["permission_level"]),
status=str(row["status"]),
result_summary=row.get("result_summary"),
error_message=row.get("error_message"),
started_at=row["started_at"],
finished_at=row.get("finished_at"),
tool_calls=[self._serialize_light_tool_call(item) for item in tool_calls],
semantic_parse=None,
)
def _build_list_route_json(self, row: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
for source_key, target_key in LIST_ROUTE_FIELDS:
self._set_if_present(payload, target_key, row.get(source_key))
progress = self._coerce_json_object(row.get("route_progress"))
compact_progress = {
key: value
for key, value in progress.items()
if key in LIST_PROGRESS_FIELDS and self._is_scalar_json_value(value)
}
if compact_progress:
payload["progress"] = compact_progress
return payload
def _build_list_ontology_json(self, row: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
for source_key, target_key in LIST_ONTOLOGY_FIELDS:
self._set_if_present(payload, target_key, row.get(source_key))
return payload
def _serialize_light_tool_call(self, row: dict[str, Any]) -> AgentToolCallRead:
return AgentToolCallRead(
id=str(row["id"]),
run_id=str(row["run_id"]),
tool_type=str(row["tool_type"]),
tool_name=str(row["tool_name"]),
request_json={},
response_json={},
status=str(row["status"]),
duration_ms=int(row.get("duration_ms") or 0),
error_message=row.get("error_message"),
created_at=row["created_at"],
)
@staticmethod
def _group_light_tool_calls(
tool_calls: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
grouped: dict[str, list[dict[str, Any]]] = {}
for tool_call in tool_calls:
grouped.setdefault(str(tool_call.get("run_id") or ""), []).append(tool_call)
return grouped
@staticmethod
def _coerce_json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
normalized = value.strip()
if normalized.startswith("{") and normalized.endswith("}"):
try:
loaded = json.loads(normalized)
except json.JSONDecodeError:
return {}
return loaded if isinstance(loaded, dict) else {}
return {}
@staticmethod
def _set_if_present(payload: dict[str, Any], key: str, value: Any) -> None:
if value is None:
return
if isinstance(value, str) and not value.strip():
return
if not AgentRunService._is_scalar_json_value(value):
return
payload[key] = value
@staticmethod
def _is_scalar_json_value(value: Any) -> bool:
return value is None or isinstance(value, str | int | float | bool)

View File

@@ -12,7 +12,7 @@ from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.role import Role from app.models.role import Role
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE, APPLICATION_ARCHIVE_STAGE,
ARCHIVE_ACCOUNTING_STAGE, ARCHIVE_ACCOUNTING_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
@@ -30,7 +30,7 @@ BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
BUDGET_MONITOR_APPROVAL_GRADE = "P8" BUDGET_MONITOR_APPROVAL_GRADE = "P8"
CLAIM_DELETE_ROLE_CODES = {"executive"} CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed") APPLICATION_ARCHIVED_STAGES = (APPLICATION_ARCHIVE_STAGE,)
ARCHIVED_REIMBURSEMENT_STAGES = ( ARCHIVED_REIMBURSEMENT_STAGES = (
ARCHIVE_ACCOUNTING_STAGE, ARCHIVE_ACCOUNTING_STAGE,
PAYMENT_PAID_STAGE, PAYMENT_PAID_STAGE,
@@ -67,24 +67,31 @@ class ExpenseClaimAccessPolicy:
normalized_type == "application", normalized_type == "application",
normalized_type.like("%\\_application", escape="\\"), normalized_type.like("%\\_application", escape="\\"),
) )
return or_( reimbursement_condition = and_(
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES), ~application_condition,
stage == "completed", or_(
and_( stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
application_condition, stage == "completed",
normalized_status.in_(ARCHIVED_CLAIM_STATUSES), and_(
stage.in_(APPLICATION_ARCHIVED_STAGES), normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
), or_(
and_( stage == "",
normalized_status.in_(ARCHIVED_CLAIM_STATUSES), stage.is_(None),
or_( stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
stage == "", stage == "completed",
stage.is_(None), ),
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
stage == "completed",
), ),
), ),
) )
application_archive_condition = and_(
application_condition,
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
stage.in_(APPLICATION_ARCHIVED_STAGES),
)
return or_(
reimbursement_condition,
application_archive_condition,
)
@staticmethod @staticmethod
def has_claim_delete_access(current_user: CurrentUserContext) -> bool: def has_claim_delete_access(current_user: CurrentUserContext) -> bool:
@@ -96,8 +103,6 @@ class ExpenseClaimAccessPolicy:
def is_archived_claim(claim: ExpenseClaim) -> bool: def is_archived_claim(claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower() normalized_status = str(claim.status or "").strip().lower()
stage = str(claim.approval_stage or "").strip() stage = str(claim.approval_stage or "").strip()
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
return True
normalized_type = str(claim.expense_type or "").strip().lower() normalized_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper() claim_no = str(claim.claim_no or "").strip().upper()
is_application_claim = ( is_application_claim = (
@@ -105,11 +110,9 @@ class ExpenseClaimAccessPolicy:
or normalized_type == "application" or normalized_type == "application"
or normalized_type.endswith("_application") or normalized_type.endswith("_application")
) )
if ( if is_application_claim:
is_application_claim return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in APPLICATION_ARCHIVED_STAGES
and normalized_status in ARCHIVED_CLAIM_STATUSES if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
and stage in APPLICATION_ARCHIVED_STAGES
):
return True return True
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES} return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES}

View File

@@ -5,7 +5,11 @@ from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy import or_, select
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE
APPLICATION_REIMBURSEMENT_TYPE_MAP = { APPLICATION_REIMBURSEMENT_TYPE_MAP = {
@@ -15,6 +19,7 @@ APPLICATION_REIMBURSEMENT_TYPE_MAP = {
"expense_application": "other", "expense_application": "other",
"application": "other", "application": "other",
} }
APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"}
class ExpenseClaimApplicationHandoffMixin: class ExpenseClaimApplicationHandoffMixin:
@@ -130,3 +135,116 @@ class ExpenseClaimApplicationHandoffMixin:
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft" approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}" approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}"
return draft_claim return draft_claim
@staticmethod
def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]:
application_ids: set[str] = set()
application_nos: set[str] = set()
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_reference = any(
str(flag.get(key) or "").strip()
for key in (
"application_claim_id",
"applicationClaimId",
"application_claim_no",
"applicationClaimNo",
)
)
if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference:
continue
application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip()
application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip()
if application_id:
application_ids.add(application_id)
if application_no:
application_nos.add(application_no)
return application_ids, application_nos
def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]:
application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim)
conditions = []
if application_ids:
conditions.append(ExpenseClaim.id.in_(application_ids))
if application_nos:
conditions.append(ExpenseClaim.claim_no.in_(application_nos))
if not conditions:
return []
claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all())
return [claim for claim in claims if self._is_expense_application_claim(claim)]
def _archive_linked_applications_after_reimbursement_paid(
self,
*,
reimbursement_claim: ExpenseClaim,
payment_flag: dict[str, Any],
operator: str,
current_user: Any,
) -> list[dict[str, str]]:
archived_applications: list[dict[str, str]] = []
payment_event_id = str(payment_flag.get("payment_event_id") or "").strip()
for application_claim in self._find_linked_application_claims(reimbursement_claim):
previous_status = str(application_claim.status or "").strip()
previous_stage = str(application_claim.approval_stage or "").strip()
if previous_stage == APPLICATION_ARCHIVE_STAGE:
continue
normalized_status = previous_status.lower()
if normalized_status not in {"approved", "completed"}:
continue
before_json = self._serialize_claim(application_claim)
archive_flag = with_risk_business_stage(
{
"source": "application_archive_sync",
"event_type": "expense_application_archived_by_reimbursement",
"archive_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "申请归档",
"message": (
f"关联报销单 {reimbursement_claim.claim_no} 已完成付款,"
"系统同步将申请单归档。"
),
"operator": operator,
"operator_username": getattr(current_user, "username", ""),
"operator_role_codes": [
str(item).strip().lower()
for item in getattr(current_user, "role_codes", [])
if str(item).strip()
],
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
"reimbursement_claim_id": reimbursement_claim.id,
"reimbursement_claim_no": reimbursement_claim.claim_no,
"payment_event_id": payment_event_id,
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"next_status": "approved",
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
"created_at": datetime.now(UTC).isoformat(),
},
"expense_application",
)
application_claim.status = "approved"
application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE
application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag]
archived_applications.append(
{
"application_claim_id": application_claim.id,
"application_claim_no": str(application_claim.claim_no or "").strip(),
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
}
)
self.audit_service.log_action(
actor=operator,
action="expense_application.archive_by_reimbursement",
resource_type="expense_claim",
resource_id=application_claim.id,
before_json=before_json,
after_json=self._serialize_claim(application_claim),
)
return archived_applications

View File

@@ -6,7 +6,7 @@ from typing import Any
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE, APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE,
@@ -62,7 +62,7 @@ class ExpenseClaimApprovalFlowMixin:
if merged_budget_approval: if merged_budget_approval:
label = "领导及预算审核通过" label = "领导及预算审核通过"
next_status = "approved" next_status = "approved"
next_stage = APPROVAL_DONE_STAGE next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。" default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
elif requires_budget_review: elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
@@ -73,7 +73,7 @@ class ExpenseClaimApprovalFlowMixin:
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。" default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
else: else:
next_status = "approved" next_status = "approved"
next_stage = APPROVAL_DONE_STAGE next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。" default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
else: else:
if requires_budget_review: if requires_budget_review:
@@ -99,7 +99,7 @@ class ExpenseClaimApprovalFlowMixin:
label = "预算管理者审核通过" label = "预算管理者审核通过"
if is_application_claim: if is_application_claim:
next_status = "approved" next_status = "approved"
next_stage = APPROVAL_DONE_STAGE next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。" default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
else: else:
next_status = "submitted" next_status = "submitted"
@@ -186,7 +186,7 @@ class ExpenseClaimApprovalFlowMixin:
claim.approval_stage = next_stage claim.approval_stage = next_stage
if claim.submitted_at is None: if claim.submitted_at is None:
claim.submitted_at = datetime.now(UTC) claim.submitted_at = datetime.now(UTC)
if is_application_claim and next_stage == APPROVAL_DONE_STAGE: if is_application_claim and next_stage == APPLICATION_LINK_STATUS_STAGE:
if previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: if previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion( approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion(
claim, claim,
@@ -289,6 +289,15 @@ class ExpenseClaimApprovalFlowMixin:
"reimbursement", "reimbursement",
) )
archived_applications = self._archive_linked_applications_after_reimbursement_paid(
reimbursement_claim=claim,
payment_flag=payment_flag,
operator=operator,
current_user=current_user,
)
if archived_applications:
payment_flag["archived_application_claims"] = archived_applications
claim.status = PAYMENT_PAID_STATUS claim.status = PAYMENT_PAID_STATUS
claim.approval_stage = PAYMENT_PAID_STAGE claim.approval_stage = PAYMENT_PAID_STAGE
claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag] claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag]

View File

@@ -63,6 +63,8 @@ def build_platform_risk_flag(
"rule_type": "risk", "rule_type": "risk",
"rule_code": str(manifest.get("rule_code") or "").strip(), "rule_code": str(manifest.get("rule_code") or "").strip(),
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(), "rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
"finance_rule_code": str(manifest.get("finance_rule_code") or "").strip(),
"finance_rule_sheet": str(manifest.get("finance_rule_sheet") or "").strip(),
"severity": severity, "severity": severity,
"action": action, "action": action,
"label": label, "label": label,

View File

@@ -5,6 +5,8 @@ from typing import Any
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE, APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
ARCHIVE_ACCOUNTING_STAGE, ARCHIVE_ACCOUNTING_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
@@ -73,6 +75,8 @@ CANONICAL_APPROVAL_STAGES = {
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE,
APPROVAL_DONE_STAGE, APPROVAL_DONE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
APPLICATION_ARCHIVE_STAGE,
ARCHIVE_ACCOUNTING_STAGE, ARCHIVE_ACCOUNTING_STAGE,
PAYMENT_PENDING_STAGE, PAYMENT_PENDING_STAGE,
PAYMENT_PAID_STAGE, PAYMENT_PAID_STAGE,
@@ -214,8 +218,10 @@ def _approved_stage(raw_stage: str, is_application_claim: bool) -> str:
stage = _normalize_stage_alias(raw_stage) stage = _normalize_stage_alias(raw_stage)
lowered = str(raw_stage or "").strip().lower() lowered = str(raw_stage or "").strip().lower()
if is_application_claim: if is_application_claim:
if not stage or lowered == "completed": if stage == APPLICATION_ARCHIVE_STAGE:
return APPROVAL_DONE_STAGE return APPLICATION_ARCHIVE_STAGE
if not stage or lowered == "completed" or stage == APPROVAL_DONE_STAGE:
return APPLICATION_LINK_STATUS_STAGE
return stage return stage
if stage in {ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE}: if stage in {ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE}:
return stage return stage

View File

@@ -2,6 +2,8 @@ DIRECT_MANAGER_APPROVAL_STAGE = "直属领导审批"
BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批" BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批"
FINANCE_APPROVAL_STAGE = "财务审批" FINANCE_APPROVAL_STAGE = "财务审批"
APPROVAL_DONE_STAGE = "审批完成" APPROVAL_DONE_STAGE = "审批完成"
APPLICATION_LINK_STATUS_STAGE = "关联单据状态"
APPLICATION_ARCHIVE_STAGE = "申请归档"
ARCHIVE_ACCOUNTING_STAGE = "归档入账" ARCHIVE_ACCOUNTING_STAGE = "归档入账"
PAYMENT_PENDING_STATUS = "pending_payment" PAYMENT_PENDING_STATUS = "pending_payment"
PAYMENT_PAID_STATUS = "paid" PAYMENT_PAID_STATUS = "paid"

View File

@@ -858,7 +858,7 @@ class ExpenseClaimService(
self._release_budget_for_delete(claim, current_user) self._release_budget_for_delete(claim, current_user)
self._delete_claim_analysis_records(resource_id) self._delete_claim_analysis_records(resource_id)
self._attachment_storage.delete_claim_files(claim) self._attachment_storage.delete_claim_files(claim)
ReceiptFolderService().delete_receipts_for_claim(resource_id) ReceiptFolderService().unlink_receipts_for_claim(resource_id)
self.db.delete(claim) self.db.delete(claim)
self.db.commit() self.db.commit()
@@ -1021,4 +1021,3 @@ class ExpenseClaimService(

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from app.services.agent_asset_spreadsheet import COMPANY_TRAVEL_EXPENSE_RULE_CODE from app.services.agent_asset_spreadsheet import (
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
)
DEPRECATED_FINANCE_RULE_CODES = ( DEPRECATED_FINANCE_RULE_CODES = (
"rule.expense.company_transport_hotel_detail_reimbursement", "rule.expense.company_transport_hotel_detail_reimbursement",
@@ -17,4 +20,6 @@ DEPRECATED_FINANCE_RULE_REPLACEMENTS = {
"rule.expense.company_transport_hotel_detail_reimbursement": ( "rule.expense.company_transport_hotel_detail_reimbursement": (
COMPANY_TRAVEL_EXPENSE_RULE_CODE COMPANY_TRAVEL_EXPENSE_RULE_CODE
), ),
"rule.expense.company_meal_expense_reimbursement": COMPANY_PREAPPROVAL_RULE_CODE,
"rule.expense.company_office_expense_reimbursement": COMPANY_PREAPPROVAL_RULE_CODE,
} }

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -26,10 +24,23 @@ class HermesEmployeeProfileScannerService:
summary["baseline_summary"] = baseline_summary summary["baseline_summary"] = baseline_summary
logger.info( logger.info(
"Hermes employee profile scan completed: %s", "Hermes employee profile scan completed: %s",
json.dumps(summary, ensure_ascii=False), self._build_log_summary(summary),
) )
return summary return summary
def _build_log_summary(self, summary: dict) -> dict:
baseline_summary = self._as_dict(summary.get("baseline_summary"))
buckets = baseline_summary.get("buckets")
return {
"target_employee_count": self._to_int(summary.get("target_employee_count")),
"snapshot_count": self._to_int(summary.get("snapshot_count")),
"high_attention_employee_count": self._to_int(
summary.get("high_attention_employee_count")
),
"window_days": summary.get("window_days") or [],
"baseline_bucket_count": len(buckets) if isinstance(buckets, list) else 0,
}
def _build_baseline_summary(self) -> dict: def _build_baseline_summary(self) -> dict:
stmt = ( stmt = (
select(ExpenseClaim) select(ExpenseClaim)
@@ -42,3 +53,14 @@ class HermesEmployeeProfileScannerService:
for claim in self.db.scalars(stmt).all() for claim in self.db.scalars(stmt).all()
] ]
return ProfileBaselineUpdater().build_from_claims(claims).as_dict() return ProfileBaselineUpdater().build_from_claims(claims).as_dict()
@staticmethod
def _as_dict(value: object) -> dict:
return value if isinstance(value, dict) else {}
@staticmethod
def _to_int(value: object) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0

View File

@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import json import json
import re import re
import shutil import shutil
import subprocess import subprocess
import threading
from collections import OrderedDict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@@ -17,6 +20,7 @@ from app.services.document_intelligence import DocumentIntelligenceService
WORKER_JSON_PREFIX = "__OCR_JSON__=" WORKER_JSON_PREFIX = "__OCR_JSON__="
SUPPORTED_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".pdf"} SUPPORTED_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".pdf"}
OCR_RESULT_CACHE_LIMIT = 32
@dataclass(slots=True) @dataclass(slots=True)
@@ -50,6 +54,12 @@ class AggregatedOcrDocument:
class OcrService: class OcrService:
_cache_lock = threading.Lock()
_result_cache: OrderedDict[str, OcrRecognizeDocumentRead] = OrderedDict()
_worker_semaphore_lock = threading.Lock()
_worker_semaphore: threading.Semaphore | None = None
_worker_semaphore_limit = 0
def __init__(self, db: Session | None = None) -> None: def __init__(self, db: Session | None = None) -> None:
self.settings = get_settings() self.settings = get_settings()
self.document_intelligence_service = DocumentIntelligenceService(db) self.document_intelligence_service = DocumentIntelligenceService(db)
@@ -70,6 +80,7 @@ class OcrService:
python_bin = self._resolve_python_bin() python_bin = self._resolve_python_bin()
worker_path = self._resolve_worker_path() worker_path = self._resolve_worker_path()
worker_payload: dict = {} worker_payload: dict = {}
cache_keys_by_source: dict[str, str] = {}
try: try:
for filename, content, media_type in files: for filename, content, media_type in files:
@@ -109,6 +120,16 @@ class OcrService:
) )
continue continue
cache_key = self._build_cache_key(content)
cached_document = self._read_cached_document(
cache_key,
filename=normalized_name,
media_type=resolved_media_type,
)
if cached_document is not None:
documents.append(cached_document)
continue
temp_path = temp_root / f"{uuid4().hex}{suffix}" temp_path = temp_root / f"{uuid4().hex}{suffix}"
temp_path.write_bytes(content) temp_path.write_bytes(content)
cleanup_paths.append(temp_path) cleanup_paths.append(temp_path)
@@ -116,15 +137,16 @@ class OcrService:
if suffix == ".pdf": if suffix == ".pdf":
try: try:
text_layer = self._extract_pdf_text_layer(temp_path) text_layer = self._extract_pdf_text_layer(temp_path)
prepared_inputs.extend( pdf_inputs = self._prepare_pdf_inputs(
self._prepare_pdf_inputs( pdf_path=temp_path,
pdf_path=temp_path, filename=normalized_name,
filename=normalized_name, media_type=resolved_media_type,
media_type=resolved_media_type, cleanup_paths=cleanup_paths,
cleanup_paths=cleanup_paths, text_layer=text_layer,
text_layer=text_layer,
)
) )
prepared_inputs.extend(pdf_inputs)
for item in pdf_inputs:
cache_keys_by_source.setdefault(item.source_key, cache_key)
except RuntimeError as exc: except RuntimeError as exc:
documents.append( documents.append(
OcrRecognizeDocumentRead( OcrRecognizeDocumentRead(
@@ -135,10 +157,11 @@ class OcrService:
) )
continue continue
source_key = uuid4().hex
prepared_inputs.append( prepared_inputs.append(
PreparedOcrInput( PreparedOcrInput(
input_path=temp_path, input_path=temp_path,
source_key=uuid4().hex, source_key=source_key,
filename=normalized_name, filename=normalized_name,
media_type=resolved_media_type, media_type=resolved_media_type,
preview_kind="image" if resolved_media_type.startswith("image/") else "", preview_kind="image" if resolved_media_type.startswith("image/") else "",
@@ -149,6 +172,7 @@ class OcrService:
), ),
) )
) )
cache_keys_by_source[source_key] = cache_key
if prepared_inputs: if prepared_inputs:
worker_payload = self._invoke_worker( worker_payload = self._invoke_worker(
@@ -156,11 +180,15 @@ class OcrService:
worker_path=worker_path, worker_path=worker_path,
input_paths=[item.input_path for item in prepared_inputs], input_paths=[item.input_path for item in prepared_inputs],
) )
documents.extend( recognized_documents = self._build_documents(
self._build_documents( worker_documents=worker_payload.get("documents", []),
worker_documents=worker_payload.get("documents", []), prepared_inputs=prepared_inputs,
prepared_inputs=prepared_inputs, )
) documents.extend(recognized_documents)
self._write_cached_documents(
recognized_documents,
prepared_inputs=prepared_inputs,
cache_keys_by_source=cache_keys_by_source,
) )
success_count = sum( success_count = sum(
@@ -215,6 +243,79 @@ class OcrService:
raise RuntimeError(f"OCR worker 不存在:{worker_path}") raise RuntimeError(f"OCR worker 不存在:{worker_path}")
return str(worker_path) return str(worker_path)
def _build_cache_key(self, content: bytes) -> str:
digest = hashlib.sha256(content).hexdigest()
return "|".join(
[
self.settings.ocr_language,
self.settings.ocr_text_detection_model,
self.settings.ocr_text_recognition_model,
digest,
]
)
@classmethod
def _read_cached_document(
cls,
cache_key: str,
*,
filename: str,
media_type: str,
) -> OcrRecognizeDocumentRead | None:
if not cache_key:
return None
with cls._cache_lock:
cached = cls._result_cache.get(cache_key)
if cached is None:
return None
cls._result_cache.move_to_end(cache_key)
return cached.model_copy(update={"filename": filename, "media_type": media_type})
@classmethod
def _write_cached_documents(
cls,
documents: list[OcrRecognizeDocumentRead],
*,
prepared_inputs: list[PreparedOcrInput],
cache_keys_by_source: dict[str, str],
) -> None:
if not documents or not cache_keys_by_source:
return
source_order: list[str] = []
seen_sources: set[str] = set()
for item in prepared_inputs:
if item.source_key in seen_sources:
continue
seen_sources.add(item.source_key)
source_order.append(item.source_key)
with cls._cache_lock:
for source_key, document in zip(source_order, documents, strict=False):
cache_key = cache_keys_by_source.get(source_key, "")
if not cache_key:
continue
cls._result_cache[cache_key] = document.model_copy(
update={
"receipt_id": "",
"receipt_status": "",
"receipt_preview_url": "",
"receipt_source_url": "",
}
)
cls._result_cache.move_to_end(cache_key)
while len(cls._result_cache) > OCR_RESULT_CACHE_LIMIT:
cls._result_cache.popitem(last=False)
@classmethod
def _resolve_worker_semaphore(cls, limit: int) -> threading.Semaphore:
normalized_limit = max(1, int(limit or 1))
with cls._worker_semaphore_lock:
if cls._worker_semaphore is None or cls._worker_semaphore_limit != normalized_limit:
cls._worker_semaphore = threading.Semaphore(normalized_limit)
cls._worker_semaphore_limit = normalized_limit
return cls._worker_semaphore
def _invoke_worker( def _invoke_worker(
self, self,
*, *,
@@ -235,13 +336,15 @@ class OcrService:
for path in input_paths: for path in input_paths:
command.extend(["--input", str(path)]) command.extend(["--input", str(path)])
completed = subprocess.run( semaphore = self._resolve_worker_semaphore(self.settings.ocr_max_concurrent_workers)
command, with semaphore:
capture_output=True, completed = subprocess.run(
text=True, command,
timeout=self.settings.ocr_timeout_seconds, capture_output=True,
check=False, text=True,
) timeout=self.settings.ocr_timeout_seconds,
check=False,
)
if completed.returncode != 0: if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip() detail = (completed.stderr or completed.stdout or "").strip()
raise RuntimeError(f"OCR 执行失败:{detail or 'worker 返回非 0 状态码。'}") raise RuntimeError(f"OCR 执行失败:{detail or 'worker 返回非 0 状态码。'}")

View File

@@ -336,11 +336,11 @@ class ReceiptFolderService:
shutil.rmtree(receipt_dir) shutil.rmtree(receipt_dir)
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id) return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id)
def delete_receipts_for_claim(self, claim_id: str) -> int: def unlink_receipts_for_claim(self, claim_id: str) -> int:
normalized_claim_id = str(claim_id or "").strip() normalized_claim_id = str(claim_id or "").strip()
if not normalized_claim_id: if not normalized_claim_id:
return 0 return 0
deleted_count = 0 unlinked_count = 0
self.root.mkdir(parents=True, exist_ok=True) self.root.mkdir(parents=True, exist_ok=True)
for meta_path in list(self.root.glob("*/*/meta.json")): for meta_path in list(self.root.glob("*/*/meta.json")):
try: try:
@@ -349,9 +349,18 @@ class ReceiptFolderService:
continue continue
if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id: if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id:
continue continue
shutil.rmtree(meta_path.parent, ignore_errors=True) meta["status"] = "unlinked"
deleted_count += 1 meta["linked_claim_id"] = ""
return deleted_count meta["linked_claim_no"] = ""
meta["linked_item_id"] = ""
meta["linked_at"] = ""
meta["updated_at"] = datetime.now(UTC).isoformat()
self._write_meta(meta_path.parent, meta)
unlinked_count += 1
return unlinked_count
def delete_receipts_for_claim(self, claim_id: str) -> int:
return self.unlink_receipts_for_claim(claim_id)
def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]: def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
meta = self._read_receipt_meta(receipt_id, current_user) meta = self._read_receipt_meta(receipt_id, current_user)

View File

@@ -603,6 +603,8 @@ class RiskRuleTemplateExecutor:
) )
if normalized.startswith("attachment."): if normalized.startswith("attachment."):
return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts) return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
if normalized.startswith("application."):
return self._resolve_application_values(normalized.removeprefix("application."), claim)
if normalized.startswith("budget."): if normalized.startswith("budget."):
return self._resolve_budget_values(normalized.removeprefix("budget."), contexts) return self._resolve_budget_values(normalized.removeprefix("budget."), contexts)
return [] return []
@@ -714,6 +716,99 @@ class RiskRuleTemplateExecutor:
values.append(budget_context.get(key)) values.append(budget_context.get(key))
return self._normalize_values(values) return self._normalize_values(values)
def _resolve_application_values(self, field_key: str, claim: ExpenseClaim) -> list[str]:
values: list[Any] = []
normalized_key = str(field_key or "").strip()
alias_map = {
"id": (
"application_claim_id",
"applicationClaimId",
"application_id",
"applicationId",
"claim_id",
"claimId",
"id",
),
"claim_no": (
"application_claim_no",
"applicationClaimNo",
"application_no",
"applicationNo",
"claim_no",
"claimNo",
"no",
),
"status": ("application_status", "applicationStatus", "status"),
"approved_amount": (
"application_approved_amount",
"applicationApprovedAmount",
"approved_amount",
"approvedAmount",
"application_amount",
"applicationAmount",
"amount",
),
"amount": (
"application_amount",
"applicationAmount",
"approved_amount",
"approvedAmount",
"amount",
),
"expense_type": (
"application_expense_type",
"applicationExpenseType",
"expense_type",
"expenseType",
),
"department_name": (
"application_department_name",
"applicationDepartmentName",
"department_name",
"departmentName",
),
"reason": ("application_reason", "applicationReason", "reason"),
}
lookup_keys = alias_map.get(
normalized_key,
(normalized_key, normalized_key.replace("_", ""), normalized_key.replace("_", "-")),
)
for source in self._iter_application_contexts(claim):
for key in lookup_keys:
if key in source and source.get(key) not in (None, ""):
values.append(source.get(key))
return self._normalize_values(values)
@staticmethod
def _iter_application_contexts(claim: ExpenseClaim) -> list[dict[str, Any]]:
contexts: list[dict[str, Any]] = []
application_sources = {"application_detail", "application_handoff", "application_link"}
nested_keys = (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
)
for flag in list(getattr(claim, "risk_flags_json", None) or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_anchor = (
source in application_sources
or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail"))
)
if not has_application_anchor:
continue
contexts.append(flag)
for key in nested_keys:
nested = flag.get(key)
if isinstance(nested, dict):
contexts.append(nested)
return contexts
def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]: def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]:
values: list[Any] = [] values: list[Any] = []
for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}: for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}:

View File

@@ -229,10 +229,9 @@ class StewardModelPlanBuilder:
StewardThinkingEvent( StewardThinkingEvent(
event_id="intent_agent_function_call", event_id="intent_agent_function_call",
stage="llm_function_call", stage="llm_function_call",
title="意图识别智能体接管", title="拆解财务事项",
content=( content=(
"已调用系统主模型的 submit_steward_intent_plan 工具," "我正在把这句话拆成可执行的财务事项,并检查每一项应该进入申请流程还是报销流程。"
"把用户话术转换为可校验的结构化财务任务计划。"
), ),
) )
] ]
@@ -255,6 +254,10 @@ class StewardModelPlanBuilder:
) )
if len(events) == 1: if len(events) == 1:
events.extend(self.planner._build_thinking_events(tasks, attachment_groups, attachments)[1:]) events.extend(self.planner._build_thinking_events(tasks, attachment_groups, attachments)[1:])
else:
gap_event = self.planner._build_business_gap_thinking_event(tasks)
if gap_event:
events.append(gap_event)
return events return events
def _sanitize_model_missing_fields( def _sanitize_model_missing_fields(

View File

@@ -52,6 +52,39 @@ REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报
MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?") MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?")
ISO_DATE_PATTERN = re.compile(r"(?P<year>\d{4})[-/年](?P<month>\d{1,2})[-/月](?P<day>\d{1,2})(?:日)?") ISO_DATE_PATTERN = re.compile(r"(?P<year>\d{4})[-/年](?P<month>\d{1,2})[-/月](?P<day>\d{1,2})(?:日)?")
BUSINESS_FIELD_LABELS = {
"expense_type": "费用类型",
"time_range": "时间",
"location": "地点",
"reason": "事由",
"amount": "金额",
"transport_mode": "出行方式",
"attachments": "附件/凭证",
"customer_name": "客户或项目对象",
"merchant_name": "商户/开票方",
"department_name": "所属部门",
"employee_name": "申请人",
"employee_no": "员工编号",
}
EXPENSE_TYPE_LABELS = {
"travel": "差旅",
"transport": "交通费",
"entertainment": "业务招待费",
"office": "办公用品",
"meeting": "会议费",
"training": "培训费",
"other": "其他费用",
}
TRANSPORT_MODE_LABELS = {
"train": "火车/高铁",
"flight": "飞机",
"taxi": "出租车/网约车",
"subway": "地铁",
"other": "其他交通方式",
}
@dataclass(frozen=True) @dataclass(frozen=True)
class PlannedTaskDraft: class PlannedTaskDraft:
@@ -372,6 +405,8 @@ class StewardPlannerService:
required = ["expense_type", "time_range", "reason"] required = ["expense_type", "time_range", "reason"]
if task_type == "expense_application": if task_type == "expense_application":
required.append("location") required.append("location")
if fields.get("expense_type") in {"travel", "transport"}:
required.append("transport_mode")
return [key for key in required if not str(fields.get(key) or "").strip()] return [key for key in required if not str(fields.get(key) or "").strip()]
@staticmethod @staticmethod
@@ -543,10 +578,13 @@ class StewardPlannerService:
StewardThinkingEvent( StewardThinkingEvent(
event_id="intent_ontology_mapping", event_id="intent_ontology_mapping",
stage="ontology_mapping", stage="ontology_mapping",
title="映射业务本体字段", title="核对业务要素",
content=ontology_summary, content=ontology_summary,
), ),
] ]
gap_event = self._build_business_gap_thinking_event(tasks)
if gap_event:
events.append(gap_event)
if attachments: if attachments:
events.append( events.append(
StewardThinkingEvent( StewardThinkingEvent(
@@ -580,23 +618,82 @@ class StewardPlannerService:
if fields.get("location"): if fields.get("location"):
anchors.append(fields["location"]) anchors.append(fields["location"])
if fields.get("expense_type"): if fields.get("expense_type"):
anchors.append(fields["expense_type"]) anchors.append(StewardPlannerService._format_business_field_value("expense_type", fields["expense_type"]))
anchor_text = "".join(anchors) if anchors else "待补充关键字段" anchor_text = "".join(anchors) if anchors else "待补充关键字段"
parts.append(f"{task_label}{task.title}{anchor_text}") parts.append(f"{task_label}{task.title}{anchor_text}")
return "".join(parts) return "".join(parts)
@staticmethod @staticmethod
def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str: def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str:
canonical_keys = [] mapped_labels = []
missing_keys = [] missing_labels = []
for task in tasks: for task in tasks:
canonical_keys.extend(task.ontology_fields.keys()) mapped_labels.extend(StewardPlannerService._business_field_label(key) for key in task.ontology_fields.keys())
missing_keys.extend(task.missing_fields) missing_labels.extend(StewardPlannerService._business_field_label(key) for key in task.missing_fields)
unique_keys = sorted({item for item in canonical_keys if item}) mapped = "".join(dict.fromkeys(label for label in mapped_labels if label)) or "暂无稳定业务要素"
unique_missing = sorted({item for item in missing_keys if item}) missing = ";还缺少:" + "".join(dict.fromkeys(label for label in missing_labels if label)) if missing_labels else ""
mapped = "".join(unique_keys) if unique_keys else "暂无稳定字段" return f"已把用户输入归一为业务要素:{mapped}{missing}。后续执行仍会先让用户确认。"
missing = ";缺失字段:" + "".join(unique_missing) if unique_missing else ""
return f"已使用 canonical ontology fields{mapped}{missing}。兼容字段只作为输入别名,不直接进入业务逻辑。" @staticmethod
def _build_business_gap_thinking_event(tasks: list[StewardTask]) -> StewardThinkingEvent | None:
gap_lines = []
for task in tasks:
if not task.missing_fields:
continue
missing_labels = [
StewardPlannerService._business_field_label(key)
for key in task.missing_fields
if key
]
if not missing_labels:
continue
if task.task_type == "expense_application" and "transport_mode" in task.missing_fields:
gap_lines.append(
(
f"{task.title}已识别到{StewardPlannerService._summarize_known_business_points(task)}"
"但用户没有说明出行方式;出行方式会影响交通费用测算,进入申请单核对后需要先追问火车、飞机或轮船。"
)
)
else:
gap_lines.append(
(
f"{task.title}还缺少{''.join(dict.fromkeys(missing_labels))}"
"需要在对应步骤里继续向用户确认,不能直接执行入库或提交。"
)
)
if not gap_lines:
return None
return StewardThinkingEvent(
event_id="intent_business_gap_check",
stage="business_gap_check",
title="判断待补充信息",
content="".join(gap_lines),
)
@staticmethod
def _summarize_known_business_points(task: StewardTask) -> str:
parts = []
for key in ("time_range", "location", "reason", "expense_type"):
value = str(task.ontology_fields.get(key) or "").strip()
if value:
parts.append(
f"{StewardPlannerService._business_field_label(key)}"
f"{StewardPlannerService._format_business_field_value(key, value)}"
)
return "".join(parts) or "部分业务要素"
@staticmethod
def _business_field_label(key: str) -> str:
return BUSINESS_FIELD_LABELS.get(str(key or "").strip(), str(key or "").strip())
@staticmethod
def _format_business_field_value(key: str, value: str) -> str:
cleaned = str(value or "").strip()
if key == "expense_type":
return EXPENSE_TYPE_LABELS.get(cleaned, cleaned)
if key == "transport_mode":
return TRANSPORT_MODE_LABELS.get(cleaned, cleaned)
return cleaned
@staticmethod @staticmethod
def _summarize_attachment_correlation( def _summarize_attachment_correlation(

View File

@@ -0,0 +1,197 @@
from __future__ import annotations
import json
from typing import Any
from app.schemas.steward import (
StewardRuntimeDecisionRequest,
StewardRuntimeDecisionResponse,
)
from app.services.runtime_chat import RuntimeChatService
STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision"
RUNTIME_NEXT_ACTIONS = {
"plan_new_tasks",
"submit_current_application",
"continue_next_task",
"fill_current_slot",
"ask_user",
"cancel_current_action",
"no_op",
}
class StewardRuntimeDecisionAgent:
"""用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。"""
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
self.runtime_chat_service = runtime_chat_service
def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse:
normalized_request = self._normalize_request(request)
result = self.runtime_chat_service.complete_with_tool_call(
self._build_messages(normalized_request),
tools=[self._build_tool_schema()],
tool_choice={
"type": "function",
"function": {"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME},
},
max_tokens=1000,
temperature=0.05,
timeout_seconds=30,
max_attempts=1,
)
traces = result.calls_as_dicts()
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)
@staticmethod
def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest:
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 {},
)
@staticmethod
def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]:
payload = {
"user_message": request.user_message,
"session_type": request.session_type,
"runtime_state": request.runtime_state,
"context_json": request.context_json,
}
return [
{
"role": "system",
"content": (
"你是 X-Financial 小财管家的运行时决策智能体。"
"你必须基于 runtime_state 判断用户当前输入对应哪个等待动作,不能把每次输入都当成全新任务。"
"runtime_state 会包含 current_task、remaining_tasks、completed_tasks、pending_application、"
"pending_steward_action、waiting_for、recent_structured_result 等上下文。"
"如果用户是在确认当前申请核对表无误,应返回 submit_current_application"
"如果用户是在确认继续下一项,应返回 continue_next_task"
"如果用户补充了当前等待字段,应返回 fill_current_slot"
"如果当前结构化结果仍缺字段,应返回 ask_user"
"只有当前没有可匹配上下文,且用户输入明显是新财务事项时,才返回 plan_new_tasks。"
"提交、入库、绑定、审批等高风险动作只返回结构化意图,实际执行由系统安全校验完成。"
"rationale 和 response_text 必须面向用户,不暴露内部推理链。"
),
},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
]
@staticmethod
def _build_tool_schema() -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME,
"description": "提交小财管家基于运行时上下文的下一步动作决策。",
"parameters": {
"type": "object",
"properties": {
"next_action": {
"type": "string",
"enum": sorted(RUNTIME_NEXT_ACTIONS),
},
"target_task_id": {"type": "string"},
"target_message_id": {"type": "string"},
"field_key": {"type": "string"},
"field_value": {"type": "string"},
"confirmation_required": {"type": "boolean"},
"question": {"type": "string"},
"response_text": {"type": "string"},
"rationale": {"type": "string"},
},
"required": [
"next_action",
"target_task_id",
"target_message_id",
"field_key",
"field_value",
"confirmation_required",
"question",
"response_text",
"rationale",
],
},
},
}
def _build_response_from_model_payload(
self,
payload: dict[str, Any],
request: StewardRuntimeDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardRuntimeDecisionResponse | None:
next_action = str(payload.get("next_action") or "").strip()
if next_action not in RUNTIME_NEXT_ACTIONS:
return None
return StewardRuntimeDecisionResponse(
decision_source="llm_function_call",
next_action=next_action, # type: ignore[arg-type]
target_task_id=self._clean_text(payload.get("target_task_id")),
target_message_id=self._clean_text(payload.get("target_message_id")),
field_key=self._clean_text(payload.get("field_key")),
field_value=self._clean_text(payload.get("field_value")),
confirmation_required=bool(payload.get("confirmation_required")),
question=self._clean_text(payload.get("question")),
response_text=self._clean_text(payload.get("response_text")),
rationale=self._clean_text(payload.get("rationale")),
model_call_traces=traces,
)
def _build_rule_fallback(
self,
request: StewardRuntimeDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardRuntimeDecisionResponse:
state = request.runtime_state
pending_application = state.get("pending_application") if isinstance(state.get("pending_application"), dict) else {}
pending_steward_action = state.get("pending_steward_action") if isinstance(state.get("pending_steward_action"), dict) else {}
waiting_for = str(state.get("waiting_for") or "").strip()
message = request.user_message.replace(" ", "")
confirmation_text = message in {"确认", "确定", "无误", "确认提交", "可以提交", "提交", "没问题"}
if confirmation_text and pending_application.get("ready_to_submit"):
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="submit_current_application",
target_message_id=str(pending_application.get("message_id") or ""),
target_task_id=str(pending_application.get("task_id") or ""),
rationale="模型运行时决策暂不可用,我先按当前待提交申请单上下文处理你的确认。",
model_call_traces=traces,
)
if confirmation_text and pending_steward_action:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="continue_next_task",
target_message_id=str(pending_steward_action.get("message_id") or ""),
target_task_id=str(pending_steward_action.get("target_task_id") or ""),
rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。",
model_call_traces=traces,
)
if waiting_for:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="ask_user",
question="我需要先确认当前等待事项,请补充或选择当前问题对应的信息。",
rationale="模型运行时决策暂不可用,当前仍存在等待用户补充的信息。",
model_call_traces=traces,
)
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="plan_new_tasks",
rationale="模型运行时决策暂不可用,当前没有可安全匹配的等待动作,回到任务规划。",
model_call_traces=traces,
)
@staticmethod
def _clean_text(value: Any) -> str:
return str(value or "").strip()

View File

@@ -0,0 +1,301 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from app.schemas.steward import (
StewardSlotDecisionRequest,
StewardSlotDecisionResponse,
StewardSlotOption,
)
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER, BUSINESS_CANONICAL_FIELDS
STEWARD_SLOT_DECISION_FUNCTION_NAME = "submit_steward_slot_decision"
FIELD_CATALOG: dict[str, dict[str, str]] = {
"expense_type": {"label": "费用类型", "description": "申请或报销所属费用场景,如差旅、交通、住宿、业务招待。"},
"time_range": {"label": "时间", "description": "申请时为出差起止日期,报销时为费用发生日期。"},
"location": {"label": "地点", "description": "出差目的地、费用发生地或业务活动地点。"},
"reason": {"label": "事由", "description": "出差、报销或业务活动的业务原因。"},
"amount": {"label": "金额", "description": "报销时为实际金额;申请时金额可由系统估算,不应默认要求用户填写。"},
"transport_mode": {"label": "出行方式", "description": "差旅申请交通费用测算所需字段,由用户明确选择或表达。"},
"attachments": {"label": "附件/凭证", "description": "发票、行程单、付款截图或其他证明材料。"},
"customer_name": {"label": "客户或项目对象", "description": "业务招待、客户拜访或项目支撑涉及的对象。"},
"merchant_name": {"label": "商户/开票方", "description": "报销票据上的商户或开票方。"},
"department_name": {"label": "所属部门", "description": "申请人或费用归属部门。"},
"employee_name": {"label": "申请人", "description": "发起申请或报销的员工。"},
"employee_no": {"label": "员工编号", "description": "公司内部员工编号。"},
}
APPLICATION_NON_BLOCKING_FIELDS = {"amount", "attachments", "employee_no", "department_name", "employee_name"}
@dataclass(frozen=True, slots=True)
class StewardSlotDecisionAgentResult:
payload: dict[str, Any]
model_call_traces: list[dict[str, Any]]
class StewardSlotDecisionAgent:
"""用大模型 function calling 判断当前任务缺什么,以及下一步是否应先追问。"""
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
self.runtime_chat_service = runtime_chat_service
def decide(self, request: StewardSlotDecisionRequest) -> StewardSlotDecisionResponse:
normalized_request = self._normalize_request(request)
result = self.runtime_chat_service.complete_with_tool_call(
self._build_messages(normalized_request),
tools=[self._build_tool_schema()],
tool_choice={
"type": "function",
"function": {"name": STEWARD_SLOT_DECISION_FUNCTION_NAME},
},
max_tokens=1200,
temperature=0.05,
timeout_seconds=30,
max_attempts=1,
)
if result.tool_call is not None and result.tool_call.name == STEWARD_SLOT_DECISION_FUNCTION_NAME:
response = self._build_response_from_model_payload(
result.tool_call.arguments,
normalized_request,
result.calls_as_dicts(),
)
if response is not None:
return response
return self._build_rule_fallback(normalized_request, result.calls_as_dicts())
@staticmethod
def _normalize_request(request: StewardSlotDecisionRequest) -> StewardSlotDecisionRequest:
normalized_fields = {
key: value
for key, value in normalize_ontology_form_values(request.ontology_fields).items()
if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip()
}
missing_fields: list[str] = []
for item in request.missing_fields:
key = str(item or "").strip()
if request.task_type == "expense_application" and key in APPLICATION_NON_BLOCKING_FIELDS:
continue
if key in BUSINESS_CANONICAL_FIELDS and key not in missing_fields and not normalized_fields.get(key):
missing_fields.append(key)
return StewardSlotDecisionRequest(
task_type=request.task_type,
user_message=str(request.user_message or "").strip(),
ontology_fields=normalized_fields,
missing_fields=missing_fields,
task_context=request.task_context if isinstance(request.task_context, dict) else {},
)
@staticmethod
def _build_messages(request: StewardSlotDecisionRequest) -> list[dict[str, Any]]:
context_payload = {
"task_type": request.task_type,
"user_message": request.user_message,
"ontology_fields": request.ontology_fields,
"missing_fields_from_intent_agent": request.missing_fields,
"field_catalog": {
key: FIELD_CATALOG[key]
for key in BUSINESS_CANONICAL_FIELD_ORDER
if key in FIELD_CATALOG
},
"task_context": request.task_context,
}
return [
{
"role": "system",
"content": (
"你是 X-Financial 小财管家的任务字段决策智能体。"
"你必须通过 function calling 返回下一步动作。"
"你的任务不是关键词匹配而是结合用户意图、当前任务类型、canonical ontology 字段、"
"上游意图识别给出的缺失字段和字段目录,判断现在应先追问用户,还是可以展示核对结果。"
"所有 required_fields 和 missing_fields 只能使用 field_catalog 中的 canonical 字段。"
"如果字段是内部提示、示例、系统指令或可选项,不能当作用户已经提供。"
"费用申请场景中 amount 可由系统估算,不应作为用户必须手填字段。"
"费用申请生成核对表阶段attachments 不阻塞生成,可在报销或归档阶段补充;"
"employee_no、department_name、employee_name 属于系统用户档案字段,必须从上下文读取,不能向用户追问。"
"差旅申请通常只有 transport_mode 这类会影响费用测算的字段才需要先追问。"
"如果缺失字段会影响后续测算、入库、附件归集或合规判断,应返回 ask_user"
"如果信息足以生成可核对但未提交的结果,应返回 render_preview。"
"question 和 rationale 必须是面向用户的业务说明,不暴露内部推理链。"
),
},
{
"role": "user",
"content": json.dumps(context_payload, ensure_ascii=False),
},
]
@staticmethod
def _build_tool_schema() -> dict[str, Any]:
canonical_fields = list(BUSINESS_CANONICAL_FIELD_ORDER)
return {
"type": "function",
"function": {
"name": STEWARD_SLOT_DECISION_FUNCTION_NAME,
"description": "提交小财管家当前任务的字段缺口和下一步动作决策。",
"parameters": {
"type": "object",
"properties": {
"next_action": {
"type": "string",
"enum": ["ask_user", "render_preview"],
},
"required_fields": {
"type": "array",
"items": {"type": "string", "enum": canonical_fields},
},
"missing_fields": {
"type": "array",
"items": {"type": "string", "enum": canonical_fields},
},
"question": {"type": "string"},
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {"type": "string"},
"value": {"type": "string"},
"field_key": {"type": "string", "enum": canonical_fields},
"description": {"type": "string"},
},
"required": ["label", "value", "field_key"],
},
},
"rationale": {"type": "string"},
},
"required": ["next_action", "required_fields", "missing_fields", "question", "options", "rationale"],
},
},
}
def _build_response_from_model_payload(
self,
payload: dict[str, Any],
request: StewardSlotDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardSlotDecisionResponse | None:
next_action = str(payload.get("next_action") or "").strip()
if next_action not in {"ask_user", "render_preview"}:
return None
required_fields = self._sanitize_fields(payload.get("required_fields"))
missing_fields = self._sanitize_fields(payload.get("missing_fields"))
required_fields = self._filter_blocking_fields(required_fields, request.task_type)
missing_fields = self._filter_blocking_fields(missing_fields, request.task_type)
missing_fields = [
key
for key in missing_fields
if key in required_fields or key in request.missing_fields
]
if next_action == "ask_user" and not missing_fields:
missing_fields = list(request.missing_fields)
if next_action == "ask_user" and not missing_fields:
next_action = "render_preview"
options = []
question = ""
rationale = "当前申请信息足以先生成核对结果;附件和员工编号不应作为用户补填项阻塞申请预览。"
else:
options = self._sanitize_options(payload.get("options"), missing_fields)
question = self._clean_text(payload.get("question"))
rationale = self._clean_text(payload.get("rationale"))
return StewardSlotDecisionResponse(
decision_source="llm_function_call",
next_action=next_action, # type: ignore[arg-type]
required_fields=required_fields,
missing_fields=missing_fields,
question=question,
options=options,
rationale=rationale,
model_call_traces=traces,
)
@staticmethod
def _filter_blocking_fields(fields: list[str], task_type: str) -> list[str]:
if task_type != "expense_application":
return fields
return [field for field in fields if field not in APPLICATION_NON_BLOCKING_FIELDS]
@staticmethod
def _sanitize_fields(raw_fields: Any) -> list[str]:
fields: list[str] = []
if not isinstance(raw_fields, list):
return fields
for item in raw_fields:
key = str(item or "").strip()
if key in BUSINESS_CANONICAL_FIELDS and key not in fields:
fields.append(key)
return fields
def _sanitize_options(self, raw_options: Any, missing_fields: list[str]) -> list[StewardSlotOption]:
options: list[StewardSlotOption] = []
if isinstance(raw_options, list):
for item in raw_options:
if not isinstance(item, dict):
continue
field_key = str(item.get("field_key") or "").strip()
label = self._clean_text(item.get("label"))
value = self._clean_text(item.get("value")) or label
if not field_key or field_key not in BUSINESS_CANONICAL_FIELDS or not label or not value:
continue
options.append(
StewardSlotOption(
field_key=field_key,
label=label,
value=value,
description=self._clean_text(item.get("description")),
)
)
if not options and missing_fields and missing_fields[0] == "transport_mode":
options = [
StewardSlotOption(field_key="transport_mode", label="火车", value="火车", description="选择火车或高铁出行。"),
StewardSlotOption(field_key="transport_mode", label="飞机", value="飞机", description="选择飞机出行。"),
StewardSlotOption(field_key="transport_mode", label="轮船", value="轮船", description="选择轮船出行。"),
]
return options[:6]
def _build_rule_fallback(
self,
request: StewardSlotDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardSlotDecisionResponse:
missing_fields = list(request.missing_fields)
if missing_fields:
field = missing_fields[0]
return StewardSlotDecisionResponse(
decision_source="rule_fallback",
next_action="ask_user",
required_fields=list(dict.fromkeys([*request.ontology_fields.keys(), *missing_fields])),
missing_fields=missing_fields,
question=self._build_fallback_question(field),
options=self._sanitize_options([], [field]),
rationale="模型字段决策暂不可用,我先按上游意图识别给出的本体缺口向你确认。",
model_call_traces=traces,
)
return StewardSlotDecisionResponse(
decision_source="rule_fallback",
next_action="render_preview",
required_fields=list(request.ontology_fields.keys()),
missing_fields=[],
question="",
options=[],
rationale="当前任务没有上游标记的关键字段缺口,可以先生成核对结果供你确认。",
model_call_traces=traces,
)
@staticmethod
def _build_fallback_question(field: str) -> str:
label = FIELD_CATALOG.get(field, {}).get("label") or field
if field == "transport_mode":
return "请问你这次打算怎么出行?可以选择火车、飞机或轮船。"
return f"当前还缺少{label},请先补充后我再继续处理。"
@staticmethod
def _clean_text(value: Any) -> str:
return str(value or "").strip()

View File

@@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session
from app.db.base import Base from app.db.base import Base
from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_feedback import AgentOperationFeedback
@@ -17,6 +18,7 @@ SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"}
FAILED_STATUSES = {"failed", "failure", "error", "errored"} FAILED_STATUSES = {"failed", "failure", "error", "errored"}
BLOCKED_STATUSES = {"blocked", "forbidden", "rejected"} BLOCKED_STATUSES = {"blocked", "forbidden", "rejected"}
RUNNING_STATUSES = {"running", "pending"} RUNNING_STATUSES = {"running", "pending"}
TOKEN_ESTIMATE_FALLBACK_TOTAL = 600
TOOL_BUCKETS = [ TOOL_BUCKETS = [
{ {
@@ -58,6 +60,32 @@ TOOL_BUCKETS = [
] ]
@dataclass(slots=True)
class _DashboardToolCall:
id: str
run_id: str
tool_type: str | None
tool_name: str | None
status: str | None
duration_ms: int | None
error_message: str | None
created_at: datetime | None
input_tokens: int
output_tokens: int
total_tokens: int
@dataclass(slots=True)
class _DashboardRun:
run_id: str
agent: str | None
source: str | None
user_id: str | None
status: str | None
started_at: datetime
tool_calls: list[_DashboardToolCall] = field(default_factory=list)
class SystemDashboardService: class SystemDashboardService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
@@ -116,16 +144,73 @@ class SystemDashboardService:
def _ensure_storage_ready(self) -> None: def _ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind()) Base.metadata.create_all(bind=self.db.get_bind())
def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[AgentRun]: def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[_DashboardRun]:
stmt = ( stmt = (
select(AgentRun) select(
.options(selectinload(AgentRun.tool_calls)) AgentRun.run_id.label("run_id"),
AgentRun.agent.label("agent"),
AgentRun.source.label("source"),
AgentRun.user_id.label("user_id"),
AgentRun.status.label("run_status"),
AgentRun.started_at.label("started_at"),
AgentToolCall.id.label("tool_id"),
AgentToolCall.run_id.label("tool_run_id"),
AgentToolCall.tool_type.label("tool_type"),
AgentToolCall.tool_name.label("tool_name"),
AgentToolCall.status.label("tool_status"),
AgentToolCall.duration_ms.label("duration_ms"),
AgentToolCall.error_message.label("tool_error_message"),
AgentToolCall.created_at.label("tool_created_at"),
AgentToolCall.request_json["input_tokens"].as_integer().label("request_input_tokens"),
AgentToolCall.request_json["prompt_tokens"].as_integer().label("request_prompt_tokens"),
AgentToolCall.request_json["total_tokens"].as_integer().label("request_total_tokens"),
AgentToolCall.response_json["input_tokens"].as_integer().label("response_input_tokens"),
AgentToolCall.response_json["output_tokens"].as_integer().label("response_output_tokens"),
AgentToolCall.response_json["completion_tokens"].as_integer().label("response_completion_tokens"),
AgentToolCall.response_json["total_tokens"].as_integer().label("response_total_tokens"),
)
.outerjoin(AgentToolCall, AgentToolCall.run_id == AgentRun.run_id)
.where(AgentRun.started_at >= start) .where(AgentRun.started_at >= start)
.order_by(AgentRun.started_at.asc()) .order_by(AgentRun.started_at.asc(), AgentToolCall.created_at.asc())
) )
if before is not None: if before is not None:
stmt = stmt.where(AgentRun.started_at < before) stmt = stmt.where(AgentRun.started_at < before)
return list(self.db.scalars(stmt).all())
runs: dict[str, _DashboardRun] = {}
for row in self.db.execute(stmt).all():
run = runs.get(row.run_id)
if run is None:
run = _DashboardRun(
run_id=row.run_id,
agent=row.agent,
source=row.source,
user_id=row.user_id,
status=row.run_status,
started_at=row.started_at,
)
runs[row.run_id] = run
if row.tool_id is None:
continue
input_tokens, output_tokens, total_tokens = self._token_counts_from_row(row)
run.tool_calls.append(
_DashboardToolCall(
id=row.tool_id,
run_id=row.tool_run_id or row.run_id,
tool_type=row.tool_type,
tool_name=row.tool_name,
status=row.tool_status,
duration_ms=row.duration_ms,
error_message=row.tool_error_message,
created_at=row.tool_created_at,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
)
return list(runs.values())
def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]: def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]:
stmt = ( stmt = (
@@ -143,7 +228,11 @@ class SystemDashboardService:
) )
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
def _agent_daily_ratio(self, labels: list[str], tool_calls: list[AgentToolCall]) -> dict[str, Any]: def _agent_daily_ratio(
self,
labels: list[str],
tool_calls: list[_DashboardToolCall],
) -> dict[str, Any]:
counts = {bucket["key"]: [0 for _ in labels] for bucket in TOOL_BUCKETS} counts = {bucket["key"]: [0 for _ in labels] for bucket in TOOL_BUCKETS}
label_index = {label: index for index, label in enumerate(labels)} label_index = {label: index for index, label in enumerate(labels)}
for tool in tool_calls: for tool in tool_calls:
@@ -231,7 +320,7 @@ class SystemDashboardService:
for index, (user_id, value) in enumerate(rows) for index, (user_id, value) in enumerate(rows)
] ]
def _accuracy_comparison(self, tool_calls: list[AgentToolCall]) -> dict[str, Any]: def _accuracy_comparison(self, tool_calls: list[_DashboardToolCall]) -> dict[str, Any]:
correct = {bucket["name"]: 0 for bucket in TOOL_BUCKETS} correct = {bucket["name"]: 0 for bucket in TOOL_BUCKETS}
wrong = {bucket["name"]: 0 for bucket in TOOL_BUCKETS} wrong = {bucket["name"]: 0 for bucket in TOOL_BUCKETS}
for tool in tool_calls: for tool in tool_calls:
@@ -297,7 +386,7 @@ class SystemDashboardService:
def _tool_detail_rows( def _tool_detail_rows(
self, self,
tool_calls: list[AgentToolCall], tool_calls: list[_DashboardToolCall],
records: list[dict[str, Any]], records: list[dict[str, Any]],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
token_by_tool = {str(record["tool_id"]): int(record["total"]) for record in records} token_by_tool = {str(record["tool_id"]): int(record["total"]) for record in records}
@@ -331,14 +420,15 @@ class SystemDashboardService:
) )
return rows return rows
def _build_token_records(self, runs: list[AgentRun]) -> list[dict[str, Any]]: def _build_token_records(self, runs: list[_DashboardRun]) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = [] records: list[dict[str, Any]] = []
for run in runs: for run in runs:
for tool in run.tool_calls: for tool in run.tool_calls:
input_tokens, output_tokens = self._extract_tool_tokens(tool) input_tokens = int(tool.input_tokens or 0)
total = input_tokens + output_tokens output_tokens = int(tool.output_tokens or 0)
total = int(tool.total_tokens or input_tokens + output_tokens)
if total <= 0: if total <= 0:
total = self._estimate_tool_tokens(tool) total = self._estimate_tool_tokens(tool) if hasattr(tool, "request_json") else 0
input_tokens = int(total * 0.62) input_tokens = int(total * 0.62)
output_tokens = total - input_tokens output_tokens = total - input_tokens
records.append( records.append(
@@ -353,6 +443,42 @@ class SystemDashboardService:
) )
return records return records
def _token_counts_from_row(self, row: Any) -> tuple[int, int, int]:
input_tokens = self._first_positive_int(
row.request_input_tokens,
row.request_prompt_tokens,
row.response_input_tokens,
)
output_tokens = self._first_positive_int(
row.response_output_tokens,
row.response_completion_tokens,
)
total_tokens = self._first_positive_int(
row.request_total_tokens,
row.response_total_tokens,
)
if total_tokens and not input_tokens and not output_tokens:
input_tokens = int(total_tokens * 0.62)
output_tokens = total_tokens - input_tokens
if input_tokens + output_tokens <= 0 and total_tokens <= 0:
total_tokens = TOKEN_ESTIMATE_FALLBACK_TOTAL
input_tokens = int(total_tokens * 0.62)
output_tokens = total_tokens - input_tokens
if total_tokens <= 0:
total_tokens = input_tokens + output_tokens
return input_tokens, output_tokens, total_tokens
@staticmethod
def _first_positive_int(*values: Any) -> int:
for value in values:
if isinstance(value, (int, float)) and value > 0:
return int(value)
return 0
def _extract_tool_tokens(self, tool: AgentToolCall) -> tuple[int, int]: def _extract_tool_tokens(self, tool: AgentToolCall) -> tuple[int, int]:
payload = { payload = {
"request": tool.request_json or {}, "request": tool.request_json or {},
@@ -392,7 +518,7 @@ class SystemDashboardService:
return found return found
return 0 return 0
def _tool_bucket(self, tool: AgentToolCall) -> dict[str, Any]: def _tool_bucket(self, tool: AgentToolCall | _DashboardToolCall) -> dict[str, Any]:
text = f"{tool.tool_type or ''} {tool.tool_name or ''}".lower() text = f"{tool.tool_type or ''} {tool.tool_name or ''}".lower()
if self._is_failed(tool.status) and ("timeout" in text or tool.error_message): if self._is_failed(tool.status) and ("timeout" in text or tool.error_message):
return TOOL_BUCKETS[-1] return TOOL_BUCKETS[-1]

View File

@@ -24,6 +24,7 @@ from app.services.document_numbering import (
) )
from app.services.user_agent_application_dates import ( from app.services.user_agent_application_dates import (
expand_application_time_with_days, expand_application_time_with_days,
resolve_application_date_range,
resolve_application_days_from_time_range, resolve_application_days_from_time_range,
) )
from app.services.user_agent_application_locations import normalize_application_location from app.services.user_agent_application_locations import normalize_application_location
@@ -1143,8 +1144,19 @@ class UserAgentApplicationMixin:
facts: dict[str, str], facts: dict[str, str],
occurred_at: datetime, occurred_at: datetime,
) -> bool: ) -> bool:
current_range = resolve_application_date_range(facts.get("time", ""))
current_time = cls._normalize_application_time_identity(facts.get("time")) current_time = cls._normalize_application_time_identity(facts.get("time"))
existing_detail = cls._extract_application_detail_from_claim(claim) existing_detail = cls._extract_application_detail_from_claim(claim)
existing_range = resolve_application_date_range(existing_detail.get("time"))
if existing_range is None and claim.occurred_at is not None:
existing_day = claim.occurred_at.date()
existing_range = (existing_day, existing_day)
if current_range is None and occurred_at is not None:
current_day = occurred_at.date()
current_range = (current_day, current_day)
if current_range is not None and existing_range is not None:
return current_range[0] <= existing_range[1] and existing_range[0] <= current_range[1]
existing_time = cls._normalize_application_time_identity(existing_detail.get("time")) existing_time = cls._normalize_application_time_identity(existing_detail.get("time"))
if current_time and existing_time: if current_time and existing_time:
return current_time == existing_time return current_time == existing_time

View File

@@ -45,7 +45,7 @@ def resolve_application_days_count(days_text: str) -> int:
def resolve_application_days_from_time_range(time_text: str) -> int: def resolve_application_days_from_time_range(time_text: str) -> int:
matches = re.findall( matches = re.findall(
r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", r"20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?",
str(time_text or ""), str(time_text or ""),
) )
if len(matches) < 2: if len(matches) < 2:
@@ -57,10 +57,29 @@ def resolve_application_days_from_time_range(time_text: str) -> int:
return (end_date - start_date).days + 1 return (end_date - start_date).days + 1
def resolve_application_date_range(time_text: str) -> tuple[date, date] | None:
matches = re.findall(
r"20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?",
str(time_text or ""),
)
dates = [
parsed
for parsed in (_parse_application_date(value) for value in matches)
if parsed is not None
]
if not dates:
return None
start_date = dates[0]
end_date = dates[-1] if len(dates) > 1 else start_date
if end_date < start_date:
start_date, end_date = end_date, start_date
return start_date, end_date
def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None: def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None:
if time_text: if time_text:
match = re.search( match = re.search(
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", r"(?P<date>20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?)",
time_text, time_text,
) )
if match: if match:

View File

@@ -36,10 +36,13 @@ from app.services import agent_foundation as agent_foundation_module
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY, FINANCE_RULES_LIBRARY,
) )
from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService from app.services.audit import AuditLogService
@@ -62,6 +65,7 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
for file_name in ( for file_name in (
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_FILENAME,
): ):
source_path = real_finance_rules / file_name source_path = real_finance_rules / file_name
if source_path.exists(): if source_path.exists():
@@ -181,8 +185,10 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
communication_rule = next( communication_rule = next(
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
) )
preapproval_rule = next(item for item in rules if item.code == COMPANY_PREAPPROVAL_RULE_CODE)
travel_config = travel_rule.config_json or {} travel_config = travel_rule.config_json or {}
communication_config = communication_rule.config_json or {} communication_config = communication_rule.config_json or {}
preapproval_config = preapproval_rule.config_json or {}
assert travel_rule.scenario_json == ["差旅费"] assert travel_rule.scenario_json == ["差旅费"]
assert travel_config["scenario_category"] == "差旅费" assert travel_config["scenario_category"] == "差旅费"
@@ -190,6 +196,12 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
assert communication_rule.scenario_json == ["通信费"] assert communication_rule.scenario_json == ["通信费"]
assert communication_config["scenario_category"] == "通信费" assert communication_config["scenario_category"] == "通信费"
assert communication_config["ai_review_category"] == "通信费" assert communication_config["ai_review_category"] == "通信费"
assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
assert preapproval_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
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None: def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:

View File

@@ -106,6 +106,68 @@ def test_agent_run_service_updates_existing_tool_call() -> None:
assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"} assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"}
def test_agent_run_list_uses_lightweight_preview_and_detail_keeps_full_payload() -> None:
with build_session() as db:
service = AgentRunService(db)
run = service.create_run(
agent=AgentName.HERMES.value,
source=AgentRunSource.SCHEDULE.value,
status=AgentRunStatus.SUCCEEDED.value,
ontology_json={
"scenario": "knowledge",
"intent": "sync",
"parse_strategy": "rule_fallback",
"model_invocation_summary": {"tokens": 999},
},
route_json={
"job_type": "knowledge_index_sync",
"phase": "indexing",
"progress": {
"percent": 50,
"total_documents": 2,
"completed_documents": 1,
"documents": [{"id": "doc-1", "text": "x" * 2000}],
},
"knowledge_ingest": {"documents": [{"id": "doc-1", "text": "x" * 2000}]},
},
)
service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.LLM.value,
tool_name="lightrag.index_documents",
request_json={"prompt": "x" * 2000},
response_json={"documents": [{"id": "doc-1", "text": "x" * 2000}]},
status="succeeded",
duration_ms=123,
)
listed = next(item for item in service.list_runs(limit=20) if item.run_id == run.run_id)
detail = service.get_run(run.run_id)
assert listed.ontology_json == {
"scenario": "knowledge",
"intent": "sync",
"parse_strategy": "rule_fallback",
}
assert listed.route_json["job_type"] == "knowledge_index_sync"
assert listed.route_json["phase"] == "indexing"
assert listed.route_json["progress"] == {
"percent": 50,
"total_documents": 2,
"completed_documents": 1,
}
assert "knowledge_ingest" not in listed.route_json
assert len(listed.tool_calls) == 1
assert listed.tool_calls[0].tool_name == "lightrag.index_documents"
assert listed.tool_calls[0].request_json == {}
assert listed.tool_calls[0].response_json == {}
assert detail is not None
assert "knowledge_ingest" in detail.route_json
assert detail.tool_calls[0].request_json["prompt"]
assert detail.tool_calls[0].response_json["documents"]
def test_agent_run_service_summarizes_model_and_tool_failures() -> None: def test_agent_run_service_summarizes_model_and_tool_failures() -> None:
with build_session() as db: with build_session() as db:
service = AgentRunService(db) service = AgentRunService(db)

View File

@@ -16,7 +16,7 @@ from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.role import Role from app.models.role import Role
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE, APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE,
@@ -147,7 +147,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict)
@@ -160,7 +160,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "manual_approval" and flag.get("source") == "manual_approval"
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("route_decision", {}).get("requires_budget_review") is False and flag.get("route_decision", {}).get("requires_budget_review") is False
for flag in approved.risk_flags_json for flag in approved.risk_flags_json
) )
@@ -218,7 +218,7 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "approval_routing" and flag.get("source") == "approval_routing"
@@ -285,7 +285,7 @@ def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
route_flag = [ route_flag = [
flag flag
for flag in approved.risk_flags_json for flag in approved.risk_flags_json

View File

@@ -319,6 +319,44 @@ def test_expense_application_pre_review_runs_stage_rules(tmp_path, monkeypatch)
assert ai_pre_review["business_stage"] == "expense_application" assert ai_pre_review["business_stage"] == "expense_application"
def test_preapproval_amount_rules_run_from_rule_library() -> None:
with build_session() as db:
claim = _build_claim(claim_no="RE-PREAPPROVAL-MEAL", expense_type="meal")
claim.amount = Decimal("501.00")
flags = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)["flags"]
meal_flags = [
flag
for flag in flags
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.application.meal_high_value_without_preapproval"
]
assert len(meal_flags) == 1
assert meal_flags[0]["finance_rule_code"] == "expense.preapproval.policy"
assert "500" in meal_flags[0]["message"]
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-preapproval-ok",
"application_claim_no": "AP-202606-OK",
}
]
flags = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)["flags"]
assert all(
flag.get("rule_code") != "risk.application.meal_high_value_without_preapproval"
for flag in flags
if isinstance(flag, dict)
)
def test_reimbursement_item_sync_persists_rule_center_risk_preview( def test_reimbursement_item_sync_persists_rule_center_risk_preview(
tmp_path, tmp_path,
monkeypatch, monkeypatch,

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.db.base import Base from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee from app.models.employee import Employee
@@ -31,11 +32,14 @@ from app.services.expense_claim_attachment_storage import ExpenseClaimAttachment
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE, APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
) )
from app.services.ontology import SemanticOntologyService from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService
def build_claim(*, expense_type: str, location: str) -> ExpenseClaim: def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
@@ -3907,6 +3911,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
approval_stage="审批完成", approval_stage="审批完成",
risk_flags_json=[], risk_flags_json=[],
), ),
ExpenseClaim(
claim_no="AP-20260525121000-ARCHIVED",
employee_name="",
department_name="E部",
project_code="PRJ-E",
expense_type="travel_application",
reason="E 申请",
location="广州",
amount=Decimal("600.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
status="approved",
approval_stage=APPLICATION_ARCHIVE_STAGE,
risk_flags_json=[],
),
ExpenseClaim( ExpenseClaim(
claim_no="AP-20260525123000-HGFEDCBA", claim_no="AP-20260525123000-HGFEDCBA",
employee_name="", employee_name="",
@@ -3933,7 +3954,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
assert {claim.claim_no for claim in claims} == { assert {claim.claim_no for claim in claims} == {
"EXP-ARCH-101", "EXP-ARCH-101",
"EXP-ARCH-PAID", "EXP-ARCH-PAID",
"AP-20260525120000-ABCDEFGH", "AP-20260525121000-ARCHIVED",
} }
@@ -4288,6 +4309,65 @@ def test_admin_can_delete_archived_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None assert db.get(ExpenseClaim, claim_id) is None
def test_admin_delete_claim_unlinks_receipt_folder_items(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
receipt_owner = CurrentUserContext(
username="emp-1",
name="Employee",
role_codes=[],
is_admin=False,
)
admin_user = CurrentUserContext(
username="superadmin",
name="Admin",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
claim = build_claim(expense_type="travel", location="Shanghai")
db.add(claim)
db.commit()
claim_id = claim.id
claim_no = claim.claim_no
item_id = claim.items[0].id
receipt_service = ReceiptFolderService()
receipt = receipt_service.save_receipt(
filename="admin-delete-linked-receipt.pdf",
content=b"%PDF-1.4 linked",
media_type="application/pdf",
current_user=receipt_owner,
linked_claim_id=claim_id,
linked_claim_no=claim_no,
linked_item_id=item_id,
document=OcrRecognizeDocumentRead(
filename="admin-delete-linked-receipt.pdf",
media_type="application/pdf",
text="invoice number 123 amount 100",
document_type="vat_invoice",
document_type_label="invoice",
scene_code="other",
scene_label="receipt",
),
)
assert receipt.status == "linked"
deleted = ExpenseClaimService(db).delete_claim(claim_id, admin_user)
assert deleted is not None
assert db.get(ExpenseClaim, claim_id) is None
unlinked_receipt = receipt_service.get_receipt(receipt.id, receipt_owner)
assert unlinked_receipt.status == "unlinked"
assert unlinked_receipt.linked_claim_id == ""
assert unlinked_receipt.linked_claim_no == ""
assert unlinked_receipt.linked_at is None
finally:
get_settings.cache_clear()
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None: def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="manager-return@example.com", username="manager-return@example.com",
@@ -4842,7 +4922,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == "审批完成" assert approved.approval_stage == "关联单据状态"
archived_claims = ExpenseClaimService(db).list_archived_claims( archived_claims = ExpenseClaimService(db).list_archived_claims(
CurrentUserContext( CurrentUserContext(
username="finance-archive@example.com", username="finance-archive@example.com",
@@ -4851,7 +4931,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
is_admin=False, is_admin=False,
) )
) )
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims) assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
assert generated_draft.status == "draft" assert generated_draft.status == "draft"
assert generated_draft.approval_stage == "待提交" assert generated_draft.approval_stage == "待提交"
@@ -4891,7 +4971,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
and flag.get("opinion") == "预算额度可承接,同意。" and flag.get("opinion") == "预算额度可承接,同意。"
and flag.get("previous_approval_stage") == "预算管理者审批" and flag.get("previous_approval_stage") == "预算管理者审批"
and flag.get("next_status") == "approved" and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成" and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("generated_draft_claim_id") == generated_draft.id and flag.get("generated_draft_claim_id") == generated_draft.id
and flag.get("generated_draft_claim_no") == generated_draft.claim_no and flag.get("generated_draft_claim_no") == generated_draft.claim_no
for flag in approved.risk_flags_json for flag in approved.risk_flags_json
@@ -5002,7 +5082,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None: def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
@@ -5147,7 +5227,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any( assert not any(
isinstance(flag, dict) isinstance(flag, dict)
@@ -5158,7 +5238,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "manual_approval" and flag.get("source") == "manual_approval"
and flag.get("next_status") == "approved" and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("budget_approval_merged") is True and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json for flag in approved.risk_flags_json
@@ -5235,7 +5315,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == "审批完成" assert approved.approval_stage == "关联单据状态"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any( assert not any(
isinstance(flag, dict) isinstance(flag, dict)
@@ -5250,7 +5330,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("opinion") == "业务必要且预算可承接,同意申请。" and flag.get("opinion") == "业务必要且预算可承接,同意申请。"
and flag.get("previous_approval_stage") == "直属领导审批" and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "approved" and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成" and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("budget_approval_merged") is True and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json for flag in approved.risk_flags_json
@@ -5819,6 +5899,94 @@ def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
) )
def test_marking_linked_reimbursement_paid_archives_application_claim() -> None:
current_user = CurrentUserContext(
username="finance-pay-linked-application@example.com",
name="财务付款",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
application_claim = ExpenseClaim(
claim_no="AP-202606050001-ARCHIVE",
employee_name="张三",
department_name="交付部",
project_code="PRJ-APP",
expense_type="travel_application",
reason="支撑国网部署",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 6, 5, 10, 0, tzinfo=UTC),
status="approved",
approval_stage=APPROVAL_DONE_STAGE,
risk_flags_json=[],
)
db.add(application_claim)
db.flush()
reimbursement_claim = ExpenseClaim(
claim_no="RE-202606050001-ARCHIVE",
employee_name="张三",
department_name="交付部",
project_code="PRJ-APP",
expense_type="travel",
reason="支撑国网部署报销",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 6, 6, 10, 0, tzinfo=UTC),
status="pending_payment",
approval_stage="待付款",
risk_flags_json=[
{
"source": "application_handoff",
"event_type": "expense_application_to_reimbursement_draft",
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
}
],
)
db.add(reimbursement_claim)
db.commit()
archived_before = ExpenseClaimService(db).list_archived_claims(current_user)
assert all(claim.claim_no != application_claim.claim_no for claim in archived_before)
paid = ExpenseClaimService(db).mark_claim_paid(reimbursement_claim.id, current_user)
assert paid is not None
db.refresh(application_claim)
assert application_claim.status == "approved"
assert application_claim.approval_stage == APPLICATION_ARCHIVE_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "application_archive_sync"
and flag.get("event_type") == "expense_application_archived_by_reimbursement"
and flag.get("reimbursement_claim_no") == reimbursement_claim.claim_no
and flag.get("next_approval_stage") == APPLICATION_ARCHIVE_STAGE
for flag in application_claim.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "payment"
and any(
item.get("application_claim_no") == application_claim.claim_no
for item in flag.get("archived_application_claims", [])
if isinstance(item, dict)
)
for flag in paid.risk_flags_json
)
archived_after = ExpenseClaimService(db).list_archived_claims(current_user)
assert any(claim.claim_no == application_claim.claim_no for claim in archived_after)
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None: def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="finance-returned@example.com", username="finance-returned@example.com",

View File

@@ -3,7 +3,8 @@ from app.services.expense_claim_status_registry import (
normalize_expense_claim_state, normalize_expense_claim_state,
) )
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE, APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
ARCHIVE_ACCOUNTING_STAGE, ARCHIVE_ACCOUNTING_STAGE,
FINANCE_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE,
PAYMENT_PAID_STAGE, PAYMENT_PAID_STAGE,
@@ -40,7 +41,19 @@ def test_normalize_reimbursement_archive_stage_differs_from_application_done() -
) )
assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE
assert application_state.approval_stage == APPROVAL_DONE_STAGE assert application_state.approval_stage == APPLICATION_LINK_STATUS_STAGE
def test_normalize_application_archive_stage_is_distinct_from_approval_done() -> None:
state = normalize_expense_claim_state(
"approved",
APPLICATION_ARCHIVE_STAGE,
claim_no="AP-20260602-0002",
expense_type="travel_application",
)
assert state.status == "approved"
assert state.approval_stage == APPLICATION_ARCHIVE_STAGE
def test_normalize_payment_stages_by_status() -> None: def test_normalize_payment_stages_by_status() -> None:

View File

@@ -117,3 +117,28 @@ def test_notification_state_endpoint_reads_and_updates_current_user_state() -> N
assert payload["states"][0]["hidden_at"] is None assert payload["states"][0]["hidden_at"] is None
assert payload["states"][0]["context_json"]["kind"] == "workbench" assert payload["states"][0]["context_json"]["kind"] == "workbench"
assert other_response.json()["states"] == [] assert other_response.json()["states"] == []
def test_notification_state_endpoint_accepts_document_center_bulk_read_state() -> None:
client = build_client()
headers = {"x-auth-username": "alice", "x-auth-name": "Alice"}
states = [
{
"notification_id": f"document:owned:DOC-{index}",
"read": True,
"hidden": False,
"context_json": {"kind": "document", "target_type": "documents-center"},
}
for index in range(150)
]
response = client.post(
"/api/v1/notification-states",
json={"states": states},
headers=headers,
)
assert response.status_code == 200
payload = response.json()
assert len(payload["states"]) == 150
assert all(item["read_at"] for item in payload["states"])

View File

@@ -179,6 +179,64 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
assert recognized.lines[1].page_index == 1 assert recognized.lines[1].page_index == 1
def test_ocr_service_reuses_cached_document_for_same_content(
monkeypatch,
tmp_path: Path,
) -> None:
calls = {"count": 0}
def fake_invoke_worker(
self,
*,
python_bin: str,
worker_path: str,
input_paths: list[Path],
) -> dict:
calls["count"] += 1
return {
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"documents": [
{
"input_path": str(input_paths[0]),
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"text": "增值税电子发票 金额 20 元",
"summary": "增值税电子发票,金额 20 元。",
"avg_score": 0.97,
"line_count": 1,
"page_count": 1,
"warnings": [],
"lines": [
{
"text": "增值税电子发票 金额 20 元",
"score": 0.97,
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
}
],
}
],
}
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
OcrService._result_cache.clear()
get_settings.cache_clear()
try:
first = OcrService().recognize_files([("first.png", b"same-image", "image/png")])
second = OcrService().recognize_files([("second.png", b"same-image", "image/png")])
finally:
OcrService._result_cache.clear()
get_settings.cache_clear()
assert calls["count"] == 1
assert first.documents[0].filename == "first.png"
assert second.documents[0].filename == "second.png"
assert second.documents[0].summary == first.documents[0].summary
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy( def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
monkeypatch, monkeypatch,
tmp_path: Path, tmp_path: Path,

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
import pytest
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeDocumentRead
@@ -71,7 +69,7 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke
get_settings.cache_clear() get_settings.cache_clear()
def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkeypatch, tmp_path) -> None: def test_receipt_folder_unlink_receipts_for_claim_marks_linked_receipts_unlinked(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear() get_settings.cache_clear()
try: try:
@@ -101,9 +99,17 @@ def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkey
), ),
) )
assert service.get_receipt(receipt.id, current_user).linked_claim_id == "claim-1" linked_detail = service.get_receipt(receipt.id, current_user)
assert service.delete_receipts_for_claim("claim-1") == 1 assert linked_detail.status == "linked"
with pytest.raises(FileNotFoundError): assert linked_detail.linked_claim_id == "claim-1"
service.get_receipt(receipt.id, current_user) assert linked_detail.linked_claim_no == "RE-001"
assert service.unlink_receipts_for_claim("claim-1") == 1
unlinked_detail = service.get_receipt(receipt.id, current_user)
assert unlinked_detail.status == "unlinked"
assert unlinked_detail.linked_claim_id == ""
assert unlinked_detail.linked_claim_no == ""
assert unlinked_detail.linked_at is None
finally: finally:
get_settings.cache_clear() get_settings.cache_clear()

View File

@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from pathlib import Path
import pytest import pytest
from app.core.config import SERVER_DIR
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.risk_rule_dsl_examples import ( from app.services.risk_rule_dsl_examples import (
get_risk_rule_dsl_example, get_risk_rule_dsl_example,
@@ -166,6 +169,95 @@ def test_date_rule_uses_application_month_before_ticket_item_date() -> None:
assert condition["outside_dates"] == ["2026-02-20"] assert condition["outside_dates"] == ["2026-02-20"]
def test_application_context_values_are_available_to_composite_rules() -> None:
claim = _claim(amount=Decimal("3000.00"))
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-ctx-1",
"application_claim_no": "AP-202606-CTX",
"application_detail": {
"application_amount": "3000",
"application_expense_type": "office",
},
}
]
manifest = {
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
"params": {
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
"conditions": [
{
"id": "application_present",
"operator": "exists_any",
"fields": ["application.id", "application.claim_no"],
}
],
"hit_logic": "application_present",
"condition_summary": "application exists",
},
}
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is not None
condition = result["evidence"]["conditions"][0]
assert condition["values"] == ["application-ctx-1", "AP-202606-CTX"]
@pytest.mark.parametrize(
("file_name", "expense_type", "amount"),
[
("risk.application.meal_high_value_without_preapproval.json", "meal", Decimal("501.00")),
("risk.application.office_bulk_without_purchase.json", "office", Decimal("2001.00")),
("risk.application.large_expense_without_preapproval.json", "software", Decimal("2001.00")),
],
)
def test_preapproval_amount_rules_hit_without_linked_application(
file_name: str,
expense_type: str,
amount: Decimal,
) -> None:
claim = _claim(amount=amount)
claim.expense_type = expense_type
manifest = _load_rule_manifest(file_name)
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is not None
assert result["evidence"]["condition_results"]["amount_exceeds_preapproval_threshold"] is True
assert result["evidence"]["condition_results"]["application_present"] is False
@pytest.mark.parametrize(
("file_name", "expense_type", "amount"),
[
("risk.application.meal_high_value_without_preapproval.json", "entertainment", Decimal("800.00")),
("risk.application.office_bulk_without_purchase.json", "office", Decimal("2600.00")),
("risk.application.large_expense_without_preapproval.json", "software", Decimal("2600.00")),
],
)
def test_preapproval_amount_rules_skip_when_application_is_linked(
file_name: str,
expense_type: str,
amount: Decimal,
) -> None:
claim = _claim(amount=amount)
claim.expense_type = expense_type
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-linked-ok",
"application_claim_no": "AP-202606-OK",
}
]
manifest = _load_rule_manifest(file_name)
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is None
def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim: def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
claim = ExpenseClaim( claim = ExpenseClaim(
claim_no="TEST-RISK-RULE-DSL", claim_no="TEST-RISK-RULE-DSL",
@@ -193,3 +285,8 @@ def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
) )
] ]
return claim return claim
def _load_rule_manifest(file_name: str) -> dict:
path = Path(SERVER_DIR) / "rules" / "risk-rules" / file_name
return json.loads(path.read_text(encoding="utf-8"))

View File

@@ -93,6 +93,38 @@ class EntertainmentFunctionCallingIntentAgent:
) )
class ApplicationFunctionCallingIntentAgent:
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": "明天前往北京出差3天支撑国网仿生产部署。",
"confidence": 0.94,
"ontology_fields": {
"time_range": "明天",
"location": "北京",
"expense_type": "差旅",
"reason": "支撑国网仿生产部署",
},
"missing_fields": [],
}
],
"attachment_groups": [],
},
model_call_traces=[],
)
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None: def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费", message="我要报销昨天客户现场沟通的交通费",
@@ -136,6 +168,22 @@ def test_steward_planner_normalizes_llm_business_entertainment_expense_type() ->
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03" assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None:
payload = StewardPlanRequest(
message="明天出差北京3天支撑国网仿生产部署",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=ApplicationFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "llm_function_call"
assert result.tasks[0].missing_fields == ["transport_mode"]
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
assert gap_events
assert "没有说明出行方式" in gap_events[0].content
assert "火车、飞机或轮船" in gap_events[0].content
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None: def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="我要报销昨天的交通费", message="我要报销昨天的交通费",
@@ -197,6 +245,10 @@ def test_steward_planner_treats_future_travel_without_apply_word_as_application(
assert result.tasks[0].ontology_fields["location"] == "北京" assert result.tasks[0].ontology_fields["location"] == "北京"
assert result.tasks[0].ontology_fields["expense_type"] == "travel" assert result.tasks[0].ontology_fields["expense_type"] == "travel"
assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署" assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署"
assert result.tasks[0].missing_fields == ["transport_mode"]
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
assert gap_events
assert "没有说明出行方式" in gap_events[0].content
assert result.tasks[1].assigned_agent == "reimbursement_assistant" assert result.tasks[1].assigned_agent == "reimbursement_assistant"
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03" assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
assert result.tasks[1].ontology_fields["expense_type"] == "entertainment" assert result.tasks[1].ontology_fields["expense_type"] == "entertainment"

View File

@@ -0,0 +1,96 @@
from app.schemas.steward import StewardRuntimeDecisionRequest
from app.services.steward_runtime_decision_agent import (
STEWARD_RUNTIME_DECISION_FUNCTION_NAME,
StewardRuntimeDecisionAgent,
)
class _FakeToolCall:
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
class _FakeRuntimeResult:
def __init__(self, tool_call=None):
self.tool_call = tool_call
def calls_as_dicts(self):
return [{"tool": self.tool_call.name if self.tool_call else ""}]
class _FakeRuntime:
def __init__(self, payload):
self.payload = payload
self.last_messages = []
self.last_tools = []
self.last_tool_choice = None
def complete_with_tool_call(self, messages, tools, tool_choice, **kwargs):
self.last_messages = messages
self.last_tools = tools
self.last_tool_choice = tool_choice
if self.payload is None:
return _FakeRuntimeResult()
return _FakeRuntimeResult(_FakeToolCall(STEWARD_RUNTIME_DECISION_FUNCTION_NAME, self.payload))
def test_steward_runtime_decision_uses_function_calling_context():
runtime = _FakeRuntime(
{
"next_action": "submit_current_application",
"target_task_id": "task-application-beijing",
"target_message_id": "msg-application-preview",
"field_key": "",
"field_value": "",
"confirmation_required": False,
"question": "",
"response_text": "",
"rationale": "用户确认当前申请核对表无误。",
}
)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="确认",
runtime_state={
"waiting_for": "application_submit_confirmation",
"pending_application": {
"message_id": "msg-application-preview",
"task_id": "task-application-beijing",
"ready_to_submit": True,
},
"remaining_tasks": [
{"task_id": "task-reimbursement-meal", "task_type": "reimbursement"}
],
},
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "submit_current_application"
assert result.target_message_id == "msg-application-preview"
assert result.target_task_id == "task-application-beijing"
assert runtime.last_tool_choice["function"]["name"] == STEWARD_RUNTIME_DECISION_FUNCTION_NAME
assert "runtime_state" in runtime.last_messages[-1]["content"]
def test_steward_runtime_decision_fallback_keeps_current_context():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="确认",
runtime_state={
"pending_steward_action": {
"message_id": "msg-next-task",
"target_task_id": "task-reimbursement-meal",
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_next_task"
assert result.target_message_id == "msg-next-task"
assert result.target_task_id == "task-reimbursement-meal"

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from app.schemas.steward import StewardSlotDecisionRequest
from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatToolCall, RuntimeToolCallResult
from app.services.steward_slot_decision_agent import (
STEWARD_SLOT_DECISION_FUNCTION_NAME,
StewardSlotDecisionAgent,
)
class FakeSlotRuntime:
def __init__(self, arguments=None):
self.arguments = arguments
self.messages = None
def complete_with_tool_call(self, messages, **kwargs):
self.messages = messages
if self.arguments is None:
return RuntimeToolCallResult(tool_call=None, calls=[])
return RuntimeToolCallResult(
tool_call=RuntimeChatToolCall(
name=STEWARD_SLOT_DECISION_FUNCTION_NAME,
arguments=self.arguments,
),
calls=[
RuntimeChatCallTrace(
slot="main",
provider="OpenAI Compatible",
model="fake",
attempt=1,
status="succeeded",
)
],
)
def test_steward_slot_decision_uses_function_calling_result() -> None:
runtime = FakeSlotRuntime(
{
"next_action": "ask_user",
"required_fields": ["expense_type", "time_range", "location", "reason", "transport_mode"],
"missing_fields": ["transport_mode"],
"question": "请问你这次打算怎么出行?",
"options": [
{"field_key": "transport_mode", "label": "飞机", "value": "飞机"},
{"field_key": "transport_mode", "label": "火车", "value": "火车"},
],
"rationale": "出行方式会影响交通费用测算。",
}
)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="明天出差北京3天支撑国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"time_range": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert [item.value for item in result.options] == ["飞机", "火车"]
assert "出行方式会影响" in result.rationale
def test_steward_slot_decision_falls_back_to_intent_missing_fields_only() -> None:
runtime = FakeSlotRuntime(arguments=None)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="还需要补充:出行方式(例如高铁、飞机、自驾、出租车)",
ontology_fields={
"expense_type": "travel",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert [item.value for item in result.options] == ["火车", "飞机", "轮船"]
assert "高铁" not in result.required_fields
def test_steward_slot_decision_does_not_ask_user_for_application_profile_or_attachments() -> None:
runtime = FakeSlotRuntime(
{
"next_action": "ask_user",
"required_fields": [
"expense_type",
"time_range",
"location",
"reason",
"amount",
"attachments",
"employee_no",
],
"missing_fields": ["attachments", "employee_no"],
"question": "请补充附件和员工编号。",
"options": [],
"rationale": "附件/凭证和员工编号为合规必需字段。",
}
)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="明天出差北京3天支撑国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"time_range": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["attachments", "employee_no"],
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "render_preview"
assert result.missing_fields == []
assert "attachments" not in result.required_fields
assert "employee_no" not in result.required_fields
assert result.options == []
assert "合规必需字段" not in result.rationale

View File

@@ -693,6 +693,66 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
assert second_response.draft_payload is None assert second_response.draft_payload is None
def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None:
session_factory = build_session_factory()
with session_factory() as db:
existing_claim = ExpenseClaim(
id="application-overlap-1",
claim_no="AP-202606050001-OVERLAP",
employee_name="pytest",
department_name="技术部",
expense_type="travel_application",
reason="支撑国网部署",
location="北京",
amount=Decimal("2700.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 6, 5, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "application_detail",
"business_stage": "expense_application",
"application_detail": {
"application_type": "差旅费用申请",
"time": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网部署",
},
}
],
)
db.add(existing_claim)
db.commit()
response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={
"manager_name": "向万红",
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-06-06 至 2026-06-08",
"location": "北京",
"reason": "支撑国网仿生产部署",
"days": "3天",
"transportMode": "火车",
"amount": "2700元",
}
},
},
)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "已存在申请单" in response.answer
assert "系统没有重复创建" in response.answer
assert existing_claim.claim_no in response.answer
assert response.draft_payload is None
def test_user_agent_application_edit_resubmits_returned_application_claim() -> None: def test_user_agent_application_edit_resubmits_returned_application_claim() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

View File

@@ -1,107 +1,142 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" width="100%" height="100%"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" width="100%" height="100%">
<defs> <defs>
<!-- Soft, large shadow for the main cards --> <!-- Background glowing orbs -->
<filter id="shadow-lg" x="-20%" y="-20%" width="140%" height="140%"> <filter id="blur-huge" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="24" stdDeviation="32" flood-color="#0f172a" flood-opacity="0.08"/> <feGaussianBlur stdDeviation="60"/>
</filter> </filter>
<!-- Tighter shadow for floating chips --> <!-- Premium drop shadows -->
<filter id="shadow-sm" x="-20%" y="-20%" width="140%" height="140%"> <filter id="glass-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="16" flood-color="#3a7ca5" flood-opacity="0.12"/> <feDropShadow dx="0" dy="24" stdDeviation="32" flood-color="#020617" flood-opacity="0.12"/>
<feDropShadow dx="0" dy="8" stdDeviation="16" flood-color="#020617" flood-opacity="0.08"/>
</filter> </filter>
<!-- Glowing effect for spheres --> <filter id="glow-cyan" x="-50%" y="-50%" width="200%" height="200%">
<filter id="glow"> <feGaussianBlur stdDeviation="12" result="blur"/>
<feGaussianBlur stdDeviation="8" result="coloredBlur"/> <feComposite in="SourceGraphic" in2="blur" operator="over"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter> </filter>
<linearGradient id="glass-base" x1="0%" y1="0%" x2="100%" y2="100%"> <!-- Apple Liquid Glass Gradients -->
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/> <linearGradient id="glass-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="100%" stop-color="#f8fafc" stop-opacity="0.65"/> <stop offset="0%" stop-color="#ffffff" stop-opacity="0.85"/>
<stop offset="40%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.25"/>
</linearGradient> </linearGradient>
<linearGradient id="blue-primary" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="glass-border" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3a7ca5" stop-opacity="1"/> <stop offset="0%" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="100%" stop-color="#255b7d" stop-opacity="1"/> <stop offset="50%" stop-color="#ffffff" stop-opacity="0.1"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.6"/>
</linearGradient> </linearGradient>
<linearGradient id="amber-accent" x1="0%" y1="0%" x2="100%" y2="100%"> <!-- Vibrant Data Gradients -->
<stop offset="0%" stop-color="#b58b4c" stop-opacity="1"/> <linearGradient id="grad-blue" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="100%" stop-color="#d4a359" stop-opacity="1"/> <stop offset="0%" stop-color="#0ea5e9" stop-opacity="1"/>
<stop offset="100%" stop-color="#2563eb" stop-opacity="1"/>
</linearGradient>
<linearGradient id="grad-amber" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f59e0b" stop-opacity="1"/>
<stop offset="100%" stop-color="#ea580c" stop-opacity="1"/>
</linearGradient>
<linearGradient id="grad-emerald" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#10b981" stop-opacity="1"/>
<stop offset="100%" stop-color="#059669" stop-opacity="1"/>
</linearGradient>
<linearGradient id="area-blue" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.0"/>
</linearGradient> </linearGradient>
</defs> </defs>
<!-- Ambient Glowing Orbs Background removed as per user request -->
<g transform="translate(40, 20)"> <g transform="translate(40, 20)">
<!-- Background Document (Left) --> <!-- ========================================== -->
<g transform="translate(20, 60) rotate(-12)" filter="url(#shadow-lg)"> <!-- 1. Expense Analysis Document (Left, Back) -->
<rect width="260" height="340" rx="16" fill="url(#glass-base)" stroke="#ffffff" stroke-width="2"/> <!-- ========================================== -->
<!-- UI Skeleton Lines --> <g transform="translate(60, 30) rotate(-10)" filter="url(#glass-shadow)">
<text x="30" y="50" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="20" font-weight="bold" fill="#64748b">支出分析</text> <!-- Liquid Glass Base -->
<rect x="30" y="70" width="200" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/> <rect width="280" height="340" rx="24" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
<rect x="30" y="90" width="160" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/> <rect width="280" height="340" rx="24" fill="none" stroke="#ffffff" stroke-width="4" stroke-opacity="0.4" style="mix-blend-mode: overlay;"/>
<!-- Bar Chart Component --> <!-- Header -->
<rect x="30" y="140" width="24" height="80" rx="6" fill="#e2e8f0" opacity="0.8"/> <circle cx="40" cy="46" r="12" fill="url(#grad-blue)"/>
<rect x="70" y="100" width="24" height="120" rx="6" fill="#e2e8f0" opacity="0.8"/> <text x="66" y="52" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="20" font-weight="900" fill="#1e293b" letter-spacing="1">支出分析</text>
<rect x="110" y="160" width="24" height="60" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="150" y="80" width="24" height="140" rx="6" fill="url(#blue-primary)"/> <!-- Floating Glass Donut Chart -->
<rect x="190" y="120" width="24" height="100" rx="6" fill="#e2e8f0" opacity="0.8"/> <g transform="translate(140, 150)">
<circle cx="0" cy="0" r="50" fill="none" stroke="#e2e8f0" stroke-width="16" opacity="0.6"/>
<circle cx="0" cy="0" r="50" fill="none" stroke="url(#grad-blue)" stroke-width="16" stroke-dasharray="200 314" stroke-linecap="round" filter="url(#glow-cyan)"/>
<circle cx="0" cy="0" r="50" fill="none" stroke="url(#grad-amber)" stroke-width="16" stroke-dasharray="60 314" stroke-dashoffset="-220" stroke-linecap="round"/>
<text x="0" y="8" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="900" fill="#1e293b" text-anchor="middle">72%</text>
</g>
<!-- Data Bars -->
<rect x="30" y="240" width="220" height="40" rx="12" fill="#ffffff" opacity="0.6"/>
<rect x="42" y="256" width="120" height="8" rx="4" fill="url(#grad-blue)"/>
<rect x="180" y="256" width="50" height="8" rx="4" fill="#94a3b8"/>
<rect x="30" y="290" width="220" height="40" rx="12" fill="#ffffff" opacity="0.6"/>
<rect x="42" y="306" width="80" height="8" rx="4" fill="url(#grad-amber)"/>
<rect x="140" y="306" width="90" height="8" rx="4" fill="#94a3b8"/>
</g>
<!-- Floating Element 1 (Left) -->
<g transform="translate(40, 20) rotate(12)" filter="url(#glass-shadow)">
<rect width="80" height="80" rx="20" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
<path d="M 25 40 L 35 50 L 55 30" fill="none" stroke="url(#grad-emerald)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</g> </g>
<!-- Center Floating Sphere --> <!-- Center Floating Sphere -->
<circle cx="280" cy="90" r="30" fill="url(#amber-accent)" opacity="0.85" filter="url(#glow)"/> <circle cx="340" cy="110" r="30" fill="url(#grad-amber)" opacity="0.9" filter="url(#glass-shadow)"/>
<!-- Main Foreground Document (Right Focus) --> <!-- ========================================== -->
<g transform="translate(320, 10) rotate(6)" filter="url(#shadow-lg)"> <!-- 2. Cost Trend Document (Right, Front) -->
<!-- Main Card Body --> <!-- ========================================== -->
<rect width="320" height="400" rx="20" fill="url(#glass-base)" stroke="#ffffff" stroke-width="3"/> <g transform="translate(320, 10) rotate(5)" filter="url(#glass-shadow)">
<!-- Liquid Glass Base -->
<rect width="320" height="380" rx="24" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
<rect width="320" height="380" rx="24" fill="none" stroke="#ffffff" stroke-width="4" stroke-opacity="0.5" style="mix-blend-mode: overlay;"/>
<!-- Header Section --> <!-- Header Section -->
<circle cx="44" cy="50" r="18" fill="url(#blue-primary)"/> <circle cx="44" cy="50" r="14" fill="url(#grad-blue)"/>
<text x="76" y="58" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="800" fill="#3a7ca5">费用趋势</text> <text x="74" y="57" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="900" fill="#1e293b" letter-spacing="1">费用趋势</text>
<!-- Area Chart Widget --> <!-- Beautiful Smooth Area Chart -->
<rect x="30" y="100" width="260" height="130" rx="12" fill="#f1f5f9" opacity="0.5"/> <g transform="translate(30, 100)">
<path d="M 30 200 L 40 180 Q 90 120 140 150 T 260 90 L 290 90 L 290 230 L 30 230 Z" fill="#3a7ca5" opacity="0.1"/> <rect width="260" height="140" rx="16" fill="#ffffff" opacity="0.5"/>
<path d="M 40 180 Q 90 120 140 150 T 260 90" fill="none" stroke="#3a7ca5" stroke-width="4" stroke-linecap="round"/> <!-- Grid lines -->
<!-- Chart Nodes --> <line x1="20" y1="35" x2="240" y2="35" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
<circle cx="140" cy="150" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/> <line x1="20" y1="70" x2="240" y2="70" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
<circle cx="260" cy="90" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/> <line x1="20" y1="105" x2="240" y2="105" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
<!-- Data List Rows --> <!-- Smooth Area -->
<g transform="translate(30, 250)" filter="url(#shadow-sm)"> <path d="M 20 120 C 60 120, 80 40, 130 60 C 180 80, 200 20, 240 30 L 240 140 L 20 140 Z" fill="url(#area-blue)"/>
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/> <!-- Smooth Line -->
<rect x="16" y="16" width="60" height="8" rx="4" fill="#94a3b8"/> <path d="M 20 120 C 60 120, 80 40, 130 60 C 180 80, 200 20, 240 30" fill="none" stroke="url(#grad-blue)" stroke-width="4" stroke-linecap="round"/>
<rect x="200" y="16" width="44" height="8" rx="4" fill="url(#blue-primary)"/>
</g> <!-- Glowing Data Point -->
<g transform="translate(30, 306)" filter="url(#shadow-sm)"> <circle cx="240" cy="30" r="6" fill="#ffffff" stroke="url(#grad-blue)" stroke-width="3" filter="url(#glow-cyan)"/>
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/> <!-- Value Tooltip -->
<rect x="16" y="16" width="80" height="8" rx="4" fill="#cbd5e1"/> <rect x="190" y="-10" width="60" height="24" rx="12" fill="#1e293b"/>
<rect x="210" y="16" width="34" height="8" rx="4" fill="url(#blue-primary)"/> <text x="220" y="6" font-family="sans-serif" font-size="12" font-weight="bold" fill="#ffffff" text-anchor="middle">+14%</text>
</g> </g>
<!-- List Items -->
<rect x="30" y="260" width="260" height="44" rx="12" fill="#ffffff" opacity="0.8"/>
<circle cx="50" cy="282" r="8" fill="url(#grad-emerald)"/>
<rect x="74" y="278" width="100" height="8" rx="4" fill="#64748b"/>
<rect x="230" y="278" width="40" height="8" rx="4" fill="url(#grad-blue)"/>
<rect x="30" y="316" width="260" height="44" rx="12" fill="#ffffff" opacity="0.8"/>
<circle cx="50" cy="338" r="8" fill="url(#grad-amber)"/>
<rect x="74" y="334" width="80" height="8" rx="4" fill="#64748b"/>
<rect x="240" y="334" width="30" height="8" rx="4" fill="url(#grad-amber)"/>
</g> </g>
<!-- Floating UI Chip 1 (Top Left) -->
<g transform="translate(140, 0) rotate(-6)" filter="url(#shadow-sm)">
<rect width="150" height="48" rx="24" fill="#ffffff" stroke="#f1f5f9" stroke-width="2"/>
<circle cx="24" cy="24" r="10" fill="#10b981"/>
<rect x="44" y="20" width="80" height="8" rx="4" fill="#334155"/>
</g>
<!-- Floating UI Chip 2 (Bottom Right) -->
<g transform="translate(580, 280) rotate(14)" filter="url(#shadow-sm)">
<rect width="130" height="52" rx="14" fill="url(#blue-primary)"/>
<rect x="20" y="22" width="90" height="8" rx="4" fill="#ffffff" opacity="0.9"/>
</g>
<!-- Decorative small spheres -->
<circle cx="120" cy="350" r="8" fill="#3a7ca5" opacity="0.4" filter="url(#glow)"/>
<circle cx="680" cy="120" r="12" fill="#b58b4c" opacity="0.3" filter="url(#glow)"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

View File

@@ -0,0 +1,271 @@
/* Current DigitalEmployeeWorkRecords scoped styles. Kept last to override retired rules above. */
.digital-work-records {
height: 100%;
}
.digital-employee-list-panel,
.digital-work-records-list-stage {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.digital-work-records-table {
min-width: 1180px;
table-layout: fixed;
}
.digital-work-records-table .col-time { width: 14%; }
.digital-work-records-table .col-module { width: 13%; }
.digital-work-records-table .col-source { width: 10%; }
.digital-work-records-table .col-status { width: 16%; }
.digital-work-records-table .col-summary { width: 31%; }
.digital-work-records-table .col-trace { width: 16%; }
.work-record-row {
outline: none;
}
.work-record-row:focus-visible {
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28);
}
.work-record-summary-cell {
text-align: left !important;
}
.work-record-summary-cell strong,
.work-record-summary-cell span,
.work-record-summary-cell em {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-record-summary-cell strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.work-record-summary-cell span {
margin-top: 4px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.work-record-summary-cell em {
margin-top: 6px;
color: #94a3b8;
font-size: 12px;
font-style: normal;
}
.work-record-trace-cell {
color: #2563eb !important;
}
:deep(.work-record-detail-page) {
flex: 1 1 0;
min-height: 0;
padding: 16px 18px 0;
background: transparent;
}
:deep(.work-record-detail-page .detail-scroll) {
min-height: 0;
display: block;
padding-right: 4px;
overflow: auto;
}
:deep(.work-record-detail-page .detail-scroll) > * + * {
margin-top: 14px;
}
:deep(.work-record-detail-page .detail-grid) {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
align-items: start;
width: min(100%, 1160px);
margin: 0 auto;
}
:deep(.work-record-detail-page .detail-main),
:deep(.work-record-detail-page .detail-side) {
display: grid;
align-content: start;
gap: 14px;
min-height: 0;
min-width: 0;
}
:deep(.work-record-detail-page .detail-actions) {
margin-top: 10px;
padding: 10px 0 0;
}
:deep(.work-record-detail-page .enterprise-detail-card) {
min-height: 0;
padding: 14px;
overflow: hidden;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
box-shadow: none;
}
:deep(.work-record-detail-page .enterprise-detail-card .card-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
:deep(.work-record-detail-page .enterprise-detail-card .card-head h3) {
margin: 0;
color: #111827;
font-size: 15px;
line-height: 1.35;
font-weight: 850;
}
:deep(.work-record-detail-page .enterprise-detail-card .card-head p) {
margin: 4px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
:deep(.work-record-detail-page .edit-badge) {
min-height: 26px;
padding: 0 9px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
}
:deep(.work-record-summary-card .json-risk-meta-grid) {
padding: 0;
}
:deep(.work-record-detail-page .json-risk-meta-item),
:deep(.work-record-detail-page .json-risk-description-text) {
border-radius: 4px;
}
.work-record-summary-text {
margin: 0;
color: #334155;
font-size: 13px;
line-height: 1.7;
}
.work-record-error-text {
margin: 12px 0 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 4px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
line-height: 1.6;
}
.work-record-tool-list {
display: grid;
gap: 8px;
}
.work-record-tool-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #f8fafc;
transition: border-color 160ms ease, background 160ms ease;
}
.work-record-tool-item:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.26);
background: #f9fbff;
}
.work-record-tool-item strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-record-tool-item span {
color: #64748b;
font-size: 12px;
white-space: nowrap;
}
.work-record-inline-empty {
min-height: 92px;
display: grid;
place-items: center;
padding: 14px;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: #f8fafc;
color: #94a3b8;
font-size: 13px;
line-height: 1.6;
text-align: center;
}
.work-record-code-block {
max-height: 360px;
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #111827;
color: #e5e7eb;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.55;
}
.digital-work-records :deep(.toolbar-actions .picker-filter),
.digital-work-records :deep(.toolbar-actions .picker-trigger) {
min-width: 148px;
}
.digital-refresh-now {
width: 40px;
min-width: 40px;
padding: 0;
}
.digital-refresh-now .mdi {
font-size: 18px;
}
@media (max-width: 1180px) {
.work-record-tool-item {
grid-template-columns: 1fr;
}
}

View File

@@ -137,6 +137,171 @@
color: var(--theme-primary-active); color: var(--theme-primary-active);
} }
.document-filter,
.date-range-filter {
position: relative;
}
.document-filter-menu,
.date-range-popover {
position: absolute;
z-index: 40;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow: hidden;
}
.document-filter-menu {
top: calc(100% + 8px);
left: 0;
min-width: 150px;
max-height: 280px;
padding: 6px;
overflow-y: auto;
}
.document-filter-menu button {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
}
.document-filter-menu button:hover,
.document-filter-menu button.active {
background: rgba(58, 124, 165, 0.1);
color: var(--theme-primary-active);
}
.date-range-trigger {
min-width: 150px;
}
.date-range-label {
max-width: 104px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-popover {
top: calc(100% + 8px);
left: 0;
width: 320px;
display: grid;
gap: 14px;
padding: 16px;
}
.date-range-popover header,
.date-range-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.date-range-popover header strong {
color: #0f172a;
font-size: 15px;
}
.date-range-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #64748b;
}
.date-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-range-fields label {
display: grid;
gap: 6px;
}
.date-range-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-range-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 4px;
color: #0f172a;
font-size: 13px;
}
.ghost-btn,
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 4px;
font-size: 13px;
font-weight: 750;
}
.ghost-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
border: 0;
background: var(--theme-primary);
color: #fff;
}
.apply-btn:disabled {
cursor: not-allowed;
background: #cbd5e1;
}
.document-status-filter {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-height: 38px;
}
.status-dropdown-filter,
.status-filter-trigger,
.status-filter-menu {
min-width: 154px;
}
.status-filter-trigger > .mdi:first-child {
color: var(--theme-primary);
}
.clear-filter-btn {
justify-content: center;
}
.create-request-btn { .create-request-btn {
min-height: 40px; min-height: 40px;
display: inline-flex; display: inline-flex;

View File

@@ -61,7 +61,7 @@
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
left: 18px; left: 18px;
z-index: 60; z-index: 120;
width: min(320px, calc(100% - 36px)); width: min(320px, calc(100% - 36px));
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
display: grid; display: grid;

View File

@@ -14,38 +14,22 @@
} }
.capability-card { .capability-card {
position: relative;
isolation: isolate; isolation: isolate;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16); background: rgba(255, 255, 255, 0.96);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 42%, rgba(255, 255, 255, 0.72)); backdrop-filter: blur(12px) saturate(150%);
background: -webkit-backdrop-filter: blur(12px) saturate(150%);
var(--workbench-glass-base), border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
linear-gradient(135deg, color-mix(in srgb, var(--capability-soft) 46%, transparent) 0%, transparent 52%, color-mix(in srgb, var(--capability-color) 11%, transparent) 100%), border-left: 3px solid color-mix(in srgb, var(--capability-color) 60%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.8));
var(--workbench-glass-theme-tint); box-shadow:
background-color: rgba(255, 255, 255, 0.64); 0 12px 28px rgba(15, 23, 42, 0.04),
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96);
0 10px 28px rgba(15, 23, 42, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.84),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
backdrop-filter: var(--workbench-glass-blur);
-webkit-backdrop-filter: var(--workbench-glass-blur);
} }
.capability-card::before, .capability-card::before,
.capability-card::after, .capability-card::after,
.workbench-card::before, .workbench-card::before,
.workbench-card::after { .workbench-card::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
display: block;
pointer-events: none;
}
.capability-card::before {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent 38%),
var(--workbench-capability-bg-image) 0 0 / var(--workbench-capability-tile-size) repeat;
mix-blend-mode: soft-light; mix-blend-mode: soft-light;
opacity: var(--workbench-glass-noise-opacity); opacity: var(--workbench-glass-noise-opacity);
} }
@@ -77,40 +61,18 @@
.workbench-card { .workbench-card {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14); background: rgba(255, 255, 255, 0.96);
background: backdrop-filter: blur(12px) saturate(150%);
linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.64) 55%, rgba(255, 255, 255, 0.72)), -webkit-backdrop-filter: blur(12px) saturate(150%);
var(--workbench-glass-theme-tint); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
background-color: rgba(255, 255, 255, 0.66); box-shadow:
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.04),
0 12px 30px rgba(15, 23, 42, 0.052), inset 0 1px 0 rgba(255, 255, 255, 0.96);
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
backdrop-filter: var(--workbench-glass-blur);
-webkit-backdrop-filter: var(--workbench-glass-blur);
} }
.workbench-card::before, .workbench-card::before,
.workbench-card::after { .workbench-card::after {
border-radius: inherit; display: none !important;
}
.workbench-card::before {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 42%),
var(--workbench-panel-bg-image) 0 0 / var(--workbench-panel-tile-size) repeat;
mix-blend-mode: soft-light;
opacity: calc(var(--workbench-glass-noise-opacity) * 0.8);
}
.workbench-card::after {
border: 1px solid rgba(255, 255, 255, 0.36);
background: var(--workbench-glass-highlight);
opacity: 0.56;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.58),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.055);
transition: opacity 180ms var(--ease);
} }
.workbench-card > * { .workbench-card > * {
@@ -138,15 +100,10 @@
.capability-card:hover, .capability-card:hover,
.workbench-card:hover { .workbench-card:hover {
box-shadow: border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.4);
0 16px 36px rgba(15, 23, 42, 0.075), box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 16px 36px rgba(15, 23, 42, 0.06),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1); inset 0 1px 0 rgba(255, 255, 255, 1);
}
.capability-card:hover::after,
.workbench-card:hover::after {
opacity: 0.88;
} }
.capability-card:hover { .capability-card:hover {

View File

@@ -66,10 +66,12 @@
.insight-metric-list, .insight-metric-list,
.insight-profile-list { .insight-profile-list {
min-height: 0; min-height: 0;
display: grid; display: flex;
gap: 6px; flex-direction: column;
grid-auto-rows: minmax(0, 1fr); justify-content: space-evenly;
gap: 0;
overflow: hidden; overflow: hidden;
height: 100%;
} }
.insight-metric-row, .insight-metric-row,
@@ -94,6 +96,15 @@
transition: transition:
border-color 180ms var(--ease), border-color 180ms var(--ease),
background-color 180ms var(--ease); background-color 180ms var(--ease);
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.insight-metric-row {
animation-delay: calc(400ms + var(--item-index, 0) * 80ms);
}
.insight-profile-card {
animation-delay: calc(500ms + var(--item-index, 0) * 80ms);
} }
.insight-metric-row:hover, .insight-metric-row:hover,
@@ -104,22 +115,6 @@
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04); rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
} }
/* 局部改造让费用统计内层的小卡片也变为低透明度透镜形成双层液态玻璃Double Glassmorphism的极品手感 */
.expense-stats-panel .insight-metric-row {
background: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
border-left-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.03),
inset 0 1px 0 rgba(255, 255, 255, 0.3) !important;
}
.expense-stats-panel .insight-metric-row:hover {
background: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.4) !important;
}
.insight-metric-icon, .insight-metric-icon,
.insight-profile-icon { .insight-profile-icon {

View File

@@ -4,8 +4,8 @@
--hero-padding-top: 20px; --hero-padding-top: 20px;
--hero-padding-bottom: 20px; --hero-padding-bottom: 20px;
--hero-title-size: 28px; --hero-title-size: 28px;
--hero-copy-gap: 5px; --hero-copy-gap: 16px;
--hero-title-bottom-gap: 14px; --hero-title-bottom-gap: 10px;
--composer-min-height: 108px; --composer-min-height: 108px;
--composer-textarea-height: 48px; --composer-textarea-height: 48px;
--composer-padding-block: 10px; --composer-padding-block: 10px;
@@ -15,7 +15,9 @@
} }
.assistant-hero { .assistant-hero {
--assistant-bg-position: 56% center; --assistant-bg-position: right center;
--assistant-decor-width: clamp(760px, 66vw, 980px);
--assistant-decor-opacity: 0.86;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px; padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
} }
@@ -58,7 +60,9 @@
} }
.assistant-hero { .assistant-hero {
--assistant-bg-position: 58% center; --assistant-bg-position: right center;
--assistant-decor-width: clamp(760px, 66vw, 980px);
--assistant-decor-opacity: 0.9;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px; padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
} }
@@ -83,7 +87,7 @@
} }
.capability-copy { .capability-copy {
padding-left: 14px; padding-left: 0;
} }
.workbench-content-grid { .workbench-content-grid {
@@ -110,13 +114,15 @@
} }
.assistant-hero { .assistant-hero {
--assistant-bg-position: 62% center; --assistant-bg-position: right center;
--assistant-decor-width: clamp(620px, 74vw, 860px);
--assistant-decor-opacity: 0.62;
--assistant-readability-mask: --assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.88) 58%, rgba(255, 255, 255, 0.44) 100%); linear-gradient(90deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.5) 58%, rgba(255, 255, 255, 0.06) 100%);
--assistant-theme-tint: --assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14) 100%); linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.09) 100%);
backdrop-filter: blur(12px); backdrop-filter: blur(10px) saturate(1.12);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(10px) saturate(1.12);
} }
.assistant-copy { .assistant-copy {
@@ -140,6 +146,125 @@
} }
} }
@media (min-width: 961px) and (max-width: 1440px),
(min-width: 961px) and (max-height: 820px) {
.workbench {
--hero-padding-top: 14px;
--hero-padding-bottom: 14px;
--hero-title-size: 24px;
--hero-copy-gap: 14px;
--hero-title-bottom-gap: 8px;
--composer-min-height: 92px;
--composer-textarea-height: 38px;
--composer-padding-block: 8px;
--quick-prompts-gap-top: 5px;
--capability-row-height: 82px;
gap: 8px;
}
.assistant-hero {
--assistant-decor-width: clamp(680px, 60vw, 880px);
--assistant-decor-opacity: 0.72;
padding: var(--hero-padding-top) 16px var(--hero-padding-bottom) 34px;
}
.assistant-copy {
width: min(900px, 92%);
}
.assistant-copy h1 {
margin-bottom: var(--hero-title-bottom-gap);
font-size: var(--hero-title-size);
line-height: 1.14;
}
.assistant-composer {
min-height: var(--composer-min-height);
gap: 4px;
padding: var(--composer-padding-block) 14px 8px;
}
.assistant-composer textarea {
height: var(--composer-textarea-height);
min-height: var(--composer-textarea-height);
max-height: var(--composer-textarea-height);
font-size: 14px;
line-height: 1.42;
}
.composer-toolbar {
gap: 8px;
}
.composer-icon-button,
.composer-send-button {
height: 30px;
}
.composer-icon-button {
width: 30px;
font-size: 17px;
}
.composer-send-button {
width: 46px;
font-size: 16px;
}
.composer-count {
font-size: 12px;
}
.quick-prompts {
gap: 8px;
margin-top: var(--quick-prompts-gap-top);
font-size: 12.5px;
}
.quick-prompts button {
min-height: 24px;
padding: 0 10px;
font-size: 12px;
}
.capability-grid {
gap: 10px;
}
.capability-card {
grid-template-columns: 34px minmax(0, 1fr) 14px;
gap: 10px;
padding: 12px 12px 12px 16px;
}
.capability-icon {
--workbench-list-icon-size: 34px;
--workbench-list-icon-art-size: 20px;
width: 34px;
height: 34px;
}
.capability-copy {
gap: 2px;
}
.capability-copy strong {
font-size: 13px;
line-height: 1.2;
}
.capability-copy small {
font-size: 11px;
line-height: 1.22;
}
.capability-arrow {
width: 14px;
min-width: 14px;
font-size: 16px;
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.workbench { .workbench {
height: auto; height: auto;
@@ -156,15 +281,17 @@
.assistant-hero { .assistant-hero {
min-height: auto; min-height: auto;
--assistant-bg-position: 68% center; --assistant-bg-position: right center;
--assistant-decor-width: min(620px, 118vw);
--assistant-decor-opacity: 0.36;
--assistant-readability-mask: --assistant-readability-mask:
linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0.88) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.76) 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.72) 100%); linear-gradient(90deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.52) 100%);
--assistant-theme-tint: --assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08) 100%); linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 100%);
padding: 24px 18px 24px; padding: 24px 18px 24px;
backdrop-filter: blur(12px); backdrop-filter: blur(9px) saturate(1.1);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(9px) saturate(1.1);
} }
.assistant-copy { .assistant-copy {
@@ -311,7 +438,7 @@
} }
.capability-copy { .capability-copy {
padding-left: 6px; padding-left: 0;
gap: 2px; gap: 2px;
} }

View File

@@ -57,31 +57,48 @@
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; } .workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
.assistant-hero { .assistant-hero {
--assistant-bg-position: right center;
--assistant-decor-width: clamp(860px, 62vw, 1180px);
--assistant-decor-opacity: 0.92;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.74) 0%, rgba(255, 255, 255, 0.34) 46%, rgba(255, 255, 255, 0) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025) 54%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.075));
position: relative; position: relative;
z-index: 2; z-index: 2;
min-height: 0; min-height: 0;
overflow: hidden; display: flex;
flex-direction: column;
justify-content: center;
overflow: visible;
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px; padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
border: 1px solid rgba(255, 255, 255, 0.9); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 16px; border-radius: 4px;
background: background:
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%); linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.54)),
background-color: transparent; var(--assistant-theme-tint);
backdrop-filter: blur(40px) saturate(200%); background-color: rgba(247, 252, 255, 0.72);
-webkit-backdrop-filter: blur(40px) saturate(200%); backdrop-filter: blur(14px) saturate(1.18);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.04), inset 0 2px 4px rgba(255, 255, 255, 1); -webkit-backdrop-filter: blur(14px) saturate(1.18);
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.045),
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
isolation: isolate; isolation: isolate;
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: 0ms;
} }
.assistant-hero::after { .assistant-hero::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
right: 100px; right: 0;
bottom: 0; bottom: 0;
width: 50%; width: 82%;
min-width: 400px; min-width: 760px;
background: url("../../images/hero-financial-decor.svg") right center / auto 100% no-repeat; background: url("../../images/workbench-hero-right-bg.png") var(--assistant-bg-position) / var(--assistant-decor-width) auto no-repeat;
opacity: var(--assistant-decor-opacity);
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@@ -92,8 +109,9 @@
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
background: background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%), var(--assistant-readability-mask),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%); linear-gradient(120deg, rgba(255, 255, 255, 0.36), transparent 22%, transparent 72%, rgba(255, 255, 255, 0.18)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.05), transparent 58%);
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
} }
@@ -114,8 +132,23 @@
font-weight: 850; font-weight: 850;
} }
.assistant-copy h1 span { .assistant-copy h1 span:not(.typing-cursor) {
color: var(--workbench-primary-active); color: var(--workbench-primary-active);
display: inline-block;
animation: workbenchItemIn 400ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.typing-cursor {
display: inline-block;
color: var(--workbench-primary-active);
font-weight: 400;
margin-left: 2px;
animation: cursorBlink 0.9s step-end infinite;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
} }
.assistant-copy p { .assistant-copy p {
@@ -127,29 +160,70 @@
font-weight: 600; font-weight: 600;
} }
.assistant-copy > * {
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.assistant-copy > h1 { animation-delay: 80ms; }
.assistant-copy > p { animation-delay: 160ms; }
.assistant-copy > .assistant-composer { animation-delay: 240ms; }
.assistant-copy > .assistant-file-strip { animation-delay: 320ms; }
.assistant-copy > .quick-prompts { animation-delay: 320ms; }
.assistant-file-input { display: none; } .assistant-file-input { display: none; }
.assistant-composer { .assistant-composer {
position: relative; position: relative;
z-index: 5; z-index: 20;
display: grid; display: grid;
gap: 6px; gap: 6px;
max-width: 920px; max-width: 920px;
min-height: var(--composer-min-height); min-height: var(--composer-min-height);
padding: var(--composer-padding-block) 18px 10px; padding: var(--composer-padding-block) 18px 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
border-radius: 4px; border-radius: 4px;
background: rgba(255, 255, 255, 0.96); background:
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96); linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.74)),
backdrop-filter: blur(4px); linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.045), rgba(255, 255, 255, 0.18));
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.045),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06);
backdrop-filter: blur(10px) saturate(1.14);
-webkit-backdrop-filter: blur(10px) saturate(1.14);
transition:
border-color 180ms var(--ease),
background 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.assistant-composer::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background:
linear-gradient(110deg, rgba(255, 255, 255, 0.32), transparent 32%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 42%);
pointer-events: none;
}
.assistant-composer > * {
position: relative;
z-index: 1;
} }
.assistant-composer:focus-within { .assistant-composer:focus-within {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.85); border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.58);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06), rgba(255, 255, 255, 0.22));
box-shadow: box-shadow:
0 0 0 4px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14), 0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11),
0 16px 36px rgba(15, 23, 42, 0.06), 0 14px 30px rgba(15, 23, 42, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.96); inset 0 1px 0 rgba(255, 255, 255, 0.94),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
} }
.assistant-composer textarea { .assistant-composer textarea {
@@ -331,24 +405,28 @@
position: relative; position: relative;
isolation: isolate; isolation: isolate;
display: grid; display: grid;
grid-template-columns: 40px minmax(0, 1fr) 10px; grid-template-columns: 40px minmax(0, 1fr) 18px;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
min-height: 0; min-height: 0;
padding: 17px 12px 17px 26px; padding: 16px 18px 16px 22px;
overflow: visible; overflow: visible;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.9); border: 1px solid rgba(255, 255, 255, 0.9);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 80%, rgba(255, 255, 255, 0.9)); border-left: 3px solid color-mix(in srgb, var(--capability-color) 80%, rgba(255, 255, 255, 0.9));
border-radius: 12px; min-width: 0;
background: background: rgba(255, 255, 255, 0.96);
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%); backdrop-filter: blur(8px);
background-color: transparent; -webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(40px) saturate(200%); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.15);
-webkit-backdrop-filter: blur(40px) saturate(200%); border-radius: 4px;
text-align: left;
box-shadow: box-shadow:
0 16px 32px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(15, 23, 42, 0.03),
inset 0 2px 4px rgba(255, 255, 255, 1); inset 0 1px 0 rgba(255, 255, 255, 1);
color: var(--workbench-ink);
text-decoration: none;
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: var(--delay, 100ms);
transition: transition:
border-color 180ms var(--ease), border-color 180ms var(--ease),
box-shadow 180ms var(--ease), box-shadow 180ms var(--ease),
@@ -356,16 +434,12 @@
transform 180ms var(--ease); transform 180ms var(--ease);
} }
.capability-card::before { .capability-card:hover {
content: ""; transform: translateY(-2px);
position: absolute; border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.4);
inset: 0; box-shadow:
border-radius: inherit; 0 16px 32px rgba(15, 23, 42, 0.06),
background: inset 0 1px 0 rgba(255, 255, 255, 1);
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none;
z-index: 0;
} }
.capability-card > * { .capability-card > * {
@@ -373,13 +447,9 @@
z-index: 1; z-index: 1;
} }
.capability-card::after {
display: none;
}
.capability-icon { .capability-icon {
--workbench-list-icon-size: 40px; --workbench-list-icon-size: 40px;
--workbench-list-icon-art-size: 23px; --workbench-list-icon-art-size: 24px;
width: 40px; width: 40px;
height: 40px; height: 40px;
color: var(--capability-color); color: var(--capability-color);
@@ -388,8 +458,9 @@
.capability-copy { .capability-copy {
min-width: 0; min-width: 0;
display: grid; display: grid;
justify-items: start;
gap: 4px; gap: 4px;
padding-left: 18px; text-align: left;
} }
.capability-copy strong { .capability-copy strong {
@@ -400,6 +471,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
text-align: left;
} }
.capability-copy small { .capability-copy small {
@@ -409,11 +481,19 @@
line-height: 1.35; line-height: 1.35;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
text-align: left;
} }
.capability-arrow { .capability-arrow {
justify-self: end;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
min-width: 18px;
color: color-mix(in srgb, var(--workbench-muted) 68%, #ffffff); color: color-mix(in srgb, var(--workbench-muted) 68%, #ffffff);
font-size: 18px; font-size: 18px;
line-height: 1;
} }
.capability-card--green { .capability-card--green {
@@ -461,28 +541,16 @@
min-height: 0; min-height: 0;
height: 100%; height: 100%;
padding: 12px 14px; padding: 12px 14px;
background: background: rgba(255, 255, 255, 0.96);
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%); backdrop-filter: blur(8px);
background-color: transparent; -webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(40px) saturate(200%); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.15);
-webkit-backdrop-filter: blur(40px) saturate(200%); border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 16px;
box-shadow: box-shadow:
0 16px 32px rgba(0, 0, 0, 0.04), 0 12px 28px rgba(15, 23, 42, 0.04),
inset 0 2px 4px rgba(255, 255, 255, 1); inset 0 1px 0 rgba(255, 255, 255, 1);
} animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: var(--delay, 200ms);
.workbench-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none;
z-index: 1;
} }
.workbench-card > * { .workbench-card > * {
@@ -534,6 +602,18 @@
font-weight: 850; font-weight: 850;
} }
.insight-metric-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
border-radius: 4px;
background: color-mix(in srgb, var(--insight-color) 4%, transparent);
transition: transform 180ms var(--ease), background-color 180ms var(--ease);
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: calc(400ms + var(--item-index, 0) * 80ms);
}
.link-action { .link-action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -560,6 +640,11 @@
border-top: 0; border-top: 0;
} }
.progress-row {
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
}
.progress-identity, .progress-identity,
.progress-result { .progress-result {
gap: 12px; gap: 12px;
@@ -716,40 +801,66 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 36px; width: 42px;
height: 36px; height: 42px;
border-radius: 8px; border-radius: 12px;
font-size: 20px; font-size: 22px;
flex-shrink: 0; flex-shrink: 0;
position: relative;
box-shadow:
0 4px 10px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(0, 0, 0, 0.03);
}
.expense-type-icon::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 100%);
border-radius: inherit;
z-index: 0;
}
.expense-type-icon i {
position: relative;
z-index: 1;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.12));
} }
.expense-type-icon--blue { .expense-type-icon--blue {
background: color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff); background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 3%, #ffffff) 100%);
border: 1px solid color-mix(in srgb, var(--workbench-primary, #3a7ca5) 20%, #ffffff);
color: var(--workbench-primary, #3a7ca5); color: var(--workbench-primary, #3a7ca5);
} }
.expense-type-icon--amber { .expense-type-icon--amber {
background: color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff); background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 3%, #ffffff) 100%);
border: 1px solid color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 20%, #ffffff);
color: var(--workbench-chart-amber, #b58b4c); color: var(--workbench-chart-amber, #b58b4c);
} }
.expense-type-icon--emerald { .expense-type-icon--emerald {
background: color-mix(in srgb, #10b981 12%, #ffffff); background: linear-gradient(135deg, color-mix(in srgb, #0f8f68 12%, #ffffff) 0%, color-mix(in srgb, #0f8f68 3%, #ffffff) 100%);
color: #10b981; border: 1px solid color-mix(in srgb, #0f8f68 20%, #ffffff);
color: #0f8f68;
} }
.expense-type-icon--violet { .expense-type-icon--violet {
background: color-mix(in srgb, #8b5cf6 12%, #ffffff); background: linear-gradient(135deg, color-mix(in srgb, #6d5bd0 12%, #ffffff) 0%, color-mix(in srgb, #6d5bd0 3%, #ffffff) 100%);
color: #8b5cf6; border: 1px solid color-mix(in srgb, #6d5bd0 20%, #ffffff);
color: #6d5bd0;
} }
.expense-type-icon--cyan { .expense-type-icon--cyan {
background: color-mix(in srgb, #06b6d4 12%, #ffffff); background: linear-gradient(135deg, color-mix(in srgb, #0788a2 12%, #ffffff) 0%, color-mix(in srgb, #0788a2 3%, #ffffff) 100%);
color: #06b6d4; border: 1px solid color-mix(in srgb, #0788a2 20%, #ffffff);
color: #0788a2;
} }
.expense-type-icon--muted { .expense-type-icon--muted {
background: var(--info-soft, #f1f5f9); background: linear-gradient(135deg, var(--info-soft, #f1f5f9) 0%, #ffffff 100%);
border: 1px solid var(--workbench-line);
color: var(--workbench-muted, #64748b); color: var(--workbench-muted, #64748b);
} }
@@ -877,3 +988,22 @@
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24); border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
color: var(--workbench-primary-active); color: var(--workbench-primary-active);
} }
@keyframes workbenchItemIn {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.assistant-hero,
.capability-card,
.workbench-card {
animation: none !important;
}
}

View File

@@ -1179,6 +1179,136 @@
background: var(--theme-primary-active); background: var(--theme-primary-active);
} }
@media (min-width: 961px) and (max-width: 1440px),
(min-width: 961px) and (max-height: 820px) {
.topbar {
gap: 16px;
padding: 12px 20px 14px;
}
.topbar.chat-mode {
padding-bottom: 12px;
}
.eyebrow {
margin-bottom: 5px;
padding: 2px 8px;
font-size: 10px;
letter-spacing: 0.8px;
}
.topbar h1 {
font-size: 22px;
line-height: 1.16;
}
.topbar p {
display: -webkit-box;
max-width: 640px;
margin-top: 3px;
overflow: hidden;
color: #64748b;
font-size: 12.5px;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.top-actions {
gap: 10px;
}
.range-shell {
height: 36px;
padding: 2px;
}
.range-meta {
height: 30px;
gap: 6px;
padding: 0 10px;
font-size: 12px;
}
.range-tabs button {
height: 30px;
min-width: 48px;
padding: 0 10px;
font-size: 12px;
}
.custom-range-btn {
height: 36px;
gap: 6px;
padding: 0 11px;
font-size: 12px;
}
.dashboard-switch-wrap {
width: 176px;
flex-basis: 176px;
height: 38px;
}
.dashboard-switch-select :deep(.el-select__wrapper) {
height: 38px;
min-height: 38px;
}
.topbar-toolset {
gap: 12px;
}
.topbar-icon-btn {
width: 30px;
height: 30px;
font-size: 20px;
}
.company-switcher {
height: 34px;
padding: 0 12px;
font-size: 12px;
}
.kpi-chips {
gap: 8px;
}
.kpi-chip {
gap: 1px 8px;
padding: 6px 12px;
}
.chip-value {
font-size: 18px;
}
.chip-value small {
font-size: 11px;
}
.chip-label {
font-size: 11px;
}
.chip-delta {
font-size: 10px;
}
.detail-alert-pill {
min-height: 28px;
padding: 0 10px;
font-size: 11.5px;
}
.create-top-btn {
height: 36px;
padding: 0 14px;
font-size: 13px;
}
}
@media (max-width: 1120px) { @media (max-width: 1120px) {
.range-combo { .range-combo {
width: 100%; width: 100%;

View File

@@ -99,6 +99,13 @@
opacity: 0.58; opacity: 0.58;
} }
.reimbursement-draft-pending-detail {
display: inline;
margin-left: 8px;
color: #94a3b8;
font-weight: 760;
}
.application-draft-preview .application-draft-head { .application-draft-preview .application-draft-head {
display: grid; display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto; grid-template-columns: 36px minmax(0, 1fr) auto;

View File

@@ -60,7 +60,7 @@
max-width: min(100%, 760px); max-width: min(100%, 760px);
padding: 12px 14px; padding: 12px 14px;
border: 1px solid #d8e4f0; border: 1px solid #d8e4f0;
border-radius: 14px; border-radius: 4px;
background: #ffffff; background: #ffffff;
color: #24324a; color: #24324a;
font-size: var(--wb-fs-bubble, 13px); font-size: var(--wb-fs-bubble, 13px);
@@ -182,11 +182,54 @@
max-width: min(100%, 1080px); max-width: min(100%, 1080px);
} }
.message-feedback-bubble { .message-action-toolbar {
grid-column: 2; display: inline-flex;
justify-self: start; align-items: center;
max-width: min(100%, 420px); gap: 4px;
margin-top: -2px; margin-top: -2px;
color: #728197;
}
.message-action-btn {
width: 30px;
height: 28px;
display: inline-grid;
place-items: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: inherit;
cursor: pointer;
transition:
color 160ms ease,
background 160ms ease,
border-color 160ms ease;
}
.message-action-btn i {
font-size: 18px;
line-height: 1;
}
.message-action-btn:hover,
.message-action-btn:focus-visible {
color: #245f90;
background: #eef6fb;
border-color: #c9ddea;
outline: none;
}
.message-action-btn.active {
color: #1d6f9f;
background: #e8f4fb;
border-color: #bcd8e8;
}
.message-action-btn:disabled {
cursor: not-allowed;
color: #b7c2cf;
background: transparent;
border-color: transparent;
} }
.message-bubble-review-risk-low, .message-bubble-review-risk-low,
@@ -482,7 +525,7 @@
margin: 10px 0 12px; margin: 10px 0 12px;
overflow-x: auto; overflow-x: auto;
border: 1px solid #dbe4ee; border: 1px solid #dbe4ee;
border-radius: 10px; border-radius: 4px;
background: #ffffff; background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
} }
@@ -566,7 +609,7 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 0 12px; padding: 0 12px;
border-radius: 8px; border-radius: 4px;
font-size: var(--wb-fs-chip, 12px); font-size: var(--wb-fs-chip, 12px);
font-weight: 750; font-weight: 750;
} }
@@ -606,6 +649,44 @@
gap: 8px; gap: 8px;
} }
.structured-card-reveal-enter-active {
transition:
opacity 220ms cubic-bezier(0.2, 0, 0, 1),
transform 240ms cubic-bezier(0.22, 1, 0.36, 1),
clip-path 240ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: top left;
will-change: opacity, transform, clip-path;
}
.structured-card-reveal-leave-active {
transition:
opacity 140ms ease,
transform 140ms ease;
transform-origin: top left;
}
.structured-card-reveal-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.985);
clip-path: inset(0 0 14px 0);
}
.structured-card-reveal-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
clip-path: inset(0 0 0 0);
}
.structured-card-reveal-leave-from {
opacity: 1;
transform: translateY(0);
}
.structured-card-reveal-leave-to {
opacity: 0;
transform: translateY(4px);
}
.message-suggested-action-btn { .message-suggested-action-btn {
height: 100%; height: 100%;
min-height: 54px; min-height: 54px;
@@ -614,16 +695,32 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 10px; border-radius: 4px;
text-align: left; text-align: left;
} }
.structured-card-reveal-enter-active .message-suggested-action-btn {
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(2) {
animation-delay: 45ms;
}
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(3) {
animation-delay: 90ms;
}
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(n + 4) {
animation-delay: 120ms;
}
.message-suggested-action-icon { .message-suggested-action-icon {
width: 30px; width: 30px;
height: 30px; height: 30px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 8px; border-radius: 4px;
background: #eff6ff; background: #eff6ff;
color: var(--theme-primary, #3a7ca5); color: var(--theme-primary, #3a7ca5);
} }
@@ -651,7 +748,7 @@
margin-top: 12px; margin-top: 12px;
padding: 12px; padding: 12px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 12px; border-radius: 4px;
background: #f8fbff; background: #f8fbff;
} }
@@ -670,12 +767,17 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
border: 1px solid #d7e4f2; border: 1px solid #d7e4f2;
border-radius: 8px; border-radius: 4px;
background: #ffffff; background: #ffffff;
color: #334155; color: #334155;
font-size: var(--wb-fs-bubble, 13px); font-size: var(--wb-fs-bubble, 13px);
} }
.application-preview-shell {
min-width: 0;
display: grid;
}
.application-preview-row { .application-preview-row {
position: relative; position: relative;
display: grid; display: grid;
@@ -684,6 +786,30 @@
border-top: 1px solid #e6edf5; border-top: 1px solid #e6edf5;
} }
.structured-card-reveal-enter-active .application-preview-row {
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(2) {
animation-delay: 35ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(3) {
animation-delay: 70ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(4) {
animation-delay: 105ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(5) {
animation-delay: 140ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(n + 6) {
animation-delay: 165ms;
}
.application-preview-row.editable { .application-preview-row.editable {
cursor: pointer; cursor: pointer;
} }
@@ -786,7 +912,7 @@
height: 30px; height: 30px;
padding: 0 9px; padding: 0 9px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.48); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.48);
border-radius: 6px; border-radius: 4px;
background: #ffffff; background: #ffffff;
color: #0f172a; color: #0f172a;
font: inherit; font: inherit;
@@ -809,7 +935,7 @@
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 4px;
background: var(--theme-primary-soft, #eaf4fa); background: var(--theme-primary-soft, #eaf4fa);
color: var(--theme-primary-active, #255b7d); color: var(--theme-primary-active, #255b7d);
cursor: pointer; cursor: pointer;
@@ -903,7 +1029,7 @@
min-height: 22px; min-height: 22px;
padding: 0 7px; padding: 0 7px;
border: 0; border: 0;
border-radius: 6px; border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1); background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active, #255b7d); color: var(--theme-primary-active, #255b7d);
font-weight: 880; font-weight: 880;
@@ -914,6 +1040,17 @@
font-weight: 820; font-weight: 820;
} }
@keyframes structured-card-item-reveal {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.expense-query-record-list, .expense-query-record-list,
.message-citation-list { .message-citation-list {
display: grid; display: grid;
@@ -926,7 +1063,7 @@
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 10px; gap: 10px;
padding: 10px; padding: 10px;
border-radius: 10px; border-radius: 4px;
text-align: left; text-align: left;
} }
@@ -1054,7 +1191,7 @@
.message-bubble { .message-bubble {
max-width: 100%; max-width: 100%;
border-radius: 12px; border-radius: 4px;
} }
.steward-task-missing-list li { .steward-task-missing-list li {
@@ -1062,3 +1199,15 @@
gap: 3px; gap: 3px;
} }
} }
@media (prefers-reduced-motion: reduce) {
.structured-card-reveal-enter-active,
.structured-card-reveal-leave-active {
transition: none;
}
.structured-card-reveal-enter-active .application-preview-row,
.structured-card-reveal-enter-active .message-suggested-action-btn {
animation: none;
}
}

View File

@@ -208,3 +208,22 @@
) { ) {
border-radius: var(--enterprise-detail-radius); border-radius: var(--enterprise-detail-radius);
} }
.digital-work-records.digital-work-records .work-record-detail-page :is(
.detail-inline-state,
.detail-loading-state,
.detail-hero,
.enterprise-detail-card,
.json-risk-meta-item,
.json-risk-description-text,
.work-record-error-text,
.work-record-tool-item,
.work-record-inline-empty,
.work-record-code-block,
.edit-badge,
.back-action,
.minor-action,
.major-action
) {
border-radius: var(--enterprise-detail-radius);
}

View File

@@ -32,7 +32,7 @@
--el-text-color-primary: var(--ink); --el-text-color-primary: var(--ink);
--el-text-color-regular: var(--text); --el-text-color-regular: var(--text);
--el-text-color-secondary: var(--muted); --el-text-color-secondary: var(--muted);
--el-font-family: Inter, "SF Pro Text", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif; --el-font-family: var(--font-sans);
--el-font-size-base: 14px; --el-font-size-base: 14px;
--el-box-shadow-light: 0 8px 22px rgba(15, 23, 42, 0.08); --el-box-shadow-light: 0 8px 22px rgba(15, 23, 42, 0.08);
--el-box-shadow-lighter: 0 4px 14px rgba(15, 23, 42, 0.06); --el-box-shadow-lighter: 0 4px 14px rgba(15, 23, 42, 0.06);

View File

@@ -72,7 +72,8 @@
--desktop-stage-height: 100dvh; --desktop-stage-height: 100dvh;
--desktop-viewport-width: 1440; --desktop-viewport-width: 1440;
--desktop-viewport-height: 900; --desktop-viewport-height: 900;
font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif; --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "PingFang SC", "Hiragino Sans GB", "Helvetica Neue", "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
font-family: var(--font-sans);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }

View File

@@ -41,20 +41,6 @@
align-items: flex-start; align-items: flex-start;
} }
.budget-select-filter {
display: inline-flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.budget-select-filter .enterprise-select {
min-width: 118px;
}
.budget-primary-btn, .budget-primary-btn,
.budget-ghost-btn { .budget-ghost-btn {
min-height: 38px; min-height: 38px;
@@ -464,17 +450,11 @@
padding: 12px 12px 0; padding: 12px 12px 0;
} }
.budget-select-filter,
.budget-select-filter .enterprise-select,
.budget-primary-btn, .budget-primary-btn,
.budget-ghost-btn { .budget-ghost-btn {
width: 100%; width: 100%;
} }
.budget-select-filter {
justify-content: space-between;
}
.budget-scope-tabs { .budget-scope-tabs {
gap: 18px; gap: 18px;
flex-wrap: nowrap; flex-wrap: nowrap;

View File

@@ -15,173 +15,6 @@
overflow: hidden; overflow: hidden;
} }
.document-filter,
.date-range-filter {
position: relative;
}
.document-filter-menu,
.date-range-popover {
position: absolute;
z-index: 40;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow: hidden;
}
.document-filter-menu {
top: calc(100% + 8px);
left: 0;
min-width: 150px;
max-height: 280px;
padding: 6px;
overflow-y: auto;
}
.document-filter-menu button {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
}
.document-filter-menu button:hover,
.document-filter-menu button.active {
background: rgba(58, 124, 165, 0.1);
color: var(--theme-primary-active);
}
.date-range-trigger {
min-width: 150px;
}
.date-range-label {
max-width: 104px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-popover {
top: calc(100% + 8px);
left: 0;
width: 320px;
display: grid;
gap: 14px;
padding: 16px;
}
.date-range-popover header,
.date-range-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.date-range-popover header strong {
color: #0f172a;
font-size: 15px;
}
.date-range-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #64748b;
}
.date-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-range-fields label {
display: grid;
gap: 6px;
}
.date-range-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-range-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 4px;
color: #0f172a;
font-size: 13px;
}
.ghost-btn,
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 4px;
font-size: 13px;
font-weight: 750;
}
.ghost-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
border: 0;
background: var(--theme-primary);
color: #fff;
}
.apply-btn:disabled {
cursor: not-allowed;
background: #cbd5e1;
}
.document-status-filter {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-height: 38px;
}
.status-dropdown-filter {
min-width: 154px;
}
.status-filter-trigger {
min-width: 154px;
}
.status-filter-trigger > .mdi:first-child {
color: var(--theme-primary);
}
.status-filter-menu {
min-width: 154px;
}
.col-id { width: 11%; } .col-id { width: 11%; }
.col-created { width: 10%; } .col-created { width: 10%; }
.col-stay { width: 9%; } .col-stay { width: 9%; }

View File

@@ -40,10 +40,6 @@
width: 280px; width: 280px;
} }
.system-logs-list .document-filter {
position: relative;
}
.system-logs-list .status-dropdown-filter, .system-logs-list .status-dropdown-filter,
.system-logs-list .status-filter-trigger, .system-logs-list .status-filter-trigger,
.system-logs-list .status-filter-menu { .system-logs-list .status-filter-menu {
@@ -74,42 +70,6 @@
font-size: 18px; font-size: 18px;
} }
.system-logs-list .document-filter-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 40;
min-width: 150px;
max-height: 280px;
padding: 6px;
overflow-y: auto;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
}
.system-logs-list .document-filter-menu button {
width: 100%;
min-height: 36px;
display: block;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
}
.system-logs-list .document-filter-menu button:hover,
.system-logs-list .document-filter-menu button.active {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active);
}
.system-logs-list .system-log-table { .system-logs-list .system-log-table {
min-width: 1260px; min-width: 1260px;
} }

View File

@@ -114,44 +114,46 @@
border-right: 1px solid #edf2f7; border-right: 1px solid #edf2f7;
padding-right: 12px; padding-right: 12px;
} }
.folder-tree { .folder-tree {
min-height: 0; min-height: 0;
display: grid; display: grid;
align-content: start; align-content: start;
gap: 6px; gap: 6px;
overflow-y: auto; overflow-y: auto;
} }
.folder-tree button { .folder-tree button {
min-height: 34px; min-height: 34px;
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto; grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 0 9px; padding: 0 9px;
border: 0; border: 0;
border-radius: 7px; border-radius: 7px;
background: transparent; background: transparent;
color: #334155; color: #334155;
font-size: 13px; font-size: 13px;
text-align: left; text-align: left;
} animation: listRowIn 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: var(--delay, 0ms);
}
.folder-tree button.active { .folder-tree button.active {
background: var(--theme-primary-light-9); background: var(--theme-primary-light-9);
color: var(--theme-primary-active); color: var(--theme-primary-active);
font-weight: 850; font-weight: 850;
} }
.folder-tree b { .folder-tree b {
min-width: 24px; min-width: 24px;
height: 20px; height: 20px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 999px; border-radius: 999px;
background: #f1f5f9; background: #f1f5f9;
color: #64748b; color: #64748b;
font-size: 11px; font-size: 11px;
} }
@@ -189,7 +191,7 @@
color: #64748b; color: #64748b;
box-shadow: none; box-shadow: none;
} }
.document-area { .document-area {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -201,51 +203,51 @@
.document-area.read-only { .document-area.read-only {
grid-template-rows: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr) auto;
} }
.upload-input { .upload-input {
display: none; display: none;
} }
.upload-zone { .upload-zone {
min-height: 112px; min-height: 112px;
display: grid; display: grid;
place-items: center; place-items: center;
align-content: center; align-content: center;
gap: 8px; gap: 8px;
border: 1px dashed #93c5fd; border: 1px dashed #93c5fd;
border-radius: 10px; border-radius: 10px;
background: #f8fbff; background: #f8fbff;
color: #334155; color: #334155;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease; transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease;
} }
.upload-zone:hover { .upload-zone:hover {
border-color: #60a5fa; border-color: #60a5fa;
background: #f3f8ff; background: #f3f8ff;
} }
.upload-zone.disabled { .upload-zone.disabled {
cursor: default; cursor: default;
border-color: #cbd5e1; border-color: #cbd5e1;
background: #f8fafc; background: #f8fafc;
} }
.upload-zone.busy { .upload-zone.busy {
opacity: 0.72; opacity: 0.72;
} }
.upload-zone i { .upload-zone i {
color: #2563eb; color: #2563eb;
font-size: 31px; font-size: 31px;
} }
.upload-zone strong { .upload-zone strong {
font-size: 13px; font-size: 13px;
font-weight: 850; font-weight: 850;
} }
.upload-zone span { .upload-zone span {
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
@@ -255,24 +257,24 @@
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
} }
table { table {
width: 100%; width: 100%;
min-width: 780px; min-width: 780px;
border-collapse: collapse; border-collapse: collapse;
} }
th, th,
td { td {
padding: 12px 10px; padding: 12px 10px;
border-bottom: 1px solid #edf2f7; border-bottom: 1px solid #edf2f7;
color: #24324a; color: #24324a;
font-size: 12px; font-size: 12px;
line-height: 1.35; line-height: 1.35;
text-align: left; text-align: left;
vertical-align: middle; vertical-align: middle;
} }
th { th {
background: #f7fafc; background: #f7fafc;
color: #64748b; color: #64748b;
@@ -289,59 +291,61 @@ th {
.knowledge-document-table td:first-child { .knowledge-document-table td:first-child {
text-align: left; text-align: left;
} }
.doc-row { .doc-row {
cursor: pointer; cursor: pointer;
transition: background 180ms ease, box-shadow 180ms ease; animation: listRowIn 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
} animation-delay: var(--delay, 0ms);
transition: background 180ms ease, box-shadow 180ms ease;
.doc-row:hover { }
background: #f8fbff;
} .doc-row:hover {
background: #f8fbff;
}
.doc-row.selected { .doc-row.selected {
background: linear-gradient(90deg, rgba(var(--theme-primary-rgb), 0.08), rgba(59, 130, 246, 0.04)); background: linear-gradient(90deg, rgba(var(--theme-primary-rgb), 0.08), rgba(59, 130, 246, 0.04));
box-shadow: inset 3px 0 0 var(--theme-primary); box-shadow: inset 3px 0 0 var(--theme-primary);
} }
.file-name { .file-name {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
font-weight: 750; font-weight: 750;
white-space: nowrap; white-space: nowrap;
} }
.file-name .pdf, .file-name .pdf,
.viewer-filetype.pdf { color: #ef4444; } .viewer-filetype.pdf { color: #ef4444; }
.file-name .word, .file-name .word,
.viewer-filetype.word { color: #2563eb; } .viewer-filetype.word { color: #2563eb; }
.file-name .excel, .file-name .excel,
.viewer-filetype.excel { color: var(--success); } .viewer-filetype.excel { color: var(--success); }
.doc-tag { .doc-tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 22px; min-height: 22px;
padding: 0 7px; padding: 0 7px;
border-radius: 6px; border-radius: 6px;
background: #f1f5f9; background: #f1f5f9;
color: #64748b; color: #64748b;
font-size: 11px; font-size: 11px;
font-weight: 750; font-weight: 750;
} }
.state-tag { .state-tag {
min-height: 22px; min-height: 22px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0 8px; padding: 0 8px;
border-radius: 6px; border-radius: 6px;
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
white-space: nowrap; white-space: nowrap;
} }
.state-tag.success { .state-tag.success {
background: var(--success-soft); background: var(--success-soft);
color: var(--success-hover); color: var(--success-hover);
@@ -351,7 +355,7 @@ th {
background: #e2e8f0; background: #e2e8f0;
color: #475569; color: #475569;
} }
.state-tag.warning { .state-tag.warning {
background: #ffedd5; background: #ffedd5;
color: #f97316; color: #f97316;
@@ -373,14 +377,14 @@ th {
line-height: 1.4; line-height: 1.4;
white-space: nowrap; white-space: nowrap;
} }
.more-btn { .more-btn {
width: 32px; width: 32px;
height: 32px; height: 32px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
color: #2563eb; color: #2563eb;
} }
@@ -417,43 +421,43 @@ th {
gap: 4px; gap: 4px;
justify-content: center; justify-content: center;
} }
.empty-row { .empty-row {
color: #64748b; color: #64748b;
text-align: center; text-align: center;
} }
.list-foot { .list-foot {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-top: 8px; margin-top: 8px;
} }
.pager { .pager {
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
padding: 4px; padding: 4px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 12px; border-radius: 12px;
background: #f8fafc; background: #f8fafc;
} }
.pager button { .pager button {
width: 32px; width: 32px;
height: 32px; height: 32px;
padding: 0; padding: 0;
border: 0; border: 0;
border-radius: 9px; border-radius: 9px;
background: transparent; background: transparent;
color: #334155; color: #334155;
font-size: 14px; font-size: 14px;
font-weight: 800; font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
} }
.pager button:hover:not(.active) { .pager button:hover:not(.active) {
background: #fff; background: #fff;
color: var(--theme-primary-active); color: var(--theme-primary-active);
@@ -465,101 +469,101 @@ th {
color: #fff; color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow); box-shadow: 0 8px 16px var(--theme-primary-shadow);
} }
.list-foot .page-summary { .list-foot .page-summary {
color: #64748b; color: #64748b;
font-size: 14px; font-size: 14px;
font-weight: 650; font-weight: 650;
} }
.page-nav { .page-nav {
color: #64748b; color: #64748b;
} }
.page-size-select { .page-size-select {
width: 112px; width: 112px;
justify-self: end; justify-self: end;
} }
.preview-panel { .preview-panel {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
padding: 20px 22px; padding: 20px 22px;
overflow: hidden; overflow: hidden;
} }
.preview-head { .preview-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 18px; gap: 18px;
padding-bottom: 16px; padding-bottom: 16px;
border-bottom: 1px solid #edf2f7; border-bottom: 1px solid #edf2f7;
} }
.preview-copy { .preview-copy {
min-width: 0; min-width: 0;
} }
.preview-actions { .preview-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.mini-action, .mini-action,
.icon-action, .icon-action,
.viewer-toolbar-actions button { .viewer-toolbar-actions button {
border: 1px solid #d7e0ea; border: 1px solid #d7e0ea;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
color: #334155; color: #334155;
} }
.mini-action { .mini-action {
min-height: 34px; min-height: 34px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 0 12px; padding: 0 12px;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
} }
.icon-action { .icon-action {
width: 34px; width: 34px;
height: 34px; height: 34px;
display: grid; display: grid;
place-items: center; place-items: center;
} }
.preview-summary-line { .preview-summary-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 8px; margin-top: 8px;
color: #64748b; color: #64748b;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
} }
.preview-secondary-line { .preview-secondary-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 12px; margin-top: 12px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 10px; border-radius: 10px;
background: #1e293b; background: #1e293b;
color: #e2e8f0; color: #e2e8f0;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
} }
.preview-viewer { .preview-viewer {
min-height: 0; min-height: 0;
margin-top: 18px; margin-top: 18px;
@@ -585,6 +589,7 @@ th {
min-height: 0; min-height: 0;
} }
.preview-modal-panel { .preview-modal-panel {
height: 100%; height: 100%;
border-radius: 24px; border-radius: 24px;
@@ -1401,3 +1406,22 @@ th {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@keyframes listRowIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.folder-tree button,
.doc-row,
.page-sheet {
animation: none !important;
}
}

View File

@@ -365,8 +365,10 @@
} }
.dialog-panel { .dialog-panel {
flex: 1 1 auto; flex: 1 1 0;
height: auto;
min-height: 0; min-height: 0;
max-height: 100%;
} }
.insight-panel-shell { .insight-panel-shell {

View File

@@ -619,10 +619,13 @@
.assistant-layout { .assistant-layout {
min-height: 0; min-height: 0;
flex: 1; flex: 1;
height: 100%;
max-height: 100%;
display: flex; display: flex;
padding: clamp(12px, 1.5vw, 16px); padding: clamp(12px, 1.5vw, 16px);
align-items: stretch; align-items: stretch;
gap: clamp(12px, 1.5vw, 16px); gap: clamp(12px, 1.5vw, 16px);
overflow: hidden;
} }
.dialog-panel, .dialog-panel,
@@ -641,8 +644,11 @@
.dialog-panel { .dialog-panel {
flex: 1 1 auto; flex: 1 1 auto;
display: grid; display: flex;
grid-template-rows: auto minmax(0, 1fr) auto; flex-direction: column;
height: 100%;
max-height: 100%;
min-height: 0;
overflow: hidden; overflow: hidden;
background: #ffffff; background: #ffffff;
transition: transition:
@@ -671,6 +677,7 @@
} }
.dialog-toolbar { .dialog-toolbar {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@@ -766,12 +773,15 @@
} }
.message-list { .message-list {
flex: 1 1 0;
min-height: 0; min-height: 0;
max-height: 100%;
display: grid; display: grid;
align-content: start; align-content: start;
gap: 14px; gap: 14px;
padding: 18px; padding: 18px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
} }
.message-row.user .message-avatar { .message-row.user .message-avatar {
@@ -1918,6 +1928,13 @@
padding: 0 18px 18px; padding: 0 18px 18px;
display: grid; display: grid;
gap: 12px; gap: 12px;
position: sticky;
bottom: 0;
z-index: 20;
flex: 0 0 auto;
flex-shrink: 0;
background: #ffffff;
box-shadow: 0 -10px 22px rgba(248, 250, 252, 0.92);
} }
.hidden-file-input { .hidden-file-input {
@@ -1994,3 +2011,37 @@
font-size: 13px; font-size: 13px;
font-weight: 900; font-weight: 900;
} }
.message-row-reveal-enter-active {
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.message-row-reveal-enter-from {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
.message-row-reveal-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
}
.message-row-reveal-leave-active {
transition: opacity 200ms ease, transform 200ms ease;
position: absolute;
width: 100%;
}
.message-row-reveal-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
.message-row-reveal-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
.message-row-reveal-move {
transition: transform 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

View File

@@ -1,5 +1,7 @@
# Workbench Icons # Workbench Icons
Icons in this folder are sourced from [Heroicons](https://heroicons.com) (MIT License). Icons in this folder are SVG assets used by the Personal Workbench todo,
progress, and assistant capability entries.
Used on the Personal Workbench todo and progress lists. The assistant capability icons are custom line icons designed for the
X-Financial workbench visual system.

View File

@@ -1,6 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8.5 4.5h7A1.5 1.5 0 0 1 17 6v13a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 19V6a1.5 1.5 0 0 1 1.5-1.5h2"/> <path class="icon-fill" d="M7.1 4.7h8.4c.9 0 1.6.7 1.6 1.6v12.3c0 .9-.7 1.6-1.6 1.6H7.1c-.9 0-1.6-.7-1.6-1.6V6.3c0-.9.7-1.6 1.6-1.6Z"/>
<path d="M8.5 4.5A2 2 0 0 1 10.5 3h2A2 2 0 0 1 14.5 4.5v1h-6Z"/> <path d="M8.5 4.7H7.1c-.9 0-1.6.7-1.6 1.6v12.3c0 .9.7 1.6 1.6 1.6h8.4c.9 0 1.6-.7 1.6-1.6V6.3c0-.9-.7-1.6-1.6-1.6h-1.3"/>
<path d="M8.5 12.8 11 15.2l5-5.2"/> <path class="icon-accent" d="M8.5 4.7c.2-.9 1-1.6 2-1.6h1.8c1 0 1.8.7 2 1.6v1.2H8.5Z"/>
<path d="M8 18h7"/> <path d="m8.7 12.6 2.3 2.2 4.9-5.1"/>
<path class="icon-muted" d="M8.2 18h6.6"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 625 B

View File

@@ -1,5 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4.5v7h7A7 7 0 1 1 12 4.5Z"/> <path class="icon-fill" d="M11.6 4.2a7.5 7.5 0 1 0 7.5 7.5h-7.5Z"/>
<path d="M14.5 3.8A7.2 7.2 0 0 1 20.2 9h-5.7Z"/> <path d="M11.6 4.2a7.5 7.5 0 1 0 7.5 7.5h-7.5Z"/>
<path d="M7.5 16.5h4M7.5 13.5H10"/> <path class="icon-accent" d="M14.3 3.9a7.3 7.3 0 0 1 5.8 5.7h-5.8Z"/>
<path d="M7.5 15.7h4.5"/>
<path class="icon-muted" d="M7.5 18h3.1"/>
<path d="M16.8 14.6v2.9M19.2 13.2v4.3"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 320 B

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -1,6 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 3.5h7l3 3v13a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5v-15A1.5 1.5 0 0 1 7.5 3.5Z"/> <path class="icon-fill" d="M7.5 3.7h7.1l3.2 3.2v11.7c0 .9-.7 1.6-1.6 1.6H7.5c-.9 0-1.6-.7-1.6-1.6V5.3c0-.9.7-1.6 1.6-1.6Z"/>
<path d="M14 3.5V7h3.5"/> <path d="M7.5 3.7h7.1l3.2 3.2v11.7c0 .9-.7 1.6-1.6 1.6H7.5c-.9 0-1.6-.7-1.6-1.6V5.3c0-.9.7-1.6 1.6-1.6Z"/>
<path d="M9 11h6M9 14h3"/> <path class="icon-accent" d="M14.5 3.8v3.3h3.3"/>
<path d="M15.5 13.5v4M13.5 15.5h4"/> <path d="M8.8 10.4h6.4M8.8 13h4.2"/>
<path class="icon-muted" d="M8.8 15.7h2.6"/>
<path d="M16.4 13.9v4.1M14.4 16h4.1"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 603 B

Some files were not shown because too many files have changed in this diff Show More