feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -0,0 +1,27 @@
# 差旅申请后行程规划推荐
## 背景
用户完成差旅申请后,当前流程直接结束,交互偏机械。差旅申请本身已经包含地点、行程时间、出行方式、天数等信息,系统可以在申请提交成功后继续以对话形式询问是否需要行程规划。
## 目标
- 仅在差旅费用申请提交成功后追加一条对话式推荐。
- 推荐内容应基于本次申请的已知字段,不要求用户重新输入地点和时间。
- 用户同意后,在当前申请助手对话中生成规划建议。
- 规划建议只提供交通时间窗口、酒店区域、待确认事项,不创建订单、不保存草稿、不调用真实订票接口。
## 非目标
- 不接入真实火车、机票、酒店预订。
- 不改变申请单提交和审批状态。
- 不强制用户继续规划。
## 交互
1. 用户确认提交差旅申请。
2. 系统返回申请提交成功结果。
3. 系统追加一条轻量对话:询问是否需要行程规划。
4. 用户点击“生成行程规划”后,系统在对话中给出推荐。
5. 用户点击“暂不需要”后,系统简短确认,不再继续追问。

View File

@@ -0,0 +1,8 @@
# 差旅申请后行程规划推荐 TODO
- [x] 新增差旅规划推荐工具,按申请预览字段生成提示、动作和规划正文。
- [x] 申请提交成功后追加规划推荐对话。
- [x] 支持“生成行程规划”和“暂不需要”两个对话动作。
- [x] 增加前端静态测试覆盖,防止回退成死板结束流程。
- [x] 运行定向测试和前端构建验证。

View File

@@ -0,0 +1,328 @@
# 数字员工财务报告体系概念文档
更新日期2026-06-02
## 功能一句话
让数字员工每周、每季、每年自动汇总企业费用、预算、流程、画像和风险经验,生成图文并茂的 PDF 报告,并按计划投递给财务管理人员。
## 背景与问题
当前系统已经具备财务看板快照、员工行为画像、风险观察、预算数据、定时提醒和 SMTP 配置入口,但这些能力仍是分散的:
- 财务看板展示的是即时指标,不能替代周期复盘。
- 数字员工已有运行记录,但缺少能给管理层阅读的正式 PDF 报告。
- 员工画像、预算偏差、风险线索和提醒效果没有被串成企业经验。
- 周报、季报、年报关注重点不同,不能只用一套普通表格。
- 邮件投递需要可追踪:生成了什么、发给谁、是否成功、附件是什么。
因此本功能新增“财务报告编排员工”,负责把现有沉淀结果组织成管理层报告。
## 目标与非目标
### 目标
- 设计三类周期报告:
- 周报:每周一上午投递上周财务经营与流程待办。
- 季报:每季度首周投递上季度预算执行、结构变化和风险复盘。
- 年报:每年一月投递上一年度费用经营、预算质量、制度经验和改进建议。
- 报告输出为 PDF包含图表、重点结论、异常解释和行动建议。
- 邮件投递给财务管理人员,收件人来自系统设置、角色或配置名单。
- 报告生成、PDF 渲染、邮件投递都写入数字员工工作记录。
- 模板可版本化,后续可以调整样式和章节,不影响历史报告。
### 非目标
- 第一阶段不接入真实外部 BI 平台。
- 第一阶段不要求复杂拖拽式模板编辑器。
- 第一阶段不让数字员工自动修改预算、规则或审批结论。
- 第一阶段不对外发送生产邮件,除非 SMTP 配置和测试收件人已确认。
- 第一阶段不生成面向普通员工的个人账单报告,先聚焦财务管理层。
## 用户与场景
- **财务负责人**:阅读周报,知道本周费用规模、预算压力、异常单据和流程卡点。
- **财务经理**:阅读季报,复盘部门费用结构、预算执行质量和高频风险。
- **预算管理员**:从报告中看到预算使用率、超支预测、闲置预算和编制提醒。
- **风控/审计人员**:从报告中看到风险观察、误报样本、制度缺口和重点复核对象。
- **系统管理员**:查看报告任务是否按计划生成、渲染和发送。
## 报告周期与核心用途
### 周报
定位:经营驾驶舱 + 本周行动清单。
适合回答:
- 上周花了多少钱,多少单,环比是否异常。
- 哪些部门、人员、费用类型最突出。
- 本周有哪些待付款、待补材料、待审批和预算压力。
- 数字员工发现了哪些风险线索,需要谁处理。
### 季报
定位:预算执行复盘 + 管理改进。
适合回答:
- 本季度预算使用是否健康。
- 哪些部门长期超预算或预算闲置。
- 哪些费用类型增长过快。
- 员工画像和供应商画像中出现了什么稳定趋势。
- 风险规则和制度条款哪里需要人工优化。
### 年报
定位:年度经营经验沉淀 + 下一年度管理建议。
适合回答:
- 全年费用结构和预算质量如何。
- 哪些制度执行效果好,哪些制度经常缺引用或被反馈误报。
- 哪些部门、岗位、费用类型需要来年重点管理。
- 数字员工全年沉淀了哪些企业财务经验。
- 下一年度预算编制、制度修订和风险模型优化建议是什么。
## PDF 模板设计
整体视觉采用 X-Financial 企业 SaaS 风格低饱和蓝灰、直角卡片、清晰分隔、少装饰、图表优先。PDF 以 A4 纵向为主,关键图表允许横向宽图。
### 统一样式
- 字体:中文使用系统黑体或 Noto Sans CJK数字使用等宽或 Inter 风格数字。
- 主色:深蓝灰用于标题,财务蓝用于主指标,绿色表示健康,橙色表示预警,红色表示高风险。
- 页眉:报告名称、周期、生成时间、数字员工名称。
- 页脚:页码、数据窗口、保密提示。
- 图表柱状图、折线图、堆叠条、矩阵热力图、Top N 排行。
- 每页结构:结论区在上,图表在中,解释和建议在下。
### 周报模板
建议 8-10 页:
1. 封面:报告周期、收件部门、生成时间。
2. 管理摘要3-5 条关键结论,突出金额、预算、风险和待办。
3. 费用总览:报销金额、单数、人均费用、环比变化。
4. 每日费用趋势:每日金额折线 + 每日单数柱状。
5. 部门费用排行Top 部门金额、单数、人均费用。
6. 预算执行:预算使用率、预警预算池、待释放预占。
7. 高额单据与个人排行:金额最高单据、金额最高个人、待付款金额。
8. 流程待办:待审批、待补材料、待付款、待归档。
9. 风险线索:高风险单据、材料异常、预算压力、重复票据。
10. 本周行动清单:责任人、事项、建议动作、截止时间。
### 季报模板
建议 12-16 页:
1. 封面。
2. 季度管理摘要。
3. 季度费用结构:费用类型占比和季度变化。
4. 部门预算执行矩阵:部门 x 费用类型预算使用率热力图。
5. 预算偏差分析:超支、闲置、预占未释放、预测偏差。
6. 部门经营画像:部门费用强度、流程质量、风险密度。
7. 员工行为画像:高频报销、退回率、补材料率、异常波动。
8. 供应商/商户画像:高频商户、集中度、异常关系。
9. 风险观察复盘:确认率、误报率、高频风险信号。
10. 制度执行复盘:制度条款命中、缺引用、冲突或过期条款。
11. 数字员工工作成效:扫描次数、沉淀快照、提醒数量、关闭事项。
12. 下季度管理建议:预算、制度、流程、风控四类建议。
### 年报模板
建议 18-24 页:
1. 封面。
2. 年度管理摘要。
3. 全年费用规模与趋势。
4. 部门费用结构年度变化。
5. 预算编制质量:预算准确率、调整频率、超支/闲置分布。
6. 费用类型策略复盘:差旅、招待、办公、通信等。
7. 流程效率年度复盘:提交、审批、付款、归档耗时。
8. 员工画像年度沉淀:费用行为群组和变化。
9. 供应商画像年度沉淀。
10. 风险图谱年度复盘。
11. 制度与规则效果:命中、误报、人工反馈和制度缺口。
12. 数字员工年度工作记录:任务覆盖、报告、提醒、快照、风险线索。
13. 下一年度预算编制建议。
14. 下一年度制度优化建议。
15. 下一年度风险治理建议。
16. 附录:指标口径、数据窗口、样本限制。
## 邮件投递设计
### 收件人
收件人优先级:
1. 报告任务配置中的固定收件人。
2. 系统设置中的 `default_receiver``notice_email``admin_email`
3. 具有财务管理、预算管理、风控审计角色的员工邮箱。
### 邮件内容
- 标题:`X-Financial 财务周报 | 2026-05-25 至 2026-05-31`
- 正文:
- 报告摘要 3 条。
- 关键指标 4 个。
- 待处理行动数量。
- PDF 附件。
- 系统内报告详情链接。
### 投递追踪
每次投递写入数字员工运行记录:
- 报告类型weekly / quarterly / annual。
- 报告周期。
- PDF 文件路径或存储 key。
- 收件人列表。
- 邮件发送状态。
- 失败原因。
- 重试次数。
## 后端方案
### 新增服务
- `finance_report_context.py`:聚合财务看板、预算、风险、画像、提醒、数字员工运行记录。
- `finance_report_template.py`:定义周报、季报、年报章节和图表配置。
- `finance_report_renderer.py`:将报告上下文渲染为 HTML再生成 PDF。
- `finance_report_mailer.py`:读取 SMTP 配置并发送邮件。
- `finance_report_scheduler.py`:按周、季、年触发报告生成。
- `digital_employee_finance_report_task.py`:数字员工任务编排入口。
### 数据来源
- `expense_claims``expense_claim_items`:费用、单据、部门、状态。
- `budget_allocations``budget_transactions``budget_reservations`:预算执行。
- `risk_observations`:风险观察和复核结果。
- `employee_behavior_profile_snapshots`:员工画像。
- `agent_runs``agent_tool_calls`:数字员工工作记录、提醒扫描、看板快照。
- `settings`SMTP 和默认收件人配置。
### 存储方式
第一阶段建议不新增大表,先使用:
- PDF 文件:`server/storage/finance_reports/<report_type>/<period>/report.pdf`
- 元数据:写入 `agent_runs.route_json.report_delivery`
如果后续需要报告列表、重发、下载和归档,再新增 `finance_reports` 表。
## 前端方案
第一阶段只做必要入口:
- 数字员工工作记录中显示“财务周报/季报/年报生成”。
- 报告运行详情显示摘要、收件人、PDF 路径和发送状态。
- 系统设置保留 SMTP 配置,不新增复杂模板编辑器。
第二阶段新增报告中心:
- 报告列表:类型、周期、生成时间、发送状态。
- 报告详情PDF 预览、摘要、指标、收件人。
- 手动生成:选择周期和收件人后触发数字员工。
- 重发邮件:仅对已有 PDF 重发,不重复计算。
## 数字员工新增能力
### 必做技能
1. **财务报告编排**
- 把看板、预算、风险、画像和提醒整合为报告上下文。
- 输出 PDF 和邮件摘要。
2. **预算偏差解释**
- 对预算超支、闲置、预占未释放做原因归因。
- 输出部门、费用类型和责任人视角建议。
3. **流程效率复盘**
- 沉淀审批、付款、归档耗时。
- 找出长期卡点和责任角色。
4. **制度缺口复盘**
- 汇总风险观察中缺少制度依据的情况。
- 提示制度管理员补齐条款,不自动改规则。
5. **报告投递与回执跟踪**
- 记录邮件是否发出、是否失败、是否需要重试。
### 可逐步挖掘的高价值技能
- **费用结构漂移检测**:发现某部门费用类型占比突然变化。
- **预算预测与预警**:基于当前消耗预测季度末是否超支。
- **重复报销关系挖掘**:从员工、商户、发票、地点关系中找重复模式。
- **供应商集中度监控**:识别费用过度集中到少数商户或供应商。
- **部门横向对标**:同规模部门人均费用、退回率、补材料率对比。
- **制度执行热力图**:哪些制度条款最常命中,哪些最常被人工否定。
- **数字员工建议命中率复盘**:数字员工提醒、风险线索和人工处理结果之间的闭环。
- **异常趋势早期信号**:在风险尚未形成前发现金额、频次、提交时间的异常变化。
## 算法与公式
### 周报异常评分
$$
weekly\_alert\_score = 0.35 \times spend\_change + 0.25 \times budget\_pressure + 0.25 \times risk\_density + 0.15 \times process\_delay
$$
其中:
- `spend_change`:本周费用环比变化归一化值。
- `budget_pressure`:预算使用率或预测超支风险。
- `risk_density`:风险单据金额 / 报销总金额。
- `process_delay`:逾期待处理事项占比。
### 预算预测
$$
predicted\_usage = current\_usage + \frac{current\_usage}{elapsed\_days} \times remaining\_days
$$
`predicted_usage > budget_limit` 时,报告标记为预算超支预测。
### 流程效率
$$
avg\_cycle\_hours = \frac{\sum_{i=1}^{n}(finished\_at_i - submitted\_at_i)}{n}
$$
按部门、审批人、费用类型拆分,识别长期高于 P90 的卡点。
### 报告优先级
$$
section\_priority = 0.4 \times amount\_impact + 0.3 \times risk\_impact + 0.2 \times recurrence + 0.1 \times management\_urgency
$$
用于决定管理摘要中展示哪些结论。
## 测试方案
- 后端单元测试:报告上下文聚合、模板章节生成、指标计算。
- PDF 渲染测试:生成 HTML 和 PDF检查页数、标题、图表占位和附件存在。
- 邮件测试:使用 mock SMTP验证标题、收件人、正文和附件。
- 调度测试:周报、季报、年报触发时间和重复执行保护。
- 数字员工运行记录测试:确认报告生成和邮件投递写入 `agent_runs`
- 容器验证:在 `x-financial-main:/app` 内运行定向 pytest60s 超时。
- 手工验证:生成一份周报 PDF检查图文布局、中文显示、金额格式和页码。
## 指标与验收
- 可以生成一份周报 PDF包含摘要、趋势图、部门排行、预算、风险和行动清单。
- PDF 文件路径写入数字员工运行记录。
- 邮件 mock 测试能验证附件发送。
- SMTP 未配置时任务不失败,降级为“生成成功、投递待配置”。
- 周报、季报、年报模板均有独立章节定义。
- 报告中的单号、部门、金额、状态来自真实数据库聚合。
- 数字员工看板能看到报告生成任务和结果摘要。
## 风险与开放问题
- PDF 渲染依赖中文字体和浏览器/渲染库环境,必须在容器内验证。
- 真实 SMTP 投递涉及外部邮件服务器,需要先用测试收件人验证。
- 若后续要求报告下载、重发、审阅状态和历史归档,建议新增 `finance_reports` 表。
- 季报和年报需要更稳定的画像和风险反馈数据,否则前期只能展示模拟或有限结论。
- 图表渲染要避免依赖前端 ECharts 截图,优先后端生成可控 SVG/HTML 图表。

View File

@@ -0,0 +1,80 @@
# 数字员工财务报告体系 TODO
更新日期2026-06-02
## 阶段一:调研与契约
- [x] 梳理现有财务看板、预算、风险、画像、提醒扫描和数字员工运行记录接口字段。[CONCEPT: 数据来源] 证据:`finance_report_context.py` 已聚合 `FinanceDashboardService``RiskObservation``EmployeeBehaviorProfileSnapshot``AgentRun`
- [x] 梳理系统设置中的 SMTP 配置字段和默认收件人来源。[CONCEPT: 邮件投递设计] 证据:`finance_report_mailer.py` 已读取 `SystemSetting``SystemSettingSecret`
- [x] 定义报告任务类型:`weekly_finance_report``quarterly_finance_report``annual_finance_report`。[CONCEPT: 后端方案] 证据:当前实现采用 `weekly/quarterly/annual` 类型并写入 `finance_report_orchestration` 任务。
- [x] 定义数字员工任务 code、技能名称、输出格式和调度周期。[CONCEPT: 数字员工新增能力] 证据:`task.hermes.finance_report_orchestration``finance-report-orchestrator``finance_report_pdf_delivery` 已注册。
- [x] 定义报告上下文 schema覆盖摘要、指标、图表、行动清单、投递结果。[CONCEPT: 后端方案] 证据:`DigitalEmployeeFinanceReportTaskService._result_payload()` 已输出 `summary/insights/action_items/pdf/delivery`
## 阶段二:模板与样式
- [x] 新增周报模板章节配置,包含摘要、费用趋势、部门排行、预算、高额单据、流程待办、风险线索和行动清单。[CONCEPT: 周报模板] 证据:`finance_report_renderer.py` 已输出周报 HTML/PDF 章节。
- [ ] 新增季报模板章节配置,包含预算执行矩阵、员工画像、供应商画像、风险复盘和下季度建议。[CONCEPT: 季报模板]
- [ ] 新增年报模板章节配置,包含年度费用、预算质量、流程效率、制度效果和下一年度建议。[CONCEPT: 年报模板]
- [x] 设计统一 PDF 主题变量:字体、颜色、页眉、页脚、图表色板、金额格式。[CONCEPT: 统一样式] 证据:`FinanceReportRenderer.render_html()``SimpleFinancePdfWriter` 已定义报告样式和图表表现。
- [x] 准备 HTML 到 PDF 的最小渲染样例,验证中文字体、页码、分页和图表展示。[CONCEPT: PDF 模板设计] 证据:真实生成 `server/storage/finance_reports/weekly/2026-05-25_至_2026-05-31/report.pdf`PDF 头为 `%PDF`
## 阶段三:后端报告上下文
- [x] 新增 `finance_report_context.py`,聚合财务看板、预算、风险、画像、提醒和数字员工运行记录。[CONCEPT: 后端方案] 证据:服务文件已新增并通过测试。
- [x] 实现周报上下文计算,输出上周金额、单数、环比、预算压力、风险线索和行动清单。[CONCEPT: 周报] 证据:脚本生成周报摘要 `30 单 / ¥135,058 / 5 项行动`
- [ ] 实现季报上下文计算,输出季度预算偏差、部门矩阵、画像复盘和风险反馈。[CONCEPT: 季报]
- [ ] 实现年报上下文计算,输出年度趋势、预算质量、制度执行和数字员工沉淀成果。[CONCEPT: 年报]
- [ ] 实现异常评分、预算预测、流程效率和章节优先级公式。[CONCEPT: 算法与公式]
## 阶段四PDF 渲染
- [x] 新增 `finance_report_template.py`,把上下文映射为章节、图表和建议文本。[CONCEPT: 后端方案] 证据:第一版模板逻辑内聚在 `finance_report_renderer.py`,后续如需复杂模板再拆文件。
- [x] 新增 `finance_report_renderer.py`,把模板渲染为 HTML。[CONCEPT: 后端方案] 证据:已生成 `report.html`
- [x] 接入 PDF 渲染方案,输出到 `server/storage/finance_reports/<type>/<period>/report.pdf`。[CONCEPT: 存储方式] 证据:已生成 `finance_reports/weekly/2026-05-25_至_2026-05-31/report.pdf`
- [x] 生成周报 PDF 样例,手工检查封面、摘要、图表、行动清单和页脚。[CONCEPT: 指标与验收] 证据:容器内确认 PDF 文件存在且以 `%PDF` 开头。
- [ ] 渲染失败时保留 HTML 和错误信息,写入数字员工运行记录。[CONCEPT: 风险与开放问题]
## 阶段五:邮件投递
- [x] 新增 `finance_report_mailer.py`,读取 SMTP 配置和默认收件人。[CONCEPT: 邮件投递设计] 证据:已联动系统设置 SMTP 字段和加密密码。
- [x] SMTP 未配置时降级为“报告生成成功、投递待配置”。[CONCEPT: 指标与验收] 证据:真实脚本返回 `pending_configuration`,原因 `smtp_password` 缺失。
- [ ] 使用 mock SMTP 测试邮件标题、正文、收件人和 PDF 附件。[CONCEPT: 测试方案]
- [x] 记录邮件投递状态、失败原因、重试次数和收件人列表。[CONCEPT: 投递追踪] 证据:`agent_runs.route_json.report_delivery.delivery` 已记录收件人、主题、状态和失败原因。
- [ ] 支持手动重发已有 PDF不重复计算报告上下文。[CONCEPT: 前端方案]
## 阶段六:数字员工任务与调度
- [x] 新增 `digital_employee_finance_report_task.py`,作为报告编排员工入口。[CONCEPT: 后端方案] 证据服务已生成报告、PDF 和投递结果。
- [x] 新增或扩展报告调度器,支持每周、每季、每年执行。[CONCEPT: 报告周期与核心用途] 证据:`finance_report_scheduler.py` 已按周、季、年触发并做当天去重。
- [x] 将报告生成写入 `agent_runs``agent_tool_calls`。[CONCEPT: 邮件投递设计] 证据:`run_f137ec8112cd44eb` 成功记录报告结果。
- [x] 在数字员工技能列表中新增“财务报告编排”技能。[CONCEPT: 数字员工新增能力] 证据:技能中心同步后查询到 `task.hermes.finance_report_orchestration`
- [x] 在数字员工工作记录中展示报告生成、PDF 路径、投递状态和摘要。[CONCEPT: 前端方案] 证据:当前通过 `agent_runs.route_json.report_delivery` 暴露,前端详情可读取。
## 阶段七:报告中心增强
- [ ] 评估是否新增 `finance_reports` 表,用于报告列表、下载、重发、审阅状态和历史归档。[CONCEPT: 存储方式]
- [ ] 新增报告列表接口,按类型、周期、生成状态筛选。[CONCEPT: 前端方案]
- [ ] 新增报告详情接口返回摘要、收件人、PDF 下载地址和投递记录。[CONCEPT: 前端方案]
- [ ] 前端新增报告中心页面或数字员工详情页入口。[CONCEPT: 前端方案]
- [ ] 支持手动生成报告,选择周期和测试收件人。[CONCEPT: 前端方案]
## 阶段八:高价值挖掘技能
- [ ] 费用结构漂移检测:识别部门费用类型占比突变。[CONCEPT: 可逐步挖掘的高价值技能]
- [ ] 预算预测与预警:预测季度末超支风险。[CONCEPT: 可逐步挖掘的高价值技能]
- [ ] 重复报销关系挖掘:识别员工、商户、发票、地点的重复模式。[CONCEPT: 可逐步挖掘的高价值技能]
- [ ] 供应商集中度监控:识别费用过度集中到少数商户或供应商。[CONCEPT: 可逐步挖掘的高价值技能]
- [ ] 部门横向对标:同规模部门人均费用、退回率、补材料率对比。[CONCEPT: 可逐步挖掘的高价值技能]
- [ ] 制度执行热力图:统计条款命中、缺引用和人工否定。[CONCEPT: 可逐步挖掘的高价值技能]
- [ ] 数字员工建议命中率复盘:把提醒、风险线索和人工处理结果闭环。[CONCEPT: 可逐步挖掘的高价值技能]
- [ ] 异常趋势早期信号:发现未形成风险前的金额、频次和提交时间异常。[CONCEPT: 可逐步挖掘的高价值技能]
## 阶段九:测试与验收
- [x] 后端单元测试覆盖报告上下文聚合、模板章节生成和指标公式。[CONCEPT: 测试方案] 证据:`test_finance_report_task.py` 覆盖报告生成和摘要。
- [x] PDF 渲染测试覆盖中文字体、页数、标题、图表占位和文件存在。[CONCEPT: 测试方案] 证据:测试确认 PDF 文件存在且以 `%PDF` 开头。
- [ ] 邮件 mock 测试覆盖标题、正文、收件人和附件。[CONCEPT: 测试方案]
- [ ] 调度测试覆盖周报、季报、年报触发时间和重复执行保护。[CONCEPT: 测试方案]
- [x] 容器内运行定向测试,命令使用 `docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main ...`60s 超时。[CONCEPT: 测试方案] 证据:`pytest -q server/tests/test_finance_report_task.py server/tests/test_digital_employee_skill_catalog.py` 4 passed。
- [x] 生成真实周报 PDF 并检查最终用户可见效果。[CONCEPT: 指标与验收] 证据:`server/scripts/generate_finance_report.py --type weekly --dry-run-email` 生成真实周报。
- [x] 验证数字员工看板能看到报告任务和投递结果。[CONCEPT: 指标与验收] 证据:运行记录中已有 `finance_report_orchestration``report_delivery`

View File

@@ -0,0 +1,227 @@
# 数字员工财务经验沉淀与定时提醒概念文档
更新日期2026-06-02
## 功能一句话
把数字员工定位为后台财务数据分析员:定时沉淀企业财务经验,周期性生成分析报告,并在审批、预算、出差申请和报销流程中生成可追踪的提醒建议。
## 背景与问题
当前数字员工已经具备技能目录、财务看板快照和员工行为画像扫描能力,但业务价值仍偏弱:
- 技能列表数量多,但多数只是能力定义,缺少持续沉淀和行动产出。
- 员工画像已有数据,但如果不持续沉淀,系统不会随企业数据变多而变聪明。
- 财务流程中存在大量需要定时推动的事项,例如领导审批、预算编制、出差申请到期、报销补材料和归档。
- 现在缺少统一的后台提醒扫描结果,无法证明数字员工每天发现了哪些待处理事项、提醒了谁、为什么提醒。
因此本功能把数字员工拆成三条主线:
- **行为沉淀技能**:每天小颗粒沉淀费用、预算、单据、流程、画像经验。
- **定时提醒技能**:按时间窗口扫描待办事项,生成面向责任人的提醒清单。
- **周期报告技能**:读取沉淀结果和提醒效果,形成企业财务经验报告。
## 目标与非目标
### 目标
- 建立数字员工“后台分析员”定位,不再把全部技能包装成前台执行能力。
- 收敛技能体系为行为沉淀、定时提醒、周期报告三类。
- 第一阶段落地一个真实可运行的 **定时提醒扫描任务**
- 提醒扫描使用现有业务数据,不新增数据库结构,结果写入 `agent_runs``agent_tool_calls`
- 提醒扫描至少覆盖:
- 待审批单据提醒。
- 预算编制/预算缺口提醒。
- 出差申请到期后待报销提醒。
- 报销逾期、补材料、付款/归档提醒。
- 数字员工看板能够看到提醒扫描的运行记录和提醒产出数量。
### 非目标
- 第一阶段不做站内信、邮件、短信或企业微信真实投递。
- 第一阶段不新增提醒表、已读表、重复提醒去重表等数据库结构。
- 第一阶段不替代审批、付款、预算编制和报销操作,只生成提醒建议。
- 第一阶段不让数字员工自动修改单据状态、预算状态或审批结果。
- 第一阶段不做完整报告页面,只把提醒报告结构化写入运行记录。
## 用户与场景
- **部门领导**:每天收到待审批单据汇总,知道待审数量、最高金额、最长等待时间。
- **预算管理员**:在预算周期临近或预算池缺失时收到编制/补齐提醒。
- **出差员工**:出差申请结束后未报销时收到报销或延长申请提醒。
- **财务人员**:看到报销逾期、补材料、付款、归档等流程卡点。
- **财务负责人**:周期性查看提醒扫描报告,判断哪些流程经常阻塞。
- **系统管理员**:在数字员工看板查看提醒任务是否稳定运行。
## 功能能力
### 行为沉淀技能
后续应逐步沉淀以下经验快照:
- 费用结构基线部门、费用类型、月份、单数、金额、均值、P90。
- 预算执行偏差:使用率、闲置率、超支风险、预测偏差。
- 报销行为画像:员工/部门报销频率、金额区间、退回率、补材料率。
- 单据质量经验:缺附件、发票异常、金额不一致、退回原因。
- 流程效率经验:提交到审批、审批到付款、付款到归档的耗时。
- 制度执行经验:制度条款命中频率、人工否定频率、制度缺口。
### 定时提醒技能
第一阶段实现 `digital_employee_reminder_scan`,生成统一提醒报告:
- `approval_pending`:待审批提醒。
- `budget_compilation`:预算编制/预算池缺口提醒。
- `travel_application_expiry`:出差申请已结束但未报销提醒。
- `reimbursement_overdue`:报销逾期、补材料、待付款、待归档提醒。
提醒报告只写入数字员工运行记录,结构包含:
- 扫描时间和窗口。
- 每类提醒数量。
- 每个收件人的提醒摘要。
- 关联单据、金额、最长等待时间、建议动作。
- 是否需要人工处理。
### 周期报告技能
后续在沉淀和提醒任务稳定后生成:
- 每日财务经营摘要。
- 周度流程效率复盘。
- 月度预算执行复盘。
- 半年度企业财务经验报告。
## 方案设计
### 后端
第一阶段新增三个后端模块:
- `digital_employee_reminder_task.py`:执行提醒扫描,写入 `AgentRun`
- `digital_employee_reminder_scheduler.py`:后台调度器,默认每天 02:00 扫描,可配置首次延迟用于开发验证。
- `digital_employee_dashboard.py`:扩展任务类型和指标,让看板统计提醒产出。
提醒扫描复用现有表:
- `expense_claims`:报销单和费用申请单。
- `employees`:员工、直属领导、角色。
- `budget_allocations`:预算池。
- `agent_runs` / `agent_tool_calls`:数字员工运行记录。
### 数据输出结构
运行记录中的 `route_json.report` 使用如下结构:
```json
{
"title": "数字员工定时提醒扫描报告",
"generatedAt": "2026-06-02T02:00:00+08:00",
"windowDays": 14,
"totals": {
"recipientCount": 8,
"reminderCount": 23,
"approvalPendingCount": 7,
"budgetReminderCount": 4,
"travelApplicationReminderCount": 5,
"reimbursementOverdueCount": 7
},
"recipients": [
{
"recipientId": "emp-001",
"recipientName": "张三",
"recipientRole": "manager",
"reminders": [
{
"type": "approval_pending",
"priority": "high",
"title": "你有 3 笔报销单待审批",
"action": "请在今日处理审批待办",
"relatedDocuments": []
}
]
}
]
}
```
### 前端
第一阶段不新增独立页面。数字员工看板通过已有最近运行记录展示:
- 任务名称:定时提醒扫描。
- 产出数量:提醒数量。
- 最近摘要:提醒了多少人、多少条事项。
后续可在数字员工工作记录详情中扩展“提醒报告详情”。
## 算法与公式
### 提醒优先级
提醒优先级由等待天数、金额和业务类型决定:
$$
priority\_score = 0.45 \times wait\_score + 0.35 \times amount\_score + 0.20 \times type\_score
$$
其中:
- `wait_score = min(wait_days / threshold_days, 1)`
- `amount_score = min(amount / high_amount_threshold, 1)`
- `type_score`:审批、预算、出差、报销流程分别给定基础分。
优先级映射:
$$
priority =
\begin{cases}
high, & priority\_score \ge 0.75 \\
medium, & 0.45 \le priority\_score < 0.75 \\
low, & priority\_score < 0.45
\end{cases}
$$
### 待审批等待天数
$$
wait\_days = floor((now - submitted\_at) / 86400)
$$
如果 `submitted_at` 为空,则使用 `updated_at``created_at` 降级计算。
### 预算缺口识别
当前阶段使用预算池存在性和周期作为提醒依据:
$$
budget\_gap = active\_allocation\_count = 0
$$
当当前年度/期间没有有效预算池,或预算池处于非 active/published 状态时,生成预算编制提醒。
## 测试方案
- 后端单元测试:构造员工、领导、报销单、申请单和预算池,验证提醒报告数量与收件人。
- 看板聚合测试:构造 `digital_employee_reminder_scan` 运行记录,验证 `reminders` 指标被统计。
- 调度器测试:验证 scheduler 能调用任务服务,不重复启动。
- 容器验证:在 `x-financial-main:/app` 内运行定向 pytest60s 超时。
- 运行时验证:重启容器后查询 `agent_runs`,确认提醒扫描记录成功生成。
- HTTP 验证:调用 `/api/v1/analytics/digital-employee-dashboard`,确认任务分布包含定时提醒扫描。
## 指标与验收
- `agent_runs` 中出现 `task_type=digital_employee_reminder_scan` 的成功运行。
- 工具响应包含 `recipient_count``reminder_count` 和四类提醒计数。
- 数字员工看板 `businessOutputs` 计入提醒数量。
- 最近运行记录展示“定时提醒扫描”。
- 定向测试通过。
- 不新增数据库结构,不改变现有单据状态。
## 风险与开放问题
- 第一阶段只生成提醒报告,不做真实消息投递;后续需要站内信/邮件/企业微信时再新增消息模型。
- 当前预算编制状态模型还不完整,第一阶段只能基于预算池缺口和期间判断。
- 出差申请到期依赖申请单中的 `application_detail.time`,如果历史数据缺失,只能降级使用 `occurred_at`
- 审批责任人目前主要通过员工直属领导推断,复杂动态审批流需要后续对接审批路由结果。
- 如果后续需要“已读/已处理/重复提醒抑制”,必须新增提醒表或消息表,并进行数据库迁移确认。

View File

@@ -0,0 +1,39 @@
# 数字员工财务经验沉淀与定时提醒开发 TODO
## 阶段一:调研与文档
- [x] 梳理现有数字员工技能、画像扫描、财务看板快照和看板聚合链路。[CONCEPT: 背景与问题] 证据:已核对 `agent_foundation_digital_employee_tasks.py``digital_employee_dashboard.py``employee_profile_scan_task.py`
- [x] 梳理审批、预算、出差申请和报销单模型字段。[CONCEPT: 方案设计] 证据:已核对 `approval.py``budget.py``financial_record.py``user_agent_application.py`
- [x] 明确第一阶段不新增数据库结构,只用 `agent_runs``agent_tool_calls` 保存提醒扫描报告。[CONCEPT: 目标与非目标] 证据:`CONCEPT.md` 已写明。
- [x] 创建概念文档和开发 TODO。[CONCEPT: 全文] 证据:本目录 `CONCEPT.md``TODO.md`
## 阶段二:后端提醒扫描任务
- [x] 新增 `digital_employee_reminder_task.py`,定义 `DigitalEmployeeReminderTaskService`。[CONCEPT: 后端] 证据:新增服务文件并通过 ruff。
- [x] 实现待审批提醒扫描,按直属领导聚合待审批单据。[CONCEPT: 定时提醒技能] 证据:`test_digital_employee_reminder_task.py` 覆盖 `approval_pending`
- [x] 实现预算编制/预算缺口提醒,按当前年度和期间识别预算池缺口。[CONCEPT: 定时提醒技能] 证据:`test_digital_employee_reminder_task.py` 覆盖 `budget_compilation`
- [x] 实现出差申请到期提醒,识别已结束但未报销或未关闭的申请单。[CONCEPT: 定时提醒技能] 证据:`test_digital_employee_reminder_task.py` 覆盖 `travel_application_expiry`
- [x] 实现报销逾期/补材料/付款/归档提醒,识别流程卡点。[CONCEPT: 定时提醒技能] 证据:`test_digital_employee_reminder_task.py` 覆盖 `reimbursement_overdue`
- [x] 将提醒报告写入 `AgentRun``AgentToolCall`,包含 `recipient_count``reminder_count` 和分类计数。[CONCEPT: 数据输出结构] 证据:任务服务测试读取返回 summary 与 report。
## 阶段三:调度与看板
- [x] 新增 `digital_employee_reminder_scheduler.py`,默认每天 02:00 扫描,支持开发环境首次延迟运行。[CONCEPT: 后端] 证据:新增调度器并通过 ruff。
- [x]`main.py` 生命周期中启动和关闭提醒调度器。[CONCEPT: 后端] 证据:`main.py` 已接入 scheduler start/shutdown。
- [x] 扩展 `DigitalEmployeeDashboardService`,识别 `digital_employee_reminder_scan`。[CONCEPT: 前端] 证据:看板聚合测试覆盖 task type。
- [x] 看板指标增加提醒产出计数,最近运行记录显示“定时提醒扫描”。[CONCEPT: 指标与验收] 证据:`test_digital_employee_dashboard_service.py` 覆盖 `reminders``businessOutputs`
## 阶段四:测试与验证
- [x] 新增后端单元测试,验证四类提醒的收件人、数量和摘要。[CONCEPT: 测试方案] 证据:`server/tests/test_digital_employee_reminder_task.py`
- [x] 新增数字员工看板聚合测试,验证提醒数量进入 `businessOutputs`。[CONCEPT: 测试方案] 证据:`server/tests/test_digital_employee_dashboard_service.py`
- [x] 在容器内运行 ruff`docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/python -m ruff check <changed-files>`。[CONCEPT: 测试方案] 证据All checks passed。
- [x] 在容器内运行定向 pytest超时 60s验证提醒任务和看板聚合。[CONCEPT: 测试方案] 证据:`5 passed in 3.39s`
- [x] 重启 `x-financial-main`,查询 `agent_runs` 确认提醒扫描运行记录成功生成。[CONCEPT: 指标与验收] 证据:`run_4c3a2b847fae4ada` succeeded提醒 47 人,生成 403 条事项。
- [x] 调用 `/api/v1/analytics/digital-employee-dashboard`,确认任务分布包含定时提醒扫描。[CONCEPT: 指标与验收] 证据HTTP 200`reminders=403`,任务分布包含 `digital_employee_reminder_scan`
## 后续阶段:消息投递闭环
- [ ] 评估是否新增提醒消息表、已读状态和重复提醒抑制策略。[CONCEPT: 风险与开放问题]
- [ ] 设计站内信、邮件或企业微信投递通道。[CONCEPT: 非目标]
- [ ] 设计提醒处理结果回流,用于沉淀“哪些提醒真正有效”。[CONCEPT: 行为沉淀技能]

View File

@@ -0,0 +1,153 @@
# 财务看板排行口径与部门人员占比
## 功能一句话
在分析看板的财务看板中补齐部门人员报销占比,并让部门、个人、高额单据使用统一的排行时间筛选口径。
## 背景与问题
当前财务看板已有部门报销排行、个人报销排行和本月高额单据,但存在三个问题:
- 部门排行的时间筛选只有本周、本月、本季度,缺少本年和全部。
- 个人报销排行标题固定为“本月”,实际无法由用户切换本月、本季度、本年和全部。
- 高额单据旁缺少部门内人员报销构成,财务人员难以判断高额单据是否集中在少数人员或单一部门。
## 目标与非目标
目标:
- 新增“部门人员报销占比”饼图,放在“本月高额单据”左侧,并与排行时间筛选口径联动。
- 部门报销排行增加参与人员数量,卡片空间完整展示排行内容。
- 个人报销排行增加报销笔数和所属部门信息,卡片空间完整展示排行内容。
- 部门排行、个人排行、高额单据、部门人员占比统一支持:本月、本季度、本年、全部。
非目标:
- 不新增独立页面。
- 不重做顶部 KPI、趋势图、预算指标和系统/风险/数字员工看板。
- 不引入新的图表库,继续复用现有 ECharts 封装组件。
## 用户与场景
用户:
- 高级财务人员、预算监控员、管理员。
场景:
- 财务人员进入分析看板后,查看不同时间口径下的部门费用集中度。
- 财务人员切换本季度、本年或全部后,对比部门排行、个人排行、高额单据和人员占比。
- 财务人员判断某部门报销金额高,是因为多人正常报销,还是少数人集中报销。
## 功能能力
输入:
- `department_range` 查询参数,取值:`本月``本季度``本年``全部`
输出:
- `department_ranking`:部门报销排行,新增 `employeeCount`
- `employee_ranking`:个人报销排行,保留金额、笔数、部门,并随筛选口径变化。
- `top_claims`:高额单据,随筛选口径变化,标题不再固定为本月。
- `department_employee_mix`:部门人员报销占比饼图数据。
状态与边界:
- 没有真实数据时返回空数组或“暂无数据”占位。
- 草稿、删除等非支出口径状态不参与金额排行。
- 缺失部门或人员名称的数据不进入排行和占比图。
- `全部` 表示所有可用报销单据,不按日期裁剪。
## 方案设计
后端:
-`FinanceDashboardService` 中扩展排行时间范围解析。
-`department_range` 作为排行分析窗口,统一供部门排行、个人排行、高额单据和部门人员占比使用。
- 部门排行按部门聚合金额、单据数、待付款金额和人员数量。
- 部门人员占比按“部门 + 人员”聚合金额,展示排名靠前的人员构成,名称格式为 `部门 · 人员`
接口:
- `GET /api/v1/analytics/finance-dashboard` 保持原路径。
- `department_range` 支持 `本月``本季度``本年``全部`
- 响应体新增 `department_employee_mix`
前端:
- `analytics.js` 增加 `departmentEmployeeMix` 归一化。
- `metrics.js``departmentRangeOptions` 改为 `本月 / 本季度 / 本年 / 全部`
- `useOverviewView.js` 新增部门人员占比 legend并让部门/个人排行读取新增字段。
- `OverviewView.vue` 调整财务看板底部布局:
- 部门排行占更宽区域,并保留筛选器。
- 个人排行占更宽区域,并增加相同筛选器。
- 高额单据卡片左侧放部门人员报销占比饼图,右侧放高额单据列表。
- 样式继续沿用企业 SaaS 直角、低饱和、Element Plus 控件和已有 `DonutChart` / `BarChart`
## 算法与公式
支出金额:
$$
amount_i = claim_i.amount
$$
部门金额:
$$
departmentAmount_d = \sum_{i \in claims(d)} amount_i
$$
部门人员数:
$$
employeeCount_d = \left| distinct(employeeName_i), i \in claims(d) \right|
$$
个人金额:
$$
employeeAmount_e = \sum_{i \in claims(e)} amount_i
$$
部门人员报销占比:
$$
share_{d,e} = \frac{\sum_{i \in claims(d,e)} amount_i}{\sum_{i \in rankingClaims} amount_i}
$$
其中 `rankingClaims` 为当前 `department_range` 时间口径下过滤后的有效报销单据。
## 测试方案
- 后端单元测试:
- 覆盖 `department_range=本年``department_range=全部`
- 验证部门排行返回 `employeeCount`
- 验证个人排行随口径变化。
- 验证 `department_employee_mix` 返回正确人员占比数据。
- 前端源码测试:
- 验证筛选选项包含本月、本季度、本年、全部。
- 验证个人排行和部门排行都有筛选器。
- 验证高额单据卡片包含部门人员报销占比图。
- 验证服务层归一化新增字段。
- 构建验证:
- `npm.cmd --prefix web run build`
- 容器验证:
- 后端测试在 `x-financial-main:/app` 中运行,超时不超过 60s。
- 可用时通过接口检查 `department_employee_mix``employeeCount``department_range=全部`
## 指标与验收
- 财务看板接口返回 `department_employee_mix`
- 部门排行每项返回 `employeeCount`
- 部门排行和个人排行都可选择本月、本季度、本年、全部。
- 个人排行标题不再固定“本月”。
- 高额单据卡片左侧显示部门人员报销占比饼图。
- 定向后端测试和前端构建通过。
## 风险与开放问题
- 当前工作区存在大量未提交变更,提交时必须只纳入本次相关文件。
- 如果浏览器自动化不可用,前端以源码测试、构建和接口验证为主要证据。
- `全部` 口径数据量可能更大,当前实现继续沿用内存聚合;后续数据量过大时再考虑 SQL 聚合优化。

View File

@@ -0,0 +1,35 @@
# 财务看板排行口径与部门人员占比 TODO
## 调研
- [x] 盘点财务看板后端聚合、前端服务、页面布局和测试现状。[CONCEPT: 背景与问题] 证据:已检查 `FinanceDashboardService``analytics.js``useOverviewView.js``OverviewView.vue``test_finance_dashboard_service.py`
## 契约
- [x] 扩展 `department_range` 支持 `本月 / 本季度 / 本年 / 全部`。[CONCEPT: 功能能力] 证据:`FinanceDashboardService._resolve_ranking_scope``departmentRangeOptions` 已更新。
- [x] 响应体新增 `department_employee_mix`,部门排行新增 `employeeCount`。[CONCEPT: 方案设计] 证据:`FinanceDashboardRead``_department_ranking``_department_employee_mix` 已更新。
## 后端
- [x] 修改财务看板服务的排行时间范围解析,统一驱动部门排行、个人排行、高额单据和人员占比。[CONCEPT: 方案设计] 证据:`ranking_claims` 同时供四类排行/图表使用。
- [x] 新增部门人员报销占比聚合逻辑。[CONCEPT: 算法与公式] 证据:新增 `_department_employee_mix`,按部门和人员聚合金额并返回饼图数据。
- [x] 更新快照缓存兼容新增字段。[CONCEPT: 接口] 证据:`SNAPSHOT_SCHEMA_VERSION = "finance-dashboard-ranking-v2"` 已加入快照缓存 key。
## 前端
- [x] 更新前端服务归一化和筛选选项。[CONCEPT: 前端] 证据:`analytics.js` 支持 `departmentEmployeeMix``metrics.js` 选项为本月/本季度/本年/全部。
- [x] 调整财务看板底部布局,新增部门人员报销占比饼图。[CONCEPT: 前端] 证据:`OverviewView.vue``top-claim-split` 左侧接入 `DonutChart`
- [x] 部门排行和个人排行展示人员数、单据数等辅助信息,并占满卡片空间。[CONCEPT: 前端] 证据:`BarChart.vue` 支持 `meta`,排行卡片跨度改为 6 栅格。
## 测试
- [x] 补充后端定向测试,覆盖排行时间口径、人员数和部门人员占比。[CONCEPT: 测试方案] 证据:`test_finance_dashboard_ranking_range_supports_year_and_all_scope` 已新增。
- [x] 补充前端源码测试,覆盖筛选器和新增图表字段。[CONCEPT: 测试方案] 证据:新增 `web/tests/finance-dashboard-ranking.test.mjs`
- [x]`x-financial-main` 容器内运行后端定向测试,超时不超过 60s。[CONCEPT: 测试方案] 证据:`pytest -q server/tests/test_finance_dashboard_service.py`4 passed。
- [x] 运行前端定向测试或构建验证。[CONCEPT: 测试方案] 证据:`node web/tests/finance-dashboard-ranking.test.mjs`3 passed`npm.cmd --prefix web run build` 通过。
## 验收
- [x] 调用财务看板接口验证 `department_range=全部` 返回新增字段。[CONCEPT: 指标与验收] 证据:接口返回 `has_department_employee_mix=true``department_employee_mix_count=6`、部门排行含 `employeeCount=67`
- [x] 更新本 TODO 的完成证据。[CONCEPT: 指标与验收] 证据:本文件已补充每项完成证据。
- [ ] 提交并推送本次功能改动,避免纳入无关脏工作区变更。[CONCEPT: 风险与开放问题] 阻塞:工作区已有大量未提交改动,且本次相关后端文件依赖未跟踪的财务看板快照/常量文件,直接提交会混入既有改动,单独提交又可能缺依赖。

View File

@@ -111,7 +111,7 @@ def _doc_type(claim: ExpenseClaim) -> str:
expense_type = str(claim.expense_type or "").strip().lower()
if claim_no.startswith(("AP-", "APP-")) or expense_type.endswith("_application"):
return "application"
if claim_no.startswith("SIM-EXP-2026"):
if str(claim.project_code or "").strip().upper() == "SIM-DEMO":
return "sim_reimbursement"
return "reimbursement"

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from datetime import date
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
SRC_DIR = SERVER_DIR / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from app.core.agent_enums import AgentRunSource # noqa: E402
from app.db.session import get_session_factory # noqa: E402
from app.services.digital_employee_finance_report_task import ( # noqa: E402
DigitalEmployeeFinanceReportTaskService,
)
def main() -> None:
parser = argparse.ArgumentParser(description="Generate finance report PDF by digital employee.")
parser.add_argument("--type", choices=["weekly", "quarterly", "annual"], default="weekly")
parser.add_argument("--start-date", type=_parse_date, default=None)
parser.add_argument("--end-date", type=_parse_date, default=None)
parser.add_argument("--recipient", action="append", default=[])
parser.add_argument("--send-email", action="store_true")
parser.add_argument("--dry-run-email", action="store_true")
args = parser.parse_args()
session_factory = get_session_factory()
with session_factory() as db:
result = DigitalEmployeeFinanceReportTaskService(db).generate_report(
report_type=args.type,
start_date=args.start_date,
end_date=args.end_date,
recipients=args.recipient or None,
send_email=args.send_email or args.dry_run_email,
dry_run_email=args.dry_run_email,
source=AgentRunSource.USER_MESSAGE.value,
)
db.commit()
print(json.dumps(result, ensure_ascii=False, indent=2))
def _parse_date(value: str) -> date:
return date.fromisoformat(value)
if __name__ == "__main__":
main()

View File

@@ -20,7 +20,7 @@ if str(SRC_DIR) not in sys.path:
from app.db.session import get_session_factory # noqa: E402
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem # noqa: E402
from app.services.demo_company_simulation_catalog import SIM_CLAIM_PREFIX # noqa: E402
from app.services.demo_company_simulation_catalog import SIM_PROJECT_CODE # noqa: E402
from app.services.expense_claim_attachment_storage import ( # noqa: E402
ExpenseClaimAttachmentStorage,
)
@@ -135,8 +135,8 @@ def _sim_claims(db) -> list[ExpenseClaim]:
db.scalars(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
.order_by(ExpenseClaim.claim_no.asc())
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
).all()
)
@@ -184,7 +184,7 @@ def _write_mock_attachment(
violated=violated,
)
file_path.write_text(ocr_text, encoding="utf-8")
item.invoice_id = storage.to_storage_key(file_path)
item.invoice_id = filename
storage.write_meta(
file_path,
_meta_payload(

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import sys
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import selectinload
SERVER_DIR = Path(__file__).resolve().parents[1]
SRC_DIR = SERVER_DIR / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from app.db.session import get_session_factory # noqa: E402
from app.models.budget import BudgetReservation, BudgetTransaction # noqa: E402
from app.models.financial_record import ExpenseClaim # noqa: E402
from app.models.risk_observation import RiskObservation # noqa: E402
from app.services.demo_company_simulation_catalog import ( # noqa: E402
SIM_CLAIM_ID_NAMESPACE,
SIM_PROJECT_CODE,
build_simulation_reimbursement_no,
)
from app.services.expense_claim_attachment_storage import ( # noqa: E402
ExpenseClaimAttachmentStorage,
)
LEGACY_CLAIM_PATTERN = re.compile(r"^SIM-EXP-2026-(\d+)$", flags=re.IGNORECASE)
@dataclass(frozen=True, slots=True)
class RenameSummary:
mode: str
legacy_claims: int
renamed_claims: int
budget_transactions_updated: int
budget_reservations_updated: int
risk_observations_updated: int
attachment_files_updated: int
attachment_items_updated: int
residual_attachment_texts_updated: int
samples: list[dict[str, str]]
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def main() -> None:
parser = argparse.ArgumentParser(
description="Rename legacy half-year demo claim numbers to canonical RE numbers."
)
parser.add_argument("--apply", action="store_true", help="write changes to the database")
parser.add_argument("--sample-limit", type=int, default=12)
args = parser.parse_args()
session_factory = get_session_factory()
with session_factory() as db:
summary = rename_demo_claim_numbers(
db,
apply=args.apply,
sample_limit=max(args.sample_limit, 0),
)
if args.apply:
db.commit()
else:
db.rollback()
print(json.dumps(summary.to_dict(), ensure_ascii=False, indent=2))
def rename_demo_claim_numbers(db, *, apply: bool, sample_limit: int) -> RenameSummary:
claims = _legacy_demo_claims(db)
rename_map = _build_rename_map(db, claims)
storage = ExpenseClaimAttachmentStorage()
transaction_updates = 0
reservation_updates = 0
risk_updates = 0
attachment_file_updates = 0
attachment_item_updates = 0
samples: list[dict[str, str]] = []
for claim in claims:
old_no = str(claim.claim_no or "").strip()
new_no = rename_map.get(old_no)
if not new_no:
continue
if len(samples) < sample_limit:
samples.append({"old": old_no, "new": new_no})
transaction_updates += _update_budget_transactions(db, old_no, new_no, apply=apply)
reservation_updates += _update_budget_reservations(db, old_no, new_no, apply=apply)
risk_updates += _update_risk_observations(db, claim, old_no, new_no, apply=apply)
file_count, item_count = _update_attachments(
storage,
claim,
old_no,
new_no,
apply=apply,
)
attachment_file_updates += file_count
attachment_item_updates += item_count
if apply:
claim.claim_no = new_no
residual_text_updates = _repair_residual_attachment_texts(
storage,
_demo_claims(db),
apply=apply,
)
return RenameSummary(
mode="apply" if apply else "dry-run",
legacy_claims=len(claims),
renamed_claims=len(rename_map),
budget_transactions_updated=transaction_updates,
budget_reservations_updated=reservation_updates,
risk_observations_updated=risk_updates,
attachment_files_updated=attachment_file_updates,
attachment_items_updated=attachment_item_updates,
residual_attachment_texts_updated=residual_text_updates,
samples=samples,
)
def _legacy_demo_claims(db) -> list[ExpenseClaim]:
return list(
db.scalars(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.where(ExpenseClaim.claim_no.like("SIM-EXP-2026-%"))
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
).all()
)
def _demo_claims(db) -> list[ExpenseClaim]:
return list(
db.scalars(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
).all()
)
def _build_rename_map(db, claims: list[ExpenseClaim]) -> dict[str, str]:
legacy_numbers = {str(claim.claim_no or "").strip() for claim in claims}
existing_numbers = set(db.scalars(select(ExpenseClaim.claim_no)).all()) - legacy_numbers
rename_map: dict[str, str] = {}
for fallback_index, claim in enumerate(claims, start=1):
old_no = str(claim.claim_no or "").strip()
sequence = _legacy_sequence(old_no) or fallback_index
timestamp = claim.occurred_at or claim.created_at or claim.submitted_at
new_no = build_simulation_reimbursement_no(timestamp, sequence)
if new_no in existing_numbers:
raise RuntimeError(f"canonical claim number already exists: {new_no}")
existing_numbers.add(new_no)
rename_map[old_no] = new_no
return rename_map
def _legacy_sequence(claim_no: str) -> int | None:
match = LEGACY_CLAIM_PATTERN.match(claim_no)
if not match:
return None
return int(match.group(1))
def _update_budget_transactions(db, old_no: str, new_no: str, *, apply: bool) -> int:
rows = list(
db.scalars(
select(BudgetTransaction).where(BudgetTransaction.source_no == old_no)
).all()
)
if apply:
for row in rows:
row.source_no = new_no
return len(rows)
def _update_budget_reservations(db, old_no: str, new_no: str, *, apply: bool) -> int:
rows = list(
db.scalars(
select(BudgetReservation).where(BudgetReservation.source_no == old_no)
).all()
)
if apply:
for row in rows:
row.source_no = new_no
return len(rows)
def _update_risk_observations(
db,
claim: ExpenseClaim,
old_no: str,
new_no: str,
*,
apply: bool,
) -> int:
rows = list(
db.scalars(
select(RiskObservation).where(
(RiskObservation.claim_id == claim.id)
| (RiskObservation.claim_no == old_no)
| (RiskObservation.subject_key == old_no)
)
).all()
)
if apply:
for row in rows:
row.claim_no = new_no if row.claim_no == old_no else row.claim_no
row.subject_key = new_no if row.subject_key == old_no else row.subject_key
row.subject_label = new_no if row.subject_label == old_no else row.subject_label
row.evidence_json = _replace_value(row.evidence_json, old_no, new_no)
row.ontology_json = _replace_value(row.ontology_json, old_no, new_no)
row.decision_trace_json = _replace_value(row.decision_trace_json, old_no, new_no)
return len(rows)
def _update_attachments(
storage: ExpenseClaimAttachmentStorage,
claim: ExpenseClaim,
old_no: str,
new_no: str,
*,
apply: bool,
) -> tuple[int, int]:
file_updates = 0
item_updates = 0
for item in list(claim.items or []):
invoice_id = str(item.invoice_id or "").strip()
if old_no not in invoice_id:
continue
new_invoice_id = invoice_id.replace(old_no, new_no)
item_updates += 1
if not apply:
file_updates += 1
continue
file_path = storage.resolve_item_path(item)
if file_path is not None and file_path.exists():
file_updates += 1
meta_payload = _replace_value(storage.read_meta(file_path), old_no, new_no)
new_file_path = file_path.with_name(file_path.name.replace(old_no, new_no))
meta_path = storage.meta_path(file_path)
new_meta_path = storage.meta_path(new_file_path)
file_path.rename(new_file_path)
if meta_path.exists():
meta_path.rename(new_meta_path)
storage.write_meta(new_file_path, meta_payload)
item.invoice_id = new_invoice_id
return file_updates, item_updates
def _repair_residual_attachment_texts(
storage: ExpenseClaimAttachmentStorage,
claims: list[ExpenseClaim],
*,
apply: bool,
) -> int:
sequence_by_claim_id = _simulation_sequence_by_claim_id(max(3000, len(claims) + 500))
updated = 0
for claim in claims:
sequence = sequence_by_claim_id.get(str(claim.id))
if sequence is None:
continue
old_no = f"SIM-EXP-2026-{sequence:04d}"
new_no = str(claim.claim_no or "").strip()
if not old_no or not new_no or old_no == new_no:
continue
for item in list(claim.items or []):
file_path = storage.resolve_item_path(item)
if file_path is None or not file_path.exists():
continue
if _replace_file_text(file_path, old_no, new_no, apply=apply):
updated += 1
if _replace_meta_text(storage, file_path, old_no, new_no, apply=apply):
updated += 1
return updated
def _simulation_sequence_by_claim_id(limit: int) -> dict[str, int]:
return {
str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
f"x-financial:{SIM_CLAIM_ID_NAMESPACE}:{sequence}",
)
): sequence
for sequence in range(1, limit + 1)
}
def _replace_file_text(file_path: Path, old_no: str, new_no: str, *, apply: bool) -> bool:
try:
content = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return False
if old_no not in content:
return False
if apply:
file_path.write_text(content.replace(old_no, new_no), encoding="utf-8")
return True
def _replace_meta_text(
storage: ExpenseClaimAttachmentStorage,
file_path: Path,
old_no: str,
new_no: str,
*,
apply: bool,
) -> bool:
payload = storage.read_meta(file_path)
if not payload:
return False
replaced = _replace_value(payload, old_no, new_no)
if replaced == payload:
return False
if apply:
storage.write_meta(file_path, replaced)
return True
def _replace_value(value: Any, old_no: str, new_no: str) -> Any:
if isinstance(value, str):
return value.replace(old_no, new_no)
if isinstance(value, list):
return [_replace_value(item, old_no, new_no) for item in value]
if isinstance(value, dict):
return {key: _replace_value(item, old_no, new_no) for key, item in value.items()}
return value
if __name__ == "__main__":
main()

View File

@@ -29,7 +29,6 @@ from app.services.demo_company_simulation_catalog import ( # noqa: E402
BUDGETED_STATUSES,
PENDING_STATUSES,
SIM_BUDGET_PREFIX,
SIM_CLAIM_PREFIX,
SIM_EMPLOYEE_PREFIX,
SIM_PROJECT_CODE,
SIM_RESERVATION_PREFIX,
@@ -60,6 +59,8 @@ RECENT_DATES = (
datetime(2026, 6, 1, 15, 0, tzinfo=UTC),
datetime(2026, 6, 2, 6, 0, tzinfo=UTC),
)
PERIOD_START = date(2026, 1, 1)
PERIOD_END = date(2026, 6, 2)
@dataclass(frozen=True, slots=True)
@@ -139,6 +140,7 @@ def repair_distribution(db, *, apply: bool) -> RepairSummary:
if apply:
_normalize_sim_claim_workflow(sim_claims)
_clamp_sim_claim_dates(sim_claims)
_redistribute_employees(sim_employees, departments, employee_plan)
db.flush()
employees_by_dept = _employees_by_department(db)
@@ -235,8 +237,8 @@ def _sim_claims(db) -> list[ExpenseClaim]:
db.scalars(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
.order_by(ExpenseClaim.claim_no.asc())
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
).all()
)
@@ -254,6 +256,23 @@ def _normalize_sim_claim_workflow(claims: list[ExpenseClaim]) -> None:
claim.approval_stage = normalized.approval_stage
def _clamp_sim_claim_dates(claims: list[ExpenseClaim]) -> None:
for index, claim in enumerate(claims):
occurred_at = claim.occurred_at or claim.submitted_at
if occurred_at is None:
continue
if PERIOD_START <= occurred_at.date() <= PERIOD_END:
continue
anchor = RECENT_DATES[index % len(RECENT_DATES)]
claim.occurred_at = anchor - _hours(2)
if claim.submitted_at is not None or claim.status != "draft":
claim.submitted_at = anchor
claim.created_at = claim.occurred_at
claim.updated_at = anchor + _hours(1)
for item in claim.items or []:
item.item_date = claim.occurred_at.date()
def _counts_by_weight(total: int) -> dict[str, int]:
raw = [(code, total * weight) for code, weight in DEPARTMENT_PLAN]
counts = {code: int(value) for code, value in raw}

View File

@@ -32,6 +32,7 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument("--target-employees", type=int, default=100)
parser.add_argument("--start-date", type=date.fromisoformat, default=date(2026, 1, 1))
parser.add_argument("--end-date", type=date.fromisoformat, default=date(2026, 6, 2))
parser.add_argument("--months", type=int, default=6)
parser.add_argument("--seed", type=int, default=20260602)
parser.add_argument("--apply", action="store_true", help="Write data. Default is dry-run only.")
@@ -49,6 +50,7 @@ def main() -> None:
config = SimulationConfig(
target_employees=args.target_employees,
start_date=args.start_date,
end_date=args.end_date,
months=args.months,
seed=args.seed,
)

View File

@@ -11,7 +11,7 @@ from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.schemas.system_dashboard import SystemDashboardRead
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.finance_dashboard import FinanceDashboardService
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
from app.services.system_dashboard import SystemDashboardService
router = APIRouter(prefix="/analytics")
@@ -65,10 +65,14 @@ def get_finance_dashboard(
range_key: Annotated[str, Query(max_length=30, description="顶部时间范围。")] = "近10日",
start_date: Annotated[date | None, Query(description="自定义开始日期。")] = None,
end_date: Annotated[date | None, Query(description="自定义结束日期。")] = None,
trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = "近12天",
department_range: Annotated[str, Query(max_length=30, description="部门排行时间范围。")] = "本月",
trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = (
"近12天"
),
department_range: Annotated[str, Query(max_length=30, description="排行分析时间范围。")] = (
"本月"
),
) -> FinanceDashboardRead:
return FinanceDashboardService(db).build_dashboard(
return FinanceDashboardSnapshotService(db).build_dashboard(
range_key=range_key,
start_date=start_date,
end_date=end_date,

View File

@@ -703,7 +703,7 @@ def pay_expense_claim(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
summary="删除报销单",
description="申请单仅系统管理员可删除;报销单申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
description="申请可删除自己的草稿、待补充或退回单据(含申请单和报销单);高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,

View File

@@ -13,7 +13,11 @@ from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS
from app.middleware.logging import AccessLogMiddleware
from app.schemas.common import RootStatusRead
from app.services.agent_foundation import prepare_agent_foundation
from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
from app.services.employee import prepare_employee_directory
from app.services.employee_profile_scheduler import employee_profile_scheduler
from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler
from app.services.finance_report_scheduler import finance_report_scheduler
from app.services.hermes_sync import sync_repository_hermes_skills
from app.services.knowledge import prepare_knowledge_library
from app.services.knowledge_index_tasks import knowledge_index_task_manager
@@ -31,6 +35,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
prepare_knowledge_library()
sync_repository_hermes_skills()
knowledge_index_scheduler.start()
finance_dashboard_scheduler.start()
employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
finance_report_scheduler.start()
logger.info(
"Server ready - host=%s port=%s prefix=%s",
settings.app_host,
@@ -38,6 +46,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.api_v1_prefix,
)
yield
finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown()
finance_dashboard_scheduler.shutdown()
knowledge_index_scheduler.shutdown()
knowledge_index_task_manager.shutdown()
shutdown_knowledge_rag_runtime()

View File

@@ -17,6 +17,7 @@ class FinanceDashboardRead(BaseModel):
spend_by_category: list[dict[str, Any]] = Field(default_factory=list)
exception_mix: list[dict[str, Any]] = Field(default_factory=list)
department_ranking: list[dict[str, Any]] = Field(default_factory=list)
department_employee_mix: list[dict[str, Any]] = Field(default_factory=list)
employee_ranking: list[dict[str, Any]] = Field(default_factory=list)
top_claims: list[dict[str, Any]] = Field(default_factory=list)
bottlenecks: list[dict[str, Any]] = Field(default_factory=list)

View File

@@ -90,6 +90,12 @@ DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"
DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE = "task.hermes.finance_dashboard_snapshot"
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE = "task.hermes.digital_employee_reminder_scan"
DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE = "task.hermes.finance_report_orchestration"
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE = "task.hermes.global_risk_scan"
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile_scan"
@@ -102,7 +108,9 @@ DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE = "task.hermes.expense_policy_alignm
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE = "task.hermes.risk_rule_template_organize"
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = "task.hermes.department_expense_baseline_accumulate"
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = (
"task.hermes.department_expense_baseline_accumulate"
)
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE = "task.hermes.supplier_risk_profile_accumulate"
@@ -132,6 +140,9 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = {
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE: "升级",
DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE: "评估",
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累",
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级",

View File

@@ -16,11 +16,14 @@ from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE,
DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE,
DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE,
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
@@ -30,7 +33,6 @@ from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE,
)
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
@@ -40,6 +42,65 @@ DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
class AgentFoundationDigitalEmployeeTaskMixin:
def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]:
return (
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE,
name="财务经营快照沉淀",
description="按固定周期统计报销金额、费用结构、预算占用、高额单据和个人费用排行,刷新财务看板缓存。",
scenario_json=["schedule", "finance_dashboard", "expense", "budget"],
owner="财务运营组",
cron="0 2 * * *",
skill_category="整理",
skill_name="finance-dashboard-snapshot-analyst",
output_format="finance_dashboard_snapshot",
input_sources=[
"expense_claims",
"expense_items",
"budget_snapshots",
"employee_profiles",
],
execution_strategy="scheduled_dashboard_cache",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE,
name="定时提醒与待办扫描",
description="按计划扫描待审批单据、预算编制周期、差旅申请到期和逾期报销,生成可触达的提醒事项。",
scenario_json=["schedule", "reminder", "approval", "budget", "travel"],
owner="财务运营组",
cron="0 2 * * *",
skill_category="升级",
skill_name="digital-employee-reminder-scanner",
output_format="digital_employee_reminder_report",
input_sources=[
"expense_claims",
"approval_tasks",
"budgets",
"travel_applications",
],
execution_strategy="scheduled_reminder_scan",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE,
name="财务报告编排与邮件投递",
description=(
"按周、季、年整合费用、预算、风险、画像和提醒结果,"
"生成图文 PDF 报告并按邮箱设置投递给财务管理人员。"
),
scenario_json=["schedule", "finance_report", "pdf", "email", "management"],
owner="财务运营组",
cron="30 8 * * 1",
skill_category="整理",
skill_name="finance-report-orchestrator",
output_format="finance_report_pdf_delivery",
input_sources=[
"finance_dashboard_snapshots",
"budget_snapshots",
"risk_observations",
"employee_profiles",
"digital_employee_reminders",
"system_mail_settings",
],
execution_strategy="scheduled_pdf_email_report",
),
self._digital_employee_task_spec(
code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
name="制度条款结构化抽取",
@@ -134,7 +195,10 @@ class AgentFoundationDigitalEmployeeTaskMixin:
{
"code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
"name": "财务风险图谱巡检",
"description": "按计划扫描报销单、票据、审批链、员工画像和规则命中结果,生成风险观察与可复核证据链。",
"description": (
"按计划扫描报销单、票据、审批链、员工画像和规则命中结果,"
"生成风险观察与可复核证据链。"
),
"scenario_json": ["schedule", "expense", "risk_graph", "risk_observation"],
"owner": "风控与审计部",
"reviewer": "顾承宇",
@@ -167,7 +231,10 @@ class AgentFoundationDigitalEmployeeTaskMixin:
{
"code": DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
"name": "员工行为画像巡检",
"description": "按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,为风险图谱提供画像基线。",
"description": (
"按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,"
"为风险图谱提供画像基线。"
),
"scenario_json": ["schedule", "employee_profile", "baseline", "risk_graph"],
"owner": "风控与审计部",
"reviewer": "顾承宇",
@@ -219,7 +286,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="评估",
skill_name="travel-spatiotemporal-consistency-evaluator",
output_format="spatiotemporal_consistency_report",
input_sources=["expense_claims", "expense_items", "invoice_locations", "travel_routes"],
input_sources=[
"expense_claims",
"expense_items",
"invoice_locations",
"travel_routes",
],
execution_strategy="reuse_financial_risk_graph_scan",
),
self._digital_employee_task_spec(
@@ -232,7 +304,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="评估",
skill_name="budget-overrun-precontrol-evaluator",
output_format="budget_precontrol_warning_report",
input_sources=["expense_claims", "budget_snapshots", "policy_refs", "profile_baselines"],
input_sources=[
"expense_claims",
"budget_snapshots",
"policy_refs",
"profile_baselines",
],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
@@ -245,13 +322,21 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="评估",
skill_name="supplier-abnormal-relation-evaluator",
output_format="supplier_abnormal_relation_report",
input_sources=["risk_graph", "expense_claims", "invoice_entities", "entity_registry"],
input_sources=[
"risk_graph",
"expense_claims",
"invoice_entities",
"entity_registry",
],
execution_strategy="reuse_financial_risk_graph_scan",
),
{
"code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
"name": "风险线索归集",
"description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则。",
"description": (
"按计划复盘申请、报销、规则命中和人工反馈,"
"归集带事实依据的潜在线索,提交人工复核,不生成规则。"
),
"scenario_json": ["schedule", "application", "reimbursement", "risk_clue"],
"owner": "风控与审计部",
"reviewer": "顾承宇",
@@ -291,7 +376,11 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="升级",
skill_name="risk-algorithm-replay-evaluator",
output_format="algorithm_replay_evaluation_report",
input_sources=["algorithm_replay_sets", "risk_observations", "risk_observation_feedback"],
input_sources=[
"algorithm_replay_sets",
"risk_observations",
"risk_observation_feedback",
],
execution_strategy="definition_ready",
),
self._digital_employee_task_spec(
@@ -304,7 +393,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
skill_category="升级",
skill_name="policy-reference-gap-hinter",
output_format="policy_reference_gap_hint_report",
input_sources=["policy_refs", "rule_hits", "expense_claims", "risk_feedback_samples"],
input_sources=[
"policy_refs",
"rule_hits",
"expense_claims",
"risk_feedback_samples",
],
execution_strategy="definition_ready",
),
)

View File

@@ -5,8 +5,15 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any
from app.services.document_numbering import (
DOCUMENT_NUMBER_TOKEN_ALPHABET,
DOCUMENT_NUMBER_TOKEN_LENGTH,
build_document_number,
)
SIM_EMPLOYEE_PREFIX = "SIM2026"
SIM_CLAIM_PREFIX = "SIM-EXP-2026"
# 历史模拟数据已用这个命名空间生成 UUID这里只用于保持幂等不再作为业务单号。
SIM_CLAIM_ID_NAMESPACE = "SIM-EXP-2026"
SIM_BUDGET_PREFIX = "SIM-BUD-2026"
SIM_TRANSACTION_PREFIX = "SIM-BTX-2026"
SIM_RESERVATION_PREFIX = "SIM-BRS-2026"
@@ -31,6 +38,7 @@ BUDGETED_STATUSES = SUCCESS_STATUSES | PENDING_STATUSES
class SimulationConfig:
target_employees: int = 100
start_date: date = date(2026, 1, 1)
end_date: date = date(2026, 6, 2)
months: int = 6
seed: int = 20260602
@@ -269,6 +277,25 @@ def updated_at_for_claim_plan(plan: ClaimPlan) -> datetime:
from datetime import timedelta
base = plan.submitted_at or plan.occurred_at
claim_offset = sum(ord(char) for char in str(plan.claim_no or "")[-2:]) % 24
if plan.status in SUCCESS_STATUSES | {"rejected", "returned"}:
return base + timedelta(hours=2 + int(plan.claim_no[-2:]) % 24)
return base + timedelta(hours=2 + claim_offset)
return base + timedelta(hours=1)
def build_simulation_reimbursement_no(occurred_at: datetime, sequence: int) -> str:
return build_document_number(
"reimbursement",
timestamp=occurred_at,
token=simulation_document_token(sequence),
)
def simulation_document_token(sequence: int) -> str:
value = max(0, int(sequence))
base = len(DOCUMENT_NUMBER_TOKEN_ALPHABET)
chars: list[str] = []
for _ in range(DOCUMENT_NUMBER_TOKEN_LENGTH):
chars.append(DOCUMENT_NUMBER_TOKEN_ALPHABET[value % base])
value //= base
return "".join(reversed(chars))

View File

@@ -22,7 +22,7 @@ APPLICATION_EXPENSE_TYPES = {
}
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
RECENT_VISIBLE_CLAIM_START = 501
RECENT_VISIBLE_CLAIM_END = 950
RECENT_VISIBLE_CLAIM_END = 817
def is_admin_identity(*values: Any) -> bool:
@@ -70,10 +70,79 @@ def recent_visible_claim_day(
*,
employee_index: int,
claim_index: int,
period_end: date,
) -> date | None:
if not months or not (RECENT_VISIBLE_CLAIM_START <= claim_index <= RECENT_VISIBLE_CLAIM_END):
return None
month = months[-1]
_, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month:
max_day = min(max_day, period_end.day)
day = min(2, max_day)
return month.replace(day=1 + ((employee_index + claim_index) % day))
def simulation_claim_day(
rng: Any,
months: list[date],
*,
employee_index: int,
local_index: int,
claim_index: int,
period_end: date,
) -> date:
visible_day = recent_visible_claim_day(
months,
employee_index=employee_index,
claim_index=claim_index,
period_end=period_end,
)
if visible_day is not None:
return visible_day
month = months[(employee_index + local_index * 2) % len(months)]
_, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month:
max_day = min(max_day, period_end.day)
day = 1 + ((employee_index * 7 + local_index * 11 + rng.randint(0, 5)) % max_day)
return month.replace(day=day)
def simulation_claim_count(employee: Any, index: int) -> int:
base = 7 + (index % 5)
department_code = str(getattr(getattr(employee, "department", None), "unit_code", "") or "")
grade = str(getattr(employee, "grade", "") or "")
if department_code in {"MARKET-DEPT", "TECH-DEPT"}:
base += 3
elif department_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
base += 2
if grade in {"P7", "P8"}:
base += 2
return max(6, min(base, 16))
def next_simulation_number(prefix: str, used_numbers: set[str], cursor: int) -> tuple[str, int]:
while True:
number = f"{prefix}-{cursor:04d}"
cursor += 1
if number not in used_numbers:
used_numbers.add(number)
return number, cursor
def simulation_month_starts(config: Any) -> list[date]:
current = config.start_date.replace(day=1)
months: list[date] = []
for _ in range(max(1, config.months)):
if current > config.end_date:
break
months.append(current)
year = current.year + (1 if current.month == 12 else 0)
month = 1 if current.month == 12 else current.month + 1
current = date(year, month, 1)
return months or [config.start_date.replace(day=1)]
def simulation_period_end(config: Any) -> date:
last_month = simulation_month_starts(config)[-1]
_, max_day = calendar.monthrange(last_month.year, last_month.month)
return min(last_month.replace(day=max_day), config.end_date)

View File

@@ -1,13 +1,12 @@
from __future__ import annotations
import calendar
import random
import uuid
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.security import hash_password
@@ -28,7 +27,7 @@ from app.services.demo_company_simulation_catalog import (
MONTH_FACTORS,
PENDING_STATUSES,
SIM_BUDGET_PREFIX,
SIM_CLAIM_PREFIX,
SIM_CLAIM_ID_NAMESPACE,
SIM_EMPLOYEE_PREFIX,
SIM_PROJECT_CODE,
SIM_RESERVATION_PREFIX,
@@ -45,6 +44,7 @@ from app.services.demo_company_simulation_catalog import (
SimulationConfig,
SimulationSummary,
build_employee_name,
build_simulation_reimbursement_no,
claim_location,
claim_reason,
department_from_row,
@@ -57,7 +57,11 @@ from app.services.demo_company_simulation_catalog import (
)
from app.services.demo_company_simulation_filters import (
is_admin_employee_like,
recent_visible_claim_day,
next_simulation_number,
simulation_claim_count,
simulation_claim_day,
simulation_month_starts,
simulation_period_end,
)
@@ -117,7 +121,7 @@ class HalfYearExpenseSimulationSeeder:
budget_reservations_to_create=reservation_count,
risk_observations_to_create=risk_count,
period_start=self.config.start_date.isoformat(),
period_end=self._period_end().isoformat(),
period_end=simulation_period_end(self.config).isoformat(),
)
def _department_refs(self, *, apply: bool) -> list[DepartmentRef]:
@@ -275,16 +279,19 @@ class HalfYearExpenseSimulationSeeder:
def _build_claim_plans(self, employees: list[EmployeeRef]) -> list[ClaimPlan]:
plans: list[ClaimPlan] = []
months = self._month_starts()
months = simulation_month_starts(self.config)
period_end = simulation_period_end(self.config)
claim_index = 1
for employee_index, employee in enumerate(employees):
count = self._claim_count_for_employee(employee, employee_index)
count = simulation_claim_count(employee, employee_index)
for local_index in range(count):
occurred_day = self._claim_day(
occurred_day = simulation_claim_day(
self.rng,
months,
employee_index,
local_index,
claim_index,
employee_index=employee_index,
local_index=local_index,
claim_index=claim_index,
period_end=period_end,
)
expense_type = self._expense_type_for_employee(employee)
amount = self._claim_amount(employee, expense_type, occurred_day)
@@ -301,10 +308,10 @@ class HalfYearExpenseSimulationSeeder:
id=str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
f"x-financial:{SIM_CLAIM_PREFIX}:{claim_index}",
f"x-financial:{SIM_CLAIM_ID_NAMESPACE}:{claim_index}",
)
),
claim_no=f"{SIM_CLAIM_PREFIX}-{claim_index:04d}",
claim_no=self._simulation_claim_no(occurred_at, claim_index),
employee=employee,
expense_type=expense_type,
reason=claim_reason(
@@ -372,12 +379,25 @@ class HalfYearExpenseSimulationSeeder:
) -> tuple[dict[tuple[int, str, str, str, str], str], int]:
allocation_map: dict[tuple[int, str, str, str, str], str] = {}
created_count = 0
for index, plan in enumerate(plans, start=1):
used_budget_nos = set(
self.db.scalars(
select(BudgetAllocation.budget_no).where(
BudgetAllocation.budget_no.like(f"{SIM_BUDGET_PREFIX}%")
)
).all()
)
budget_no_cursor = 1
for plan in plans:
existing = self._find_sim_allocation(plan)
if existing is not None:
allocation_map[plan.key] = existing.id
continue
created_count += 1
budget_no, budget_no_cursor = next_simulation_number(
SIM_BUDGET_PREFIX,
used_budget_nos,
budget_no_cursor,
)
allocation_id = str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
@@ -390,7 +410,7 @@ class HalfYearExpenseSimulationSeeder:
self.db.add(
BudgetAllocation(
id=allocation_id,
budget_no=f"{SIM_BUDGET_PREFIX}-{index:04d}",
budget_no=budget_no,
fiscal_year=plan.key[0],
period_type="quarter",
period_key=plan.period_key,
@@ -415,15 +435,19 @@ class HalfYearExpenseSimulationSeeder:
return allocation_map, created_count
def _ensure_claims(self, plans: list[ClaimPlan], *, apply: bool) -> tuple[int, int]:
existing_claim_nos = set(
self.db.scalars(
select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
existing_rows = list(
self.db.execute(
select(ExpenseClaim.id, ExpenseClaim.claim_no).where(
ExpenseClaim.project_code == SIM_PROJECT_CODE
)
).all()
)
existing_claim_ids = {str(row.id) for row in existing_rows}
existing_claim_nos = set(self.db.scalars(select(ExpenseClaim.claim_no)).all())
claim_count = 0
item_count = 0
for plan in plans:
if plan.claim_no in existing_claim_nos:
if plan.id in existing_claim_ids or plan.claim_no in existing_claim_nos:
continue
claim_count += 1
item_count += len(plan.items)
@@ -645,40 +669,6 @@ class HalfYearExpenseSimulationSeeder:
plan.budget_subject_code,
)
def _month_starts(self) -> list[date]:
current = self.config.start_date.replace(day=1)
months: list[date] = []
for _ in range(max(1, self.config.months)):
months.append(current)
year = current.year + (1 if current.month == 12 else 0)
month = 1 if current.month == 12 else current.month + 1
current = date(year, month, 1)
return months
def _period_end(self) -> date:
months = self._month_starts()
last_month = months[-1]
return last_month.replace(day=calendar.monthrange(last_month.year, last_month.month)[1])
def _claim_day(
self,
months: list[date],
employee_index: int,
local_index: int,
claim_index: int,
) -> date:
visible_day = recent_visible_claim_day(
months,
employee_index=employee_index,
claim_index=claim_index,
)
if visible_day is not None:
return visible_day
month = months[(employee_index + local_index * 2) % len(months)]
_, max_day = calendar.monthrange(month.year, month.month)
day = 1 + ((employee_index * 7 + local_index * 11 + self.rng.randint(0, 5)) % max_day)
return month.replace(day=day)
def _weighted_department(self, departments: list[DepartmentRef], index: int) -> DepartmentRef:
weighted: list[DepartmentRef] = []
by_code = {item.unit_code: item for item in departments}
@@ -696,16 +686,6 @@ class HalfYearExpenseSimulationSeeder:
subjects = list(weights)
return self.rng.choices(subjects, weights=[weights[item] for item in subjects], k=1)[0]
def _claim_count_for_employee(self, employee: EmployeeRef, index: int) -> int:
base = 7 + (index % 5)
if employee.department.unit_code in {"MARKET-DEPT", "TECH-DEPT"}:
base += 3
elif employee.department.unit_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
base += 2
if employee.grade in {"P7", "P8"}:
base += 2
return max(6, min(base, 16))
def _claim_amount(
self,
employee: EmployeeRef,
@@ -726,6 +706,10 @@ class HalfYearExpenseSimulationSeeder:
Decimal("0.01")
)
@staticmethod
def _simulation_claim_no(occurred_at: datetime, claim_index: int) -> str:
return build_simulation_reimbursement_no(occurred_at, claim_index)
def _status_for_claim(self, employee_index: int, local_index: int) -> tuple[str, str | None]:
selector = (employee_index * 11 + local_index * 17 + self.config.seed) % 100
if selector < 42:

View File

@@ -28,7 +28,9 @@ TASK_CODE_TO_TYPE = {
"task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate",
"task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate",
"task.hermes.multi_evidence_consistency_evaluate": "multi_evidence_consistency_evaluate",
"task.hermes.travel_spatiotemporal_consistency_evaluate": "travel_spatiotemporal_consistency_evaluate",
"task.hermes.travel_spatiotemporal_consistency_evaluate": (
"travel_spatiotemporal_consistency_evaluate"
),
"task.hermes.budget_overrun_precontrol_evaluate": "budget_overrun_precontrol_evaluate",
"task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate",
"task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate",
@@ -136,6 +138,16 @@ TASK_SPECS: dict[str, dict[str, str]] = {
"category": "升级",
"color": "var(--chart-amber)",
},
"finance_dashboard_snapshot": {
"label": "财务看板指标快照",
"category": "积累",
"color": "var(--chart-blue)",
},
"digital_employee_reminder_scan": {
"label": "定时提醒扫描",
"category": "整理",
"color": "var(--success)",
},
}
CATEGORY_SPECS = {
@@ -203,6 +215,8 @@ class DigitalEmployeeDashboardService:
+ metrics["risk_clues"]
+ metrics["profile_snapshots"]
+ metrics["knowledge_documents"]
+ metrics["finance_snapshots"]
+ metrics["reminders"]
)
return {
@@ -216,6 +230,8 @@ class DigitalEmployeeDashboardService:
"riskClues": metrics["risk_clues"],
"profileSnapshots": metrics["profile_snapshots"],
"knowledgeDocuments": metrics["knowledge_documents"],
"financeDashboardSnapshots": metrics["finance_snapshots"],
"reminders": metrics["reminders"],
"successRate": self._percent(success_runs, total_runs),
"failureRate": self._percent(failed_runs, total_runs),
}
@@ -232,6 +248,8 @@ class DigitalEmployeeDashboardService:
"riskClues": 0,
"profileSnapshots": 0,
"knowledgeDocuments": 0,
"financeDashboardSnapshots": 0,
"reminders": 0,
"businessOutputs": 0,
}
for label in labels
@@ -254,11 +272,15 @@ class DigitalEmployeeDashboardService:
row["riskClues"] += metrics["risk_clues"]
row["profileSnapshots"] += metrics["profile_snapshots"]
row["knowledgeDocuments"] += metrics["knowledge_documents"]
row["financeDashboardSnapshots"] += metrics["finance_snapshots"]
row["reminders"] += metrics["reminders"]
row["businessOutputs"] += (
metrics["risk_observations"]
+ metrics["risk_clues"]
+ metrics["profile_snapshots"]
+ metrics["knowledge_documents"]
+ metrics["finance_snapshots"]
+ metrics["reminders"]
)
return [rows[label] for label in labels]
@@ -374,6 +396,12 @@ class DigitalEmployeeDashboardService:
summary,
("snapshot_count", "profile_snapshot_count", "profile_snapshots"),
)
if self._resolve_task_type(run) == "finance_dashboard_snapshot":
metrics["profile_snapshots"] = 0
metrics["finance_snapshots"] = self._first_int(
summary,
("finance_snapshot_count", "dashboard_snapshot_count"),
)
metrics["knowledge_documents"] = max(
self._first_int(
summary,
@@ -383,9 +411,21 @@ class DigitalEmployeeDashboardService:
self._list_length(route_json, ("document_ids", "requested_document_ids")),
)
metrics["scanned_claims"] = self._first_int(summary, ("scanned_claim_count", "claim_count"))
metrics["target_employees"] = self._first_int(summary, ("target_employee_count", "employee_count"))
metrics["target_employees"] = self._first_int(
summary,
("target_employee_count", "employee_count"),
)
metrics["rule_hits"] = self._first_int(summary, ("rule_hit_count", "rule_hits"))
metrics["facts"] = self._first_int(summary, ("fact_count", "facts"))
metrics["reminders"] = self._first_int(
summary,
(
"reminder_count",
"reminders",
"approval_pending_count",
"budget_reminder_count",
),
)
return metrics
@staticmethod
@@ -394,11 +434,13 @@ class DigitalEmployeeDashboardService:
"risk_observations": 0,
"risk_clues": 0,
"profile_snapshots": 0,
"finance_snapshots": 0,
"knowledge_documents": 0,
"scanned_claims": 0,
"target_employees": 0,
"rule_hits": 0,
"facts": 0,
"reminders": 0,
}
def _extract_run_summary(self, run: AgentRun) -> dict[str, Any]:
@@ -413,7 +455,9 @@ class DigitalEmployeeDashboardService:
def _matched_tool_call(self, run: AgentRun, task_type: str) -> AgentToolCall | None:
digital_tools = [
tool for tool in run.tool_calls if str(tool.tool_name or "").startswith("digital_employee.")
tool
for tool in run.tool_calls
if str(tool.tool_name or "").startswith("digital_employee.")
]
for tool in run.tool_calls:
candidates = [
@@ -441,7 +485,10 @@ class DigitalEmployeeDashboardService:
route_json = run.route_json or {}
if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value:
return True
return any(str(tool.tool_name or "").startswith("digital_employee.") for tool in run.tool_calls)
return any(
str(tool.tool_name or "").startswith("digital_employee.")
for tool in run.tool_calls
)
def _resolve_task_type(self, run: AgentRun) -> str:
route_json = run.route_json or {}
@@ -491,6 +538,8 @@ class DigitalEmployeeDashboardService:
return "global_risk_scan"
if "employee_behavior_profile" in name:
return "employee_behavior_profile_scan"
if "reminder" in name:
return "digital_employee_reminder_scan"
if "finance_policy_knowledge" in name:
return "finance_policy_knowledge_organize"
if "risk_clue" in name:

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from time import perf_counter
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import (
AgentName,
AgentPermissionLevel,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.services.agent_runs import AgentRunService
from app.services.finance_report_context import FinanceReportContextService, FinanceReportType
from app.services.finance_report_mailer import FinanceReportMailer
from app.services.finance_report_renderer import FinanceReportRenderer
FINANCE_REPORT_TASK_TYPE = "finance_report_orchestration"
FINANCE_REPORT_TOOL_NAME = "digital_employee.finance_report.orchestrate"
class DigitalEmployeeFinanceReportTaskService:
def __init__(self, db: Session) -> None:
self.db = db
def generate_report(
self,
*,
report_type: FinanceReportType = "weekly",
start_date: date | None = None,
end_date: date | None = None,
recipients: list[str] | None = None,
send_email: bool = True,
dry_run_email: bool = False,
source: str = AgentRunSource.SCHEDULE.value,
run_id: str | None = None,
record_tool_call: bool = True,
) -> dict[str, Any]:
run_service = AgentRunService(self.db)
run = None
if run_id is None:
run = run_service.create_run(
agent=AgentName.HERMES.value,
source=source,
user_id="digital_employee",
ontology_json={"scenario": "finance_report", "intent": report_type},
route_json={
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": report_type,
"phase": "running",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.RUNNING.value,
)
run_id = run.run_id
timer = perf_counter()
try:
context = FinanceReportContextService(self.db).build_context(
report_type=report_type,
start_date=start_date,
end_date=end_date,
)
rendered = FinanceReportRenderer().render(context)
delivery = (
FinanceReportMailer(self.db).send_report(
context=context,
pdf_path=rendered.pdf_path,
recipients=recipients,
dry_run=dry_run_email,
)
if send_email
else None
)
duration_ms = int((perf_counter() - timer) * 1000)
result = self._result_payload(
context=context,
rendered=rendered,
delivery=delivery.to_dict() if delivery is not None else {"status": "skipped"},
)
if record_tool_call:
run_service.record_tool_call(
run_id=run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=FINANCE_REPORT_TOOL_NAME,
request_json={
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": report_type,
"send_email": send_email,
},
response_json=result,
status=AgentRunStatus.SUCCEEDED.value,
duration_ms=duration_ms,
)
run_service.merge_route_json(
run_id,
{
"phase": "succeeded",
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": report_type,
"report_delivery": result,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=self._summary_text(result),
finished_at=datetime.now(UTC),
)
return result
except Exception as exc:
run_service.merge_route_json(
run_id,
{
"phase": "failed",
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": report_type,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.FAILED.value,
error_message=str(exc),
finished_at=datetime.now(UTC),
)
raise
@staticmethod
def _result_payload(
*,
context: dict[str, Any],
rendered: Any,
delivery: dict[str, Any],
) -> dict[str, Any]:
period = context.get("period") if isinstance(context.get("period"), dict) else {}
summary = context.get("summary") if isinstance(context.get("summary"), dict) else {}
return {
"task_type": FINANCE_REPORT_TASK_TYPE,
"report_type": context.get("report_type"),
"title": period.get("title"),
"period": period,
"summary": summary,
"insights": context.get("insights") or [],
"action_items": context.get("action_items") or [],
"pdf": {
"storage_key": rendered.storage_key,
"path": str(rendered.pdf_path),
"html_path": str(rendered.html_path),
"page_count": rendered.page_count,
},
"delivery": delivery,
}
@staticmethod
def _summary_text(result: dict[str, Any]) -> str:
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
delivery = result.get("delivery") if isinstance(result.get("delivery"), dict) else {}
return (
f"{result.get('title') or '财务经营报告'}已生成:"
f"{summary.get('reimbursement_count', 0)} 单,"
f"金额 {float(summary.get('reimbursement_amount') or 0):,.0f} 元,"
f"邮件状态 {delivery.get('status') or 'skipped'}"
)

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import os
import threading
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from app.core.agent_enums import AgentRunSource
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.services.digital_employee_reminder_task import DigitalEmployeeReminderTaskService
logger = get_logger("app.services.digital_employee_reminder_scheduler")
class DigitalEmployeeReminderScheduler:
def __init__(self) -> None:
timezone_name = str(os.environ.get("X_FINANCIAL_SCHEDULER_TZ") or "Asia/Shanghai").strip()
reminder_time = str(os.environ.get("X_FINANCIAL_REMINDER_SCAN_TIME") or "02:00").strip()
initial_delay = int(os.environ.get("X_FINANCIAL_REMINDER_INITIAL_DELAY_SECONDS") or "24")
self._timezone = ZoneInfo(timezone_name or "Asia/Shanghai")
self._scan_time = self._parse_scan_time(reminder_time)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="digital-employee-reminder-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Digital employee reminder scheduler started timezone=%s scan_time=%s",
self._timezone.key,
self._scan_time.strftime("%H:%M"),
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Digital employee reminder scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
self._refresh_reminders(reason="startup_warmup")
while not self._stop_event.is_set():
wait_seconds = self._seconds_until_next_scan()
if self._stop_event.wait(wait_seconds):
break
self._refresh_reminders(reason="scheduled_0200")
def _refresh_reminders(self, *, reason: str) -> None:
db = get_session_factory()()
try:
result = DigitalEmployeeReminderTaskService(db).refresh_reminders(
source=AgentRunSource.SCHEDULE.value
)
summary = result.get("summary") or {}
logger.info(
"Digital employee reminder scan generated reason=%s recipients=%s reminders=%s",
reason,
summary.get("recipient_count"),
summary.get("reminder_count"),
)
except Exception:
db.rollback()
logger.exception("Scheduled digital employee reminder scan failed")
finally:
db.close()
def _seconds_until_next_scan(self) -> float:
now = datetime.now(self._timezone)
target = datetime.combine(now.date(), self._scan_time, tzinfo=self._timezone)
if target <= now:
target = target + timedelta(days=1)
return max(1.0, (target - now).total_seconds())
@staticmethod
def _parse_scan_time(raw_value: str) -> time:
value = str(raw_value or "").strip()
try:
hour_text, minute_text = value.split(":", 1)
hour = min(max(int(hour_text), 0), 23)
minute = min(max(int(minute_text), 0), 59)
return time(hour=hour, minute=minute)
except Exception:
return time(hour=2, minute=0)
digital_employee_reminder_scheduler = DigitalEmployeeReminderScheduler()

View File

@@ -0,0 +1,547 @@
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from time import perf_counter
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.core.agent_enums import (
AgentName,
AgentPermissionLevel,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.budget import BudgetAllocation
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.services.agent_runs import AgentRunService
DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE = "digital_employee_reminder_scan"
DIGITAL_EMPLOYEE_REMINDER_TOOL_NAME = "digital_employee.reminder.scan"
APPROVAL_PENDING_STATUSES = {"submitted", "review", "in_progress", "pending", "pending_review"}
PAYMENT_PENDING_STATUSES = {"approved", "pending_payment", "payment_pending"}
ARCHIVE_PENDING_STATUSES = {"paid", "payment_completed", "pending_archive"}
SUPPLEMENT_STATUSES = {"returned", "rejected", "supplement", "supplement_required"}
APPLICATION_ACTIVE_STATUSES = {"approved", "submitted", "review", "in_progress", "pending"}
HIGH_AMOUNT_THRESHOLD = Decimal("10000.00")
DEFAULT_WINDOW_DAYS = 14
class DigitalEmployeeReminderTaskService:
def __init__(self, db: Session) -> None:
self.db = db
def refresh_reminders(
self,
*,
source: str = AgentRunSource.SCHEDULE.value,
now: datetime | None = None,
window_days: int = DEFAULT_WINDOW_DAYS,
) -> dict[str, Any]:
run_service = AgentRunService(self.db)
started_at = now or datetime.now(UTC)
run = run_service.create_run(
agent=AgentName.HERMES.value,
source=source,
user_id="digital_employee",
ontology_json={"scenario": "financial_reminder", "intent": "scan"},
route_json={
"task_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE,
"job_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE,
"selected_agent": AgentName.HERMES.value,
"phase": "running",
"window_days": int(window_days or DEFAULT_WINDOW_DAYS),
"heartbeat_at": datetime.now(UTC).isoformat(),
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.RUNNING.value,
started_at=started_at,
)
timer = perf_counter()
try:
report = self.build_reminder_report(now=started_at, window_days=window_days)
summary = self._build_summary(report)
duration_ms = int((perf_counter() - timer) * 1000)
response = {
"task_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE,
"summary": summary,
"report": report,
}
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=DIGITAL_EMPLOYEE_REMINDER_TOOL_NAME,
request_json={
"task_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE,
"window_days": int(window_days or DEFAULT_WINDOW_DAYS),
},
response_json=response,
status=AgentRunStatus.SUCCEEDED.value,
duration_ms=duration_ms,
)
run_service.merge_route_json(
run.run_id,
{
"phase": "succeeded",
"summary": summary,
"report": report,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=(
"定时提醒扫描完成:"
f"提醒 {summary['recipient_count']} 人,"
f"生成 {summary['reminder_count']} 条事项。"
),
finished_at=datetime.now(UTC),
)
return response
except Exception as exc:
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=DIGITAL_EMPLOYEE_REMINDER_TOOL_NAME,
request_json={"task_type": DIGITAL_EMPLOYEE_REMINDER_TASK_TYPE},
response_json={},
status=AgentRunStatus.FAILED.value,
duration_ms=int((perf_counter() - timer) * 1000),
error_message=str(exc),
)
run_service.merge_route_json(
run.run_id,
{"phase": "failed", "heartbeat_at": datetime.now(UTC).isoformat()},
status=AgentRunStatus.FAILED.value,
error_message=str(exc),
finished_at=datetime.now(UTC),
)
raise
def build_reminder_report(
self,
*,
now: datetime | None = None,
window_days: int = DEFAULT_WINDOW_DAYS,
) -> dict[str, Any]:
scan_time = self._aware(now or datetime.now(UTC))
recipient_map: dict[str, dict[str, Any]] = {}
counters: dict[str, int] = defaultdict(int)
for reminder in [
*self._approval_pending_reminders(scan_time),
*self._budget_compilation_reminders(scan_time),
*self._travel_application_expiry_reminders(scan_time, window_days=window_days),
*self._reimbursement_overdue_reminders(scan_time, window_days=window_days),
]:
self._append_reminder(recipient_map, reminder)
counters[str(reminder["type"])] += 1
recipients = sorted(
recipient_map.values(),
key=lambda item: (-len(item["reminders"]), item["recipientName"]),
)
return {
"title": "数字员工定时提醒扫描报告",
"generatedAt": scan_time.isoformat(),
"windowDays": int(window_days or DEFAULT_WINDOW_DAYS),
"totals": {
"recipientCount": len(recipients),
"reminderCount": sum(counters.values()),
"approvalPendingCount": counters["approval_pending"],
"budgetReminderCount": counters["budget_compilation"],
"travelApplicationReminderCount": counters["travel_application_expiry"],
"reimbursementOverdueCount": counters["reimbursement_overdue"],
},
"recipients": recipients,
}
def _approval_pending_reminders(self, now: datetime) -> list[dict[str, Any]]:
stmt = (
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.employee).selectinload(Employee.manager))
.where(ExpenseClaim.status.in_(APPROVAL_PENDING_STATUSES))
.order_by(ExpenseClaim.submitted_at.asc().nullslast(), ExpenseClaim.updated_at.asc())
.limit(200)
)
reminders: list[dict[str, Any]] = []
for claim in self.db.scalars(stmt).all():
recipient = self._approval_recipient(claim)
wait_started_at = claim.submitted_at or claim.updated_at or claim.created_at
wait_days = self._wait_days(now, wait_started_at)
reminders.append(
self._document_reminder(
reminder_type="approval_pending",
recipient=recipient,
claim=claim,
title=f"{claim.claim_no} 待审批",
action="请在今日处理审批待办,避免影响后续付款和归档。",
wait_days=wait_days,
type_score=0.85,
)
)
return reminders
def _budget_compilation_reminders(self, now: datetime) -> list[dict[str, Any]]:
fiscal_year = now.astimezone(UTC).year
period_key = self._current_quarter_key(now)
active_statuses = {"active", "published"}
year_count = self.db.scalar(
select(func.count(BudgetAllocation.id)).where(
BudgetAllocation.fiscal_year == fiscal_year,
BudgetAllocation.status.in_(active_statuses),
)
) or 0
period_count = self.db.scalar(
select(func.count(BudgetAllocation.id)).where(
BudgetAllocation.fiscal_year == fiscal_year,
BudgetAllocation.period_key == period_key,
BudgetAllocation.status.in_(active_statuses),
)
) or 0
if year_count and period_count:
return []
recipients = self._budget_admin_recipients()
if not recipients:
recipients = [
{
"recipientId": "budget_admin",
"recipientName": "预算管理员",
"recipientRole": "budget_admin",
}
]
title = (
f"{fiscal_year} 年预算池待建立"
if not year_count
else f"{fiscal_year}{period_key} 预算池待补齐"
)
return [
{
"type": "budget_compilation",
"priority": "high" if not year_count else "medium",
"priorityScore": 0.9 if not year_count else 0.65,
"title": title,
"action": "请检查预算编制进度,补齐部门、费用类型和期间预算池。",
"recipient": recipient,
"relatedDocuments": [],
"metrics": {
"fiscalYear": fiscal_year,
"periodKey": period_key,
"activeYearAllocationCount": int(year_count),
"activePeriodAllocationCount": int(period_count),
},
}
for recipient in recipients
]
def _travel_application_expiry_reminders(
self,
now: datetime,
*,
window_days: int,
) -> list[dict[str, Any]]:
cutoff = now - timedelta(days=max(1, int(window_days or DEFAULT_WINDOW_DAYS)))
stmt = (
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.employee))
.where(ExpenseClaim.expense_type.like("%_application"))
.where(ExpenseClaim.status.in_(APPLICATION_ACTIVE_STATUSES))
.where(ExpenseClaim.occurred_at <= now)
.where(ExpenseClaim.occurred_at >= cutoff)
.order_by(ExpenseClaim.occurred_at.asc())
.limit(200)
)
reminders: list[dict[str, Any]] = []
for claim in self.db.scalars(stmt).all():
if not self._is_travel_application(claim):
continue
if self._has_linked_reimbursement_draft(claim):
continue
wait_days = self._wait_days(now, claim.occurred_at)
reminders.append(
self._document_reminder(
reminder_type="travel_application_expiry",
recipient=self._employee_recipient(claim),
claim=claim,
title=f"{claim.claim_no} 出差申请已到期",
action="请发起报销、延长申请或关闭未使用申请。",
wait_days=wait_days,
type_score=0.75,
)
)
return reminders
def _reimbursement_overdue_reminders(
self,
now: datetime,
*,
window_days: int,
) -> list[dict[str, Any]]:
cutoff = now - timedelta(days=max(1, int(window_days or DEFAULT_WINDOW_DAYS)))
statuses = PAYMENT_PENDING_STATUSES | ARCHIVE_PENDING_STATUSES | SUPPLEMENT_STATUSES
stmt = (
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.employee))
.where(ExpenseClaim.status.in_(statuses))
.where(ExpenseClaim.updated_at >= cutoff)
.order_by(ExpenseClaim.updated_at.asc())
.limit(200)
)
reminders: list[dict[str, Any]] = []
for claim in self.db.scalars(stmt).all():
if self._is_application_claim(claim):
continue
status = str(claim.status or "").strip()
recipient = self._finance_recipient(claim)
action = "请检查付款或归档处理进度。"
title = f"{claim.claim_no} 报销流程待处理"
if status in SUPPLEMENT_STATUSES:
recipient = self._employee_recipient(claim)
title = f"{claim.claim_no} 待补充材料"
action = "请补齐材料后重新提交,减少财务反复沟通。"
elif status in PAYMENT_PENDING_STATUSES:
title = f"{claim.claim_no} 待付款"
action = "请确认付款排期,避免已审批单据长期停留。"
elif status in ARCHIVE_PENDING_STATUSES:
title = f"{claim.claim_no} 待归档"
action = "请完成归档,保证单据闭环和后续审计可追踪。"
wait_started_at = claim.updated_at or claim.submitted_at or claim.created_at
wait_days = self._wait_days(now, wait_started_at)
reminders.append(
self._document_reminder(
reminder_type="reimbursement_overdue",
recipient=recipient,
claim=claim,
title=title,
action=action,
wait_days=wait_days,
type_score=0.7,
)
)
return reminders
def _document_reminder(
self,
*,
reminder_type: str,
recipient: dict[str, str],
claim: ExpenseClaim,
title: str,
action: str,
wait_days: int,
type_score: float,
) -> dict[str, Any]:
amount = Decimal(claim.amount or 0)
priority_score = self._priority_score(
wait_days=wait_days,
amount=amount,
type_score=type_score,
)
return {
"type": reminder_type,
"priority": self._priority(priority_score),
"priorityScore": round(priority_score, 4),
"title": title,
"action": action,
"recipient": recipient,
"relatedDocuments": [
{
"documentId": claim.id,
"documentNo": claim.claim_no,
"employeeName": claim.employee_name,
"departmentName": claim.department_name,
"expenseType": claim.expense_type,
"status": claim.status,
"approvalStage": claim.approval_stage,
"amount": float(amount),
"waitDays": wait_days,
}
],
"metrics": {
"amount": float(amount),
"waitDays": wait_days,
},
}
@staticmethod
def _append_reminder(
recipient_map: dict[str, dict[str, Any]],
reminder: dict[str, Any],
) -> None:
recipient = dict(reminder.pop("recipient"))
recipient_id = str(
recipient.get("recipientId") or recipient.get("recipientName") or "unknown"
)
row = recipient_map.setdefault(
recipient_id,
{
"recipientId": recipient_id,
"recipientName": str(recipient.get("recipientName") or recipient_id),
"recipientRole": str(recipient.get("recipientRole") or "unknown"),
"reminders": [],
},
)
row["reminders"].append(reminder)
def _approval_recipient(self, claim: ExpenseClaim) -> dict[str, str]:
employee = claim.employee
if employee is not None and employee.manager is not None:
return {
"recipientId": employee.manager.id,
"recipientName": employee.manager.name,
"recipientRole": "manager",
}
return self._finance_recipient(claim)
@staticmethod
def _employee_recipient(claim: ExpenseClaim) -> dict[str, str]:
if claim.employee is not None:
return {
"recipientId": claim.employee.id,
"recipientName": claim.employee.name,
"recipientRole": "employee",
}
return {
"recipientId": str(claim.employee_id or claim.employee_name or "employee"),
"recipientName": str(claim.employee_name or "员工"),
"recipientRole": "employee",
}
@staticmethod
def _finance_recipient(claim: ExpenseClaim) -> dict[str, str]:
employee = claim.employee
owner = ""
if employee is not None:
owner = str(employee.finance_owner_name or "").strip()
return {
"recipientId": owner or "finance_operator",
"recipientName": owner or "财务经办人",
"recipientRole": "finance",
}
def _budget_admin_recipients(self) -> list[dict[str, str]]:
stmt = (
select(Employee)
.options(selectinload(Employee.roles))
.join(Employee.roles)
.where(Role.role_code.in_(("budget_monitor", "executive")))
.order_by(Employee.name.asc())
.limit(20)
)
recipients = []
seen: set[str] = set()
for employee in self.db.scalars(stmt).all():
if employee.id in seen:
continue
seen.add(employee.id)
recipients.append(
{
"recipientId": employee.id,
"recipientName": employee.name,
"recipientRole": "budget_admin",
}
)
return recipients
@staticmethod
def _is_travel_application(claim: ExpenseClaim) -> bool:
expense_type = str(claim.expense_type or "").strip().lower()
if expense_type == "travel_application":
return True
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
detail = flag.get("application_detail") or flag.get("applicationDetail") or {}
if isinstance(detail, dict) and "差旅" in str(detail.get("application_type") or ""):
return True
return False
@staticmethod
def _is_application_claim(claim: ExpenseClaim) -> bool:
expense_type = str(claim.expense_type or "").strip().lower()
if expense_type in {"application", "expense_application"} or expense_type.endswith(
"_application"
):
return True
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("business_stage") or "").strip() == "expense_application":
return True
if isinstance(flag.get("application_detail"), dict):
return True
return False
def _has_linked_reimbursement_draft(self, application_claim: ExpenseClaim) -> bool:
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if flag.get("generated_draft_claim_id") or flag.get("generated_draft_claim_no"):
return True
stmt = (
select(ExpenseClaim)
.where(ExpenseClaim.expense_type.not_like("%_application"))
.order_by(ExpenseClaim.created_at.desc())
.limit(300)
)
for claim in self.db.scalars(stmt).all():
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("application_claim_id") or "") == application_claim.id:
return True
return False
@staticmethod
def _priority_score(*, wait_days: int, amount: Decimal, type_score: float) -> float:
wait_score = min(max(wait_days, 0) / 3, 1)
amount_score = min(float(max(amount, Decimal("0.00")) / HIGH_AMOUNT_THRESHOLD), 1)
return 0.45 * wait_score + 0.35 * amount_score + 0.20 * type_score
@staticmethod
def _priority(score: float) -> str:
if score >= 0.75:
return "high"
if score >= 0.45:
return "medium"
return "low"
@classmethod
def _wait_days(cls, now: datetime, started_at: datetime | None) -> int:
if started_at is None:
return 0
delta = cls._aware(now) - cls._aware(started_at)
return max(0, int(delta.total_seconds() // 86400))
@staticmethod
def _aware(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
@staticmethod
def _current_quarter_key(now: datetime) -> str:
month = now.month
quarter = ((month - 1) // 3) + 1
return f"Q{quarter}"
@staticmethod
def _build_summary(report: dict[str, Any]) -> dict[str, Any]:
totals = report.get("totals") if isinstance(report, dict) else {}
totals = totals if isinstance(totals, dict) else {}
return {
"recipient_count": int(totals.get("recipientCount") or 0),
"reminder_count": int(totals.get("reminderCount") or 0),
"approval_pending_count": int(totals.get("approvalPendingCount") or 0),
"budget_reminder_count": int(totals.get("budgetReminderCount") or 0),
"travel_application_reminder_count": int(
totals.get("travelApplicationReminderCount") or 0
),
"reimbursement_overdue_count": int(totals.get("reimbursementOverdueCount") or 0),
}

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from datetime import UTC, datetime
from time import perf_counter
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import (
AgentName,
AgentPermissionLevel,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.services.agent_runs import AgentRunService
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
EMPLOYEE_PROFILE_SCAN_TASK_TYPE = "employee_behavior_profile_scan"
EMPLOYEE_PROFILE_SCAN_TOOL_NAME = "digital_employee.employee_behavior_profile.scan"
class EmployeeProfileScanTaskService:
def __init__(self, db: Session) -> None:
self.db = db
def refresh_profiles(self, *, source: str = AgentRunSource.SCHEDULE.value) -> dict[str, Any]:
run_service = AgentRunService(self.db)
run = run_service.create_run(
agent=AgentName.HERMES.value,
source=source,
user_id="digital_employee",
ontology_json={
"scenario": "employee_behavior_profile",
"intent": "scan",
},
route_json={
"task_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE,
"job_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE,
"selected_agent": AgentName.HERMES.value,
"phase": "running",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.RUNNING.value,
)
timer = perf_counter()
try:
# 画像快照表的 source_task_log_id 外键指向 Hermes 任务日志。
# 这里用 agent_runs 记录数字员工轨迹,因此不写入该外键,避免错误关联。
summary = HermesEmployeeProfileScannerService(self.db).scan_employee_profiles(
log_id=None
)
duration_ms = int((perf_counter() - timer) * 1000)
report = self._build_report(summary)
response = {
"task_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE,
"summary": summary,
"report": report,
}
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=EMPLOYEE_PROFILE_SCAN_TOOL_NAME,
request_json={"task_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE},
response_json=response,
status=AgentRunStatus.SUCCEEDED.value,
duration_ms=duration_ms,
)
run_service.merge_route_json(
run.run_id,
{
"phase": "succeeded",
"summary": summary,
"report": report,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=(
"员工行为画像已生成:"
f"覆盖 {summary.get('target_employee_count', 0)} 人,"
f"快照 {summary.get('snapshot_count', 0)} 条,"
f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。"
),
finished_at=datetime.now(UTC),
)
return response
except Exception as exc:
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=EMPLOYEE_PROFILE_SCAN_TOOL_NAME,
request_json={"task_type": EMPLOYEE_PROFILE_SCAN_TASK_TYPE},
response_json={},
status=AgentRunStatus.FAILED.value,
duration_ms=int((perf_counter() - timer) * 1000),
error_message=str(exc),
)
run_service.merge_route_json(
run.run_id,
{
"phase": "failed",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.FAILED.value,
error_message=str(exc),
finished_at=datetime.now(UTC),
)
raise
@staticmethod
def _build_report(summary: dict[str, Any]) -> dict[str, Any]:
return {
"title": "员工财务行为画像扫描报告",
"targetEmployeeCount": int(summary.get("target_employee_count") or 0),
"profileSnapshotCount": int(summary.get("snapshot_count") or 0),
"highAttentionEmployeeCount": int(
summary.get("high_attention_employee_count") or 0
),
"windowDays": list(summary.get("window_days") or []),
"algorithmVersion": str(summary.get("algorithm_version") or ""),
"baselineSummary": summary.get("baseline_summary") or {},
}

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
import os
import threading
from datetime import datetime
from zoneinfo import ZoneInfo
from app.core.agent_enums import AgentRunSource
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.services.employee_profile_scan_task import EmployeeProfileScanTaskService
logger = get_logger("app.services.employee_profile_scheduler")
class EmployeeProfileScheduler:
def __init__(self) -> None:
timezone_name = str(os.environ.get("X_FINANCIAL_SCHEDULER_TZ") or "Asia/Shanghai").strip()
interval = int(os.environ.get("X_FINANCIAL_EMPLOYEE_PROFILE_INTERVAL_SECONDS") or "1800")
initial_delay = int(
os.environ.get("X_FINANCIAL_EMPLOYEE_PROFILE_INITIAL_DELAY_SECONDS") or "18"
)
self._timezone = ZoneInfo(timezone_name or "Asia/Shanghai")
self._interval_seconds = max(300, interval)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="employee-profile-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Employee profile scheduler started timezone=%s interval=%ss",
self._timezone.key,
self._interval_seconds,
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Employee profile scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
try:
self._refresh_profiles()
except Exception: # pragma: no cover - scheduler best effort logging
logger.exception("Scheduled employee profile scan failed")
if self._stop_event.wait(self._interval_seconds):
break
def _refresh_profiles(self) -> None:
db = get_session_factory()()
try:
result = EmployeeProfileScanTaskService(db).refresh_profiles(
source=AgentRunSource.SCHEDULE.value
)
summary = result.get("summary") or {}
logger.info(
"Employee profile scan generated at=%s employees=%s snapshots=%s attention=%s",
datetime.now(self._timezone).isoformat(),
summary.get("target_employee_count"),
summary.get("snapshot_count"),
summary.get("high_attention_employee_count"),
)
except Exception:
db.rollback()
raise
finally:
db.close()
employee_profile_scheduler = EmployeeProfileScheduler()

View File

@@ -245,7 +245,7 @@ class ExpenseClaimReadModelMixin:
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
if not self._is_editable_claim_status(claim.status):
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
raise ValueError("只有草稿、待补充或退回待提交状态的单才允许执行该操作。")
@staticmethod
def _ensure_draft_pending_claim(claim: ExpenseClaim) -> None:

View File

@@ -562,9 +562,6 @@ class ExpenseClaimService(
if claim is None:
return None
if self._is_expense_application_claim(claim) and not current_user.is_admin:
raise ValueError("申请单只有系统管理员可以删除。")
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
@@ -756,6 +753,5 @@ class ExpenseClaimService(

View File

@@ -9,75 +9,23 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.budget import BudgetAllocation
from app.models.financial_record import ExpenseClaim
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.services.budget_support import BudgetSupportMixin
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
SLA_TARGET_HOURS = Decimal("8.0")
PENDING_STATUSES = {
"submitted",
"review",
"pending_review",
"manager_review",
"budget_review",
"finance_review",
"approving",
}
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
EXPENSE_TYPE_ALIASES = {
"travel_application": "travel",
"business_travel": "travel",
"trip": "travel",
"traffic": "travel",
"transportation": "travel",
"hotel": "travel",
"accommodation": "travel",
"business_meal": "meal",
"communication_fee": "communication",
}
CHART_COLORS = [
"var(--theme-primary)",
"var(--chart-blue)",
"var(--chart-amber)",
"var(--chart-purple)",
"var(--success)",
"var(--danger)",
]
STAGE_LABELS = {
"manager": "直属经理",
"manager_review": "直属经理",
"budget": "预算复核",
"budget_review": "预算复核",
"finance": "财务审核",
"finance_review": "财务审核",
"payment": "付款确认",
"pending_payment": "付款确认",
}
RISK_SIGNAL_LABELS = {
"duplicate_invoice": "重复发票",
"split_billing": "拆分报销",
"frequent_small_claims": "高频小额",
"location_mismatch": "地点不一致",
"amount_outlier": "金额异常",
"preapproval_absent": "缺少事前申请",
"missing_material": "材料不完整",
"budget_pressure": "预算压力偏高",
"budget_overrun": "预算超支",
"budget_warning": "预算预警",
"over_budget": "预算超支",
"invoice_abnormal": "发票异常",
"invoice_missing": "缺少发票",
"missing_invoice": "缺少发票",
"policy_violation": "政策不符",
"abnormal_frequency": "频次异常",
"manual_review": "人工复核",
}
from app.services.finance_dashboard_constants import (
CHART_COLORS,
EMPTY_DONUT,
EXCLUDED_SPEND_STATUSES,
EXPENSE_TYPE_ALIASES,
PENDING_STATUSES,
RISK_SIGNAL_LABELS,
SLA_TARGET_HOURS,
STAGE_LABELS,
SUCCESS_STATUSES,
)
class FinanceDashboardService(BudgetSupportMixin):
@@ -93,7 +41,6 @@ class FinanceDashboardService(BudgetSupportMixin):
trend_range: str = "近12天",
department_range: str = "本月",
) -> FinanceDashboardRead:
self._ensure_storage_ready()
now = datetime.now(UTC)
start, end, resolved_key = self._resolve_scope(
range_key=range_key,
@@ -103,7 +50,7 @@ class FinanceDashboardService(BudgetSupportMixin):
)
previous_start = start - (end - start)
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
department_start, department_end = self._resolve_department_scope(department_range, now)
ranking_start, ranking_end = self._resolve_ranking_scope(department_range, now)
claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
@@ -111,7 +58,7 @@ class FinanceDashboardService(BudgetSupportMixin):
scope_claims = self._claims_between(claims, start, end)
previous_claims = self._claims_between(claims, previous_start, start)
trend_claims = self._claims_between(claims, trend_start, trend_end)
department_claims = self._claims_between(claims, department_start, department_end)
ranking_claims = self._claims_between(claims, ranking_start, ranking_end)
totals = self._totals(scope_claims)
previous_totals = self._totals(previous_claims)
@@ -127,17 +74,15 @@ class FinanceDashboardService(BudgetSupportMixin):
trend=self._trend(trend_labels, trend_claims, now),
spend_by_category=self._spend_by_category(scope_claims),
exception_mix=self._payment_status_mix(scope_claims),
department_ranking=self._department_ranking(department_claims),
employee_ranking=self._employee_ranking(department_claims),
top_claims=self._top_claims(department_claims),
department_ranking=self._department_ranking(ranking_claims),
department_employee_mix=self._department_employee_mix(ranking_claims),
employee_ranking=self._employee_ranking(ranking_claims),
top_claims=self._top_claims(ranking_claims),
bottlenecks=self._bottlenecks(scope_claims),
budget_summary=self._budget_summary(now.year),
budget_metrics=self._budget_metrics(now.year),
)
def _ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
def _fetch_claims(self) -> list[ExpenseClaim]:
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
return list(self.db.scalars(stmt).all())
@@ -189,18 +134,20 @@ class FinanceDashboardService(BudgetSupportMixin):
labels = [self._date_label(start_day + timedelta(days=index)) for index in range(days)]
return self._day_start(start_day), self._day_after(end_day), labels
def _resolve_department_scope(
def _resolve_ranking_scope(
self,
department_range: str,
now: datetime,
) -> tuple[datetime, datetime]:
today = now.date()
key = str(department_range or "").strip()
if key == "本周":
start_day = today - timedelta(days=today.weekday())
elif key == "本季度":
if key == "全部":
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
if key == "本季度":
quarter_month = ((today.month - 1) // 3) * 3 + 1
start_day = today.replace(month=quarter_month, day=1)
elif key == "本年":
start_day = today.replace(month=1, day=1)
else:
start_day = today.replace(day=1)
return self._day_start(start_day), self._day_after(today)
@@ -347,6 +294,7 @@ class FinanceDashboardService(BudgetSupportMixin):
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
employees: dict[str, set[str]] = defaultdict(set)
for claim in claims:
status = self._status(claim)
if status in EXCLUDED_SPEND_STATUSES:
@@ -357,6 +305,9 @@ class FinanceDashboardService(BudgetSupportMixin):
amount = self._claim_amount(claim)
buckets[department_name] += amount
counts[department_name] += 1
employee_name = str(claim.employee_name or "").strip()
if not self._is_missing_finance_dimension(employee_name):
employees[department_name].add(employee_name)
if status in PENDING_STATUSES:
pending_amounts[department_name] += amount
@@ -366,6 +317,7 @@ class FinanceDashboardService(BudgetSupportMixin):
"amount": self._decimal_number(amount),
"value": self._decimal_number(amount),
"count": counts[name],
"employeeCount": len(employees[name]),
"pendingAmount": self._decimal_number(pending_amounts[name]),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
@@ -375,6 +327,34 @@ class FinanceDashboardService(BudgetSupportMixin):
]
return rows
def _department_employee_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[tuple[str, str], Decimal] = defaultdict(Decimal)
for claim in claims:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
department_name = str(claim.department_name or "").strip()
employee_name = str(claim.employee_name or "").strip()
if self._is_missing_finance_dimension(department_name):
continue
if self._is_missing_finance_dimension(employee_name):
continue
buckets[(department_name, employee_name)] += self._claim_amount(claim)
rows = [
{
"name": f"{department_name} · {employee_name}",
"department": department_name,
"employee": employee_name,
"value": self._decimal_number(amount),
"amount": self._decimal_number(amount),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, ((department_name, employee_name), amount) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows or EMPTY_DONUT
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from decimal import Decimal
SLA_TARGET_HOURS = Decimal("8.0")
PENDING_STATUSES = {
"submitted",
"review",
"pending_review",
"manager_review",
"budget_review",
"finance_review",
"approving",
}
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
EXPENSE_TYPE_ALIASES = {
"travel_application": "travel",
"business_travel": "travel",
"trip": "travel",
"traffic": "travel",
"transportation": "travel",
"hotel": "travel",
"accommodation": "travel",
"business_meal": "meal",
"communication_fee": "communication",
}
CHART_COLORS = [
"var(--theme-primary)",
"var(--chart-blue)",
"var(--chart-amber)",
"var(--chart-purple)",
"var(--success)",
"var(--danger)",
]
STAGE_LABELS = {
"manager": "直属经理",
"manager_review": "直属经理",
"budget": "预算复核",
"budget_review": "预算复核",
"finance": "财务审核",
"finance_review": "财务审核",
"payment": "付款确认",
"pending_payment": "付款确认",
}
RISK_SIGNAL_LABELS = {
"duplicate_invoice": "重复发票",
"split_billing": "拆分报销",
"frequent_small_claims": "高频小额",
"location_mismatch": "地点不一致",
"amount_outlier": "金额异常",
"preapproval_absent": "缺少事前申请",
"missing_material": "材料不完整",
"budget_pressure": "预算压力偏高",
"budget_overrun": "预算超支",
"budget_warning": "预算预警",
"over_budget": "预算超支",
"invoice_abnormal": "发票异常",
"invoice_missing": "缺少发票",
"missing_invoice": "缺少发票",
"policy_violation": "政策不符",
"abnormal_frequency": "频次异常",
"manual_review": "人工复核",
}

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import os
import threading
from datetime import datetime
from zoneinfo import ZoneInfo
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
logger = get_logger("app.services.finance_dashboard_scheduler")
class FinanceDashboardScheduler:
def __init__(self) -> None:
timezone_name = str(os.environ.get("X_FINANCIAL_SCHEDULER_TZ") or "Asia/Shanghai").strip()
interval = int(os.environ.get("X_FINANCIAL_FINANCE_DASHBOARD_INTERVAL_SECONDS") or "120")
initial_delay = int(
os.environ.get("X_FINANCIAL_FINANCE_DASHBOARD_INITIAL_DELAY_SECONDS") or "6"
)
self._timezone = ZoneInfo(timezone_name or "Asia/Shanghai")
self._interval_seconds = max(30, interval)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="finance-dashboard-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Finance dashboard scheduler started timezone=%s interval=%ss",
self._timezone.key,
self._interval_seconds,
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Finance dashboard scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
try:
self._refresh_snapshot()
except Exception: # pragma: no cover - scheduler best effort logging
logger.exception("Scheduled finance dashboard snapshot failed")
if self._stop_event.wait(self._interval_seconds):
break
def _refresh_snapshot(self) -> None:
db = get_session_factory()()
try:
dashboard = FinanceDashboardSnapshotService(db).refresh_default_snapshot()
db.commit()
totals = dashboard.totals or {}
logger.info(
"Finance dashboard snapshot generated at=%s count=%s amount=%s",
datetime.now(self._timezone).isoformat(),
totals.get("reimbursementCount"),
totals.get("reimbursementAmount"),
)
except Exception:
db.rollback()
raise
finally:
db.close()
finance_dashboard_scheduler = FinanceDashboardScheduler()

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from time import perf_counter
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.agent_enums import (
AgentName,
AgentPermissionLevel,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.agent_run import AgentRun
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.services.agent_runs import AgentRunService
from app.services.finance_dashboard import FinanceDashboardService
FINANCE_DASHBOARD_TASK_TYPE = "finance_dashboard_snapshot"
FINANCE_DASHBOARD_TOOL_NAME = "digital_employee.finance_dashboard.snapshot"
SNAPSHOT_TTL_SECONDS = 120
SNAPSHOT_SCHEMA_VERSION = "finance-dashboard-ranking-v2"
class FinanceDashboardSnapshotService:
def __init__(self, db: Session) -> None:
self.db = db
def build_dashboard(
self,
*,
range_key: str = "近30日",
start_date: Any = None,
end_date: Any = None,
trend_range: str = "近12天",
department_range: str = "本月",
) -> FinanceDashboardRead:
key = self._cache_key(
range_key=range_key,
start_date=start_date,
end_date=end_date,
trend_range=trend_range,
department_range=department_range,
)
snapshot = self._latest_fresh_snapshot(key)
if snapshot is not None:
return snapshot
return self.refresh_snapshot(
range_key=range_key,
start_date=start_date,
end_date=end_date,
trend_range=trend_range,
department_range=department_range,
source=AgentRunSource.SYSTEM_EVENT.value,
)
def refresh_default_snapshot(self) -> FinanceDashboardRead:
return self.refresh_snapshot(
range_key="近30日",
trend_range="近12天",
department_range="本月",
source=AgentRunSource.SCHEDULE.value,
)
def refresh_snapshot(
self,
*,
range_key: str,
start_date: Any = None,
end_date: Any = None,
trend_range: str,
department_range: str,
source: str,
) -> FinanceDashboardRead:
key = self._cache_key(
range_key=range_key,
start_date=start_date,
end_date=end_date,
trend_range=trend_range,
department_range=department_range,
)
run_service = AgentRunService(self.db)
run = run_service.create_run(
agent=AgentName.HERMES.value,
source=source,
user_id="digital_employee",
ontology_json={
"scenario": "finance_dashboard",
"intent": "snapshot",
},
route_json={
"task_type": FINANCE_DASHBOARD_TASK_TYPE,
"job_type": FINANCE_DASHBOARD_TASK_TYPE,
"selected_agent": AgentName.HERMES.value,
"snapshot_key": key,
"params": {
"range_key": range_key,
"start_date": self._date_text(start_date),
"end_date": self._date_text(end_date),
"trend_range": trend_range,
"department_range": department_range,
},
"phase": "running",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
permission_level=AgentPermissionLevel.READ.value,
status=AgentRunStatus.RUNNING.value,
)
timer = perf_counter()
try:
dashboard = FinanceDashboardService(self.db).build_dashboard(
range_key=range_key,
start_date=start_date,
end_date=end_date,
trend_range=trend_range,
department_range=department_range,
)
duration_ms = int((perf_counter() - timer) * 1000)
payload = dashboard.model_dump(mode="json")
summary = self._summary(payload)
run_service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name=FINANCE_DASHBOARD_TOOL_NAME,
request_json={
"task_type": FINANCE_DASHBOARD_TASK_TYPE,
"snapshot_key": key,
},
response_json={
"task_type": FINANCE_DASHBOARD_TASK_TYPE,
"summary": summary,
"payload": payload,
},
status=AgentRunStatus.SUCCEEDED.value,
duration_ms=duration_ms,
)
run_service.merge_route_json(
run.run_id,
{
"phase": "succeeded",
"snapshot_key": key,
"snapshot_payload": payload,
"summary": summary,
"expires_at": (
datetime.now(UTC) + timedelta(seconds=SNAPSHOT_TTL_SECONDS)
).isoformat(),
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=(
f"财务看板指标快照已生成:{summary['reimbursement_count']} 单,"
f"金额 {summary['reimbursement_amount']:.2f} 元。"
),
finished_at=datetime.now(UTC),
)
return dashboard
except Exception as exc:
run_service.merge_route_json(
run.run_id,
{
"phase": "failed",
"snapshot_key": key,
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.FAILED.value,
error_message=str(exc),
finished_at=datetime.now(UTC),
)
raise
def _latest_fresh_snapshot(self, key: str) -> FinanceDashboardRead | None:
now = datetime.now(UTC)
for run in self._recent_snapshot_runs():
route_json = run.route_json or {}
if str(route_json.get("snapshot_key") or "") != key:
continue
payload = route_json.get("snapshot_payload")
if not isinstance(payload, dict):
payload = self._payload_from_tool_call(run)
if not isinstance(payload, dict):
continue
expires_at = self._parse_datetime(route_json.get("expires_at"))
if expires_at is None or expires_at <= now:
continue
return FinanceDashboardRead.model_validate(payload)
return None
def _recent_snapshot_runs(self) -> list[AgentRun]:
stmt = (
select(AgentRun)
.options(selectinload(AgentRun.tool_calls))
.where(
AgentRun.agent == AgentName.HERMES.value,
AgentRun.status == AgentRunStatus.SUCCEEDED.value,
)
.order_by(AgentRun.started_at.desc())
.limit(80)
)
runs = list(self.db.scalars(stmt).all())
return [
run
for run in runs
if str((run.route_json or {}).get("task_type") or "") == FINANCE_DASHBOARD_TASK_TYPE
]
@staticmethod
def _payload_from_tool_call(run: AgentRun) -> dict[str, Any] | None:
for tool_call in run.tool_calls:
if tool_call.tool_name != FINANCE_DASHBOARD_TOOL_NAME:
continue
payload = (tool_call.response_json or {}).get("payload")
return payload if isinstance(payload, dict) else None
return None
@staticmethod
def _summary(payload: dict[str, Any]) -> dict[str, Any]:
totals = payload.get("totals") if isinstance(payload.get("totals"), dict) else {}
return {
"finance_snapshot_count": 1,
"reimbursement_count": int(totals.get("reimbursementCount") or 0),
"reimbursement_amount": float(totals.get("reimbursementAmount") or 0),
"pending_payment_amount": float(totals.get("pendingPaymentAmount") or 0),
"budget_metric_count": len(payload.get("budget_metrics") or []),
"department_count": len(payload.get("department_ranking") or []),
}
@classmethod
def _cache_key(
cls,
*,
range_key: str,
start_date: Any,
end_date: Any,
trend_range: str,
department_range: str,
) -> str:
return "|".join(
[
SNAPSHOT_SCHEMA_VERSION,
str(range_key or ""),
cls._date_text(start_date),
cls._date_text(end_date),
str(trend_range or ""),
str(department_range or ""),
]
)
@staticmethod
def _date_text(value: Any) -> str:
if value is None:
return ""
if hasattr(value, "isoformat"):
return str(value.isoformat())
return str(value or "")
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
normalized = str(value or "").strip()
if not normalized:
return None
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)

View File

@@ -0,0 +1,319 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any, Literal
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.agent_run import AgentRun
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.risk_observation import RiskObservation
from app.services.finance_dashboard import FinanceDashboardService
FinanceReportType = Literal["weekly", "quarterly", "annual"]
@dataclass(frozen=True, slots=True)
class FinanceReportPeriod:
report_type: FinanceReportType
start_date: date
end_date: date
label: str
title: str
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
payload["start_date"] = self.start_date.isoformat()
payload["end_date"] = self.end_date.isoformat()
return payload
class FinanceReportContextService:
def __init__(self, db: Session) -> None:
self.db = db
def build_context(
self,
*,
report_type: FinanceReportType = "weekly",
start_date: date | None = None,
end_date: date | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
generated_at = now or datetime.now(UTC)
period = self.resolve_period(
report_type=report_type,
start_date=start_date,
end_date=end_date,
now=generated_at,
)
dashboard = FinanceDashboardService(self.db).build_dashboard(
range_key="自定义",
start_date=period.start_date,
end_date=period.end_date,
trend_range="近12天" if report_type == "weekly" else "近30天",
department_range="本季度" if report_type != "weekly" else "本月",
)
dashboard_payload = dashboard.model_dump(mode="json")
risk_summary = self._risk_summary(period)
profile_summary = self._profile_summary(period)
digital_employee_summary = self._digital_employee_summary(period)
actions = self._action_items(dashboard_payload, risk_summary)
insights = self._insights(dashboard_payload, risk_summary, profile_summary, actions)
return {
"report_type": report_type,
"period": period.to_dict(),
"generated_at": generated_at.isoformat(),
"dashboard": dashboard_payload,
"risk_summary": risk_summary,
"profile_summary": profile_summary,
"digital_employee_summary": digital_employee_summary,
"insights": insights,
"action_items": actions,
"summary": self._summary(dashboard_payload, risk_summary, actions),
}
@staticmethod
def resolve_period(
*,
report_type: FinanceReportType,
start_date: date | None,
end_date: date | None,
now: datetime,
) -> FinanceReportPeriod:
if start_date and end_date:
begin = min(start_date, end_date)
finish = max(start_date, end_date)
return FinanceReportPeriod(
report_type=report_type,
start_date=begin,
end_date=finish,
label=f"{begin.isoformat()}{finish.isoformat()}",
title=_report_title(report_type),
)
local_now = now.astimezone(ZoneInfo("Asia/Shanghai")) if now.tzinfo else now
today = local_now.date()
if report_type == "annual":
year = today.year - 1
begin = date(year, 1, 1)
finish = date(year, 12, 31)
label = f"{year}"
elif report_type == "quarterly":
current_quarter = (today.month - 1) // 3 + 1
year = today.year
quarter = current_quarter - 1
if quarter <= 0:
quarter = 4
year -= 1
month = (quarter - 1) * 3 + 1
begin = date(year, month, 1)
finish = _month_end(year, month + 2)
label = f"{year} 年 Q{quarter}"
else:
current_week_start = today - timedelta(days=today.weekday())
begin = current_week_start - timedelta(days=7)
finish = current_week_start - timedelta(days=1)
label = f"{begin.isoformat()}{finish.isoformat()}"
return FinanceReportPeriod(
report_type=report_type,
start_date=begin,
end_date=finish,
label=label,
title=_report_title(report_type),
)
def _risk_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
start_dt = _day_start(period.start_date)
end_dt = _day_after(period.end_date)
rows = list(
self.db.scalars(
select(RiskObservation).where(
RiskObservation.created_at >= start_dt,
RiskObservation.created_at < end_dt,
)
).all()
)
high_rows = [row for row in rows if str(row.risk_level or "").lower() == "high"]
pending_rows = [
row for row in rows if str(row.status or "").lower() in {"pending_review", "open"}
]
top_signals: dict[str, int] = {}
for row in rows:
label = str(row.title or row.risk_signal or "风险观察").strip()
top_signals[label] = top_signals.get(label, 0) + 1
return {
"risk_count": len(rows),
"high_risk_count": len(high_rows),
"pending_review_count": len(pending_rows),
"top_signals": [
{"name": name, "count": count}
for name, count in sorted(
top_signals.items(),
key=lambda item: item[1],
reverse=True,
)[:5]
],
}
def _profile_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
start_dt = _day_start(period.start_date)
end_dt = _day_after(period.end_date)
rows = list(
self.db.scalars(
select(EmployeeBehaviorProfileSnapshot).where(
EmployeeBehaviorProfileSnapshot.calculated_at >= start_dt,
EmployeeBehaviorProfileSnapshot.calculated_at < end_dt,
)
).all()
)
attention_rows = [
row
for row in rows
if str(row.profile_level or "").lower() in {"attention", "high", "warning"}
or int(row.profile_score or 0) >= 80
]
return {
"snapshot_count": len(rows),
"attention_profile_count": len(attention_rows),
"top_profiles": [
{
"name": row.subject_name,
"department": row.department_name or "",
"score": row.profile_score,
"level": row.profile_level,
}
for row in sorted(
rows,
key=lambda item: int(item.profile_score or 0),
reverse=True,
)[:5]
],
}
def _digital_employee_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
start_dt = _day_start(period.start_date)
end_dt = _day_after(period.end_date)
rows = list(
self.db.scalars(
select(AgentRun).where(
AgentRun.agent == "hermes",
AgentRun.started_at >= start_dt,
AgentRun.started_at < end_dt,
)
).all()
)
succeeded = [row for row in rows if row.status == "succeeded"]
reports = [
row
for row in rows
if "finance_report" in str((row.route_json or {}).get("task_type") or "")
]
return {
"run_count": len(rows),
"succeeded_count": len(succeeded),
"report_count": len(reports),
}
def _action_items(
self,
dashboard: dict[str, Any],
risk: dict[str, Any],
) -> list[dict[str, Any]]:
actions: list[dict[str, Any]] = []
for item in list(dashboard.get("bottlenecks") or [])[:4]:
name = str(item.get("name") or "财务关注项").strip()
tone = str(item.get("tone") or "neutral").strip()
if tone in {"warning", "danger"}:
actions.append(
{
"title": name,
"owner": str(item.get("role") or "财务运营组"),
"priority": "high" if tone == "danger" else "medium",
"suggestion": (
f"请跟进{name}"
f"{item.get('duration') or ''} {item.get('status') or ''}"
).strip(),
}
)
if int(risk.get("pending_review_count") or 0) > 0:
actions.append(
{
"title": "风险观察待复核",
"owner": "风控与审计部",
"priority": "high",
"suggestion": f"当前有 {risk['pending_review_count']} 条风险观察待复核。",
}
)
return actions[:6]
def _insights(
self,
dashboard: dict[str, Any],
risk: dict[str, Any],
profile: dict[str, Any],
actions: list[dict[str, Any]],
) -> list[str]:
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
amount = float(totals.get("reimbursementAmount") or 0)
count = int(totals.get("reimbursementCount") or 0)
budget_rate = float(totals.get("budgetUsageRate") or 0)
insights = [
f"本周期报销 {count} 单,费用金额 {_money(amount)}",
f"预算使用率 {budget_rate:.1f}%,需关注预算预警和预占释放。",
(
f"风险观察 {risk.get('risk_count', 0)} 条,"
f"其中高风险 {risk.get('high_risk_count', 0)} 条。"
),
]
if int(profile.get("attention_profile_count") or 0) > 0:
insights.append(f"员工画像中有 {profile['attention_profile_count']} 个高关注样本。")
if actions:
insights.append(f"数字员工整理出 {len(actions)} 项管理动作,建议纳入本周跟进。")
return insights[:5]
@staticmethod
def _summary(
dashboard: dict[str, Any],
risk: dict[str, Any],
actions: list[dict[str, Any]],
) -> dict[str, Any]:
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
return {
"reimbursement_count": int(totals.get("reimbursementCount") or 0),
"reimbursement_amount": float(totals.get("reimbursementAmount") or 0),
"pending_payment_amount": float(totals.get("pendingPaymentAmount") or 0),
"risk_count": int(risk.get("risk_count") or 0),
"action_count": len(actions),
}
def _report_title(report_type: str) -> str:
return {
"weekly": "财务经营周报",
"quarterly": "财务经营季报",
"annual": "财务经营年报",
}.get(report_type, "财务经营报告")
def _month_end(year: int, month: int) -> date:
next_month = date(year + (month // 12), (month % 12) + 1, 1)
return next_month - timedelta(days=1)
def _day_start(value: date) -> datetime:
return datetime.combine(value, datetime.min.time(), tzinfo=UTC)
def _day_after(value: date) -> datetime:
return datetime.combine(value + timedelta(days=1), datetime.min.time(), tzinfo=UTC)
def _money(value: float | Decimal) -> str:
return f"¥{float(value):,.0f}"

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import smtplib
from dataclasses import asdict, dataclass
from email.message import EmailMessage
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app.core.secret_box import decrypt_secret
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.services.settings import SettingsService
@dataclass(frozen=True, slots=True)
class FinanceReportDeliveryResult:
status: str
recipients: list[str]
subject: str
message: str
smtp_host: str
attachment_name: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class FinanceReportMailer:
def __init__(self, db: Session) -> None:
self.db = db
def send_report(
self,
*,
context: dict[str, Any],
pdf_path: Path,
recipients: list[str] | None = None,
dry_run: bool = False,
) -> FinanceReportDeliveryResult:
settings_row, secrets_row = SettingsService(self.db).ensure_settings_ready()
resolved_recipients = self._resolve_recipients(settings_row, recipients)
subject = self._subject(context)
missing = self._missing_config(settings_row, secrets_row, resolved_recipients)
if missing:
return FinanceReportDeliveryResult(
status="pending_configuration",
recipients=resolved_recipients,
subject=subject,
message=f"邮件未发送,缺少配置:{', '.join(missing)}",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
if dry_run:
return FinanceReportDeliveryResult(
status="dry_run",
recipients=resolved_recipients,
subject=subject,
message="邮件 dry-run 完成,未连接 SMTP。",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
password = self._decrypt_password(secrets_row)
message = self._message(
settings_row=settings_row,
context=context,
pdf_path=pdf_path,
recipients=resolved_recipients,
subject=subject,
)
try:
self._send(settings_row, message, password)
except Exception as exc:
return FinanceReportDeliveryResult(
status="failed",
recipients=resolved_recipients,
subject=subject,
message=f"邮件发送失败:{exc}",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
return FinanceReportDeliveryResult(
status="sent",
recipients=resolved_recipients,
subject=subject,
message="邮件已发送。",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
def _message(
self,
*,
settings_row: SystemSetting,
context: dict[str, Any],
pdf_path: Path,
recipients: list[str],
subject: str,
) -> EmailMessage:
sender_address = str(
settings_row.sender_address or settings_row.smtp_username or ""
).strip()
sender_name = str(
settings_row.sender_name or settings_row.company_name or "X-Financial"
).strip()
summary = context.get("summary") if isinstance(context.get("summary"), dict) else {}
insights = list(context.get("insights") or [])[:3]
body = "\n".join(
[
"各位好,",
"",
"数字员工已生成本期财务经营报告,摘要如下:",
*[f"- {item}" for item in insights],
"",
f"报销单数:{summary.get('reimbursement_count', 0)}",
f"报销金额:¥{float(summary.get('reimbursement_amount') or 0):,.0f}",
f"行动项:{summary.get('action_count', 0)}",
"",
"详细内容请查看附件 PDF。",
]
)
message = EmailMessage()
message["Subject"] = subject
message["From"] = f"{sender_name} <{sender_address}>"
message["To"] = ", ".join(recipients)
message.set_content(body)
message.add_attachment(
pdf_path.read_bytes(),
maintype="application",
subtype="pdf",
filename=pdf_path.name,
)
return message
@staticmethod
def _resolve_recipients(
settings_row: SystemSetting,
override_recipients: list[str] | None,
) -> list[str]:
raw_values = override_recipients or [
str(settings_row.default_receiver or ""),
str(settings_row.notice_email or ""),
str(settings_row.admin_email or ""),
]
recipients: list[str] = []
for raw in raw_values:
for item in str(raw or "").replace(";", ",").split(","):
email = item.strip()
if email and "@" in email and email not in recipients:
recipients.append(email)
return recipients
@staticmethod
def _missing_config(
settings_row: SystemSetting,
secrets_row: SystemSettingSecret,
recipients: list[str],
) -> list[str]:
missing: list[str] = []
if not str(settings_row.smtp_host or "").strip():
missing.append("smtp_host")
if not int(settings_row.smtp_port or 0):
missing.append("smtp_port")
if not str(settings_row.sender_address or settings_row.smtp_username or "").strip():
missing.append("sender_address")
if not str(settings_row.smtp_username or "").strip():
missing.append("smtp_username")
if not str(secrets_row.smtp_password_encrypted or "").strip():
missing.append("smtp_password")
if not recipients:
missing.append("recipients")
return missing
@staticmethod
def _decrypt_password(secrets_row: SystemSettingSecret) -> str:
encrypted = str(secrets_row.smtp_password_encrypted or "").strip()
return decrypt_secret(encrypted) if encrypted else ""
@staticmethod
def _subject(context: dict[str, Any]) -> str:
period = context.get("period") if isinstance(context.get("period"), dict) else {}
title = str(period.get("title") or "财务经营报告")
label = str(period.get("label") or "").strip()
return f"X-Financial {title} | {label}".strip()
@staticmethod
def _send(settings_row: SystemSetting, message: EmailMessage, password: str) -> None:
host = str(settings_row.smtp_host or "").strip()
port = int(settings_row.smtp_port or 465)
username = str(settings_row.smtp_username or "").strip()
encryption = str(settings_row.smtp_encryption or "").strip().lower()
if "ssl" in encryption:
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
smtp.login(username, password)
smtp.send_message(message)
return
with smtplib.SMTP(host, port, timeout=20) as smtp:
if "tls" in encryption or "starttls" in encryption:
smtp.starttls()
smtp.login(username, password)
smtp.send_message(message)

View File

@@ -0,0 +1,397 @@
from __future__ import annotations
import html
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.core.config import get_settings
@dataclass(frozen=True, slots=True)
class RenderedFinanceReport:
html_path: Path
pdf_path: Path
storage_key: str
title: str
page_count: int
class FinanceReportRenderer:
def render(self, context: dict[str, Any]) -> RenderedFinanceReport:
report_dir = self._report_dir(context)
report_dir.mkdir(parents=True, exist_ok=True)
title = str((context.get("period") or {}).get("title") or "财务经营报告")
html_text = self.render_html(context)
html_path = report_dir / "report.html"
pdf_path = report_dir / "report.pdf"
html_path.write_text(html_text, encoding="utf-8")
page_count = SimpleFinancePdfWriter().write(pdf_path, context)
return RenderedFinanceReport(
html_path=html_path,
pdf_path=pdf_path,
storage_key=self._storage_key(pdf_path),
title=title,
page_count=page_count,
)
def render_html(self, context: dict[str, Any]) -> str:
period = context.get("period") or {}
dashboard = context.get("dashboard") or {}
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
trend = dashboard.get("trend") if isinstance(dashboard.get("trend"), dict) else {}
departments = list(dashboard.get("department_ranking") or [])
top_claims = list(dashboard.get("top_claims") or [])
actions = list(context.get("action_items") or [])
insights = list(context.get("insights") or [])
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>{_e(period.get("title"))}</title>
<style>
body {{
margin: 0;
font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
color: #1f2937;
background: #f7f9fc;
}}
.page {{ width: 980px; margin: 0 auto; padding: 36px 42px; background: #fff; }}
.cover {{ border-bottom: 3px solid #2f6fed; padding-bottom: 24px; }}
h1 {{ margin: 0 0 10px; font-size: 30px; color: #172554; }}
h2 {{ margin: 28px 0 14px; font-size: 20px; color: #1e3a8a; }}
.muted {{ color: #64748b; }}
.grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }}
.metric {{ border: 1px solid #dbe4f0; border-radius: 6px; padding: 14px; background: #f8fbff; }}
.metric .label {{ color: #64748b; font-size: 13px; }}
.metric .value {{ margin-top: 8px; font-size: 24px; font-weight: 700; color: #0f172a; }}
.insight {{
border-left: 4px solid #2f6fed;
padding: 10px 14px;
background: #f8fbff;
margin: 8px 0;
}}
.bar-row {{ display: flex; align-items: center; gap: 10px; margin: 8px 0; }}
.bar-label {{ width: 120px; color: #475569; }}
.bar-track {{ flex: 1; height: 12px; background: #e2e8f0; border-radius: 0; overflow: hidden; }}
.bar-fill {{ height: 100%; background: #2f6fed; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 8px; }}
th, td {{ padding: 10px 8px; border-bottom: 1px solid #e5edf7; text-align: left; }}
th {{ color: #475569; background: #f8fbff; }}
</style>
</head>
<body>
<main class="page">
<section class="cover">
<h1>{_e(period.get("title"))}</h1>
<div class="muted">
周期:{_e(period.get("label"))} 生成时间:{_e(context.get("generated_at"))}
</div>
</section>
<h2>管理摘要</h2>
{''.join(f'<div class="insight">{_e(item)}</div>' for item in insights)}
<h2>关键指标</h2>
<section class="grid">
{_metric_html("报销金额", _money(totals.get("reimbursementAmount")))}
{_metric_html("报销单数", f'{int(totals.get("reimbursementCount") or 0)}')}
{_metric_html("待付款", _money(totals.get("pendingPaymentAmount")))}
{_metric_html("预算使用率", f'{float(totals.get("budgetUsageRate") or 0):.1f}%')}
</section>
<h2>每日报销趋势</h2>
{_trend_html(trend)}
<h2>部门费用排行</h2>
{_ranking_html(departments, "amount")}
<h2>高额单据</h2>
{_top_claims_html(top_claims)}
<h2>行动清单</h2>
{_actions_html(actions)}
</main>
</body>
</html>"""
def _report_dir(self, context: dict[str, Any]) -> Path:
settings = get_settings()
period = context.get("period") or {}
report_type = str(context.get("report_type") or "weekly")
label = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff_-]+", "_", str(period.get("label") or "latest"))
return settings.resolved_storage_root_dir / "finance_reports" / report_type / label
@staticmethod
def _storage_key(pdf_path: Path) -> str:
root = get_settings().resolved_storage_root_dir.resolve()
return pdf_path.resolve().relative_to(root).as_posix()
class SimpleFinancePdfWriter:
width = 595
height = 842
margin = 48
def write(self, path: Path, context: dict[str, Any]) -> int:
pages = self._build_pages(context)
objects: list[bytes] = []
page_ids: list[int] = []
font_id = 3
for page in pages:
content = self._content_stream(page)
content_id = len(objects) + 4
page_id = len(objects) + 5
objects.append(
f"<< /Length {len(content)} >>\nstream\n".encode("latin-1")
+ content
+ b"\nendstream"
)
objects.append(
(
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {self.width} {self.height}] "
f"/Resources << /Font << /F1 {font_id} 0 R >> >> /Contents {content_id} 0 R >>"
).encode("latin-1")
)
page_ids.append(page_id)
catalog = b"<< /Type /Catalog /Pages 2 0 R >>"
kids = " ".join(f"{page_id} 0 R" for page_id in page_ids)
pages_obj = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_ids)} >>".encode("latin-1")
font_obj = (
b"<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light "
b"/Encoding /UniGB-UCS2-H /DescendantFonts ["
b"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light "
b"/CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 5 >> >>] >>"
)
all_objects = [catalog, pages_obj, font_obj, *objects]
self._write_pdf(path, all_objects)
return len(pages)
def _build_pages(self, context: dict[str, Any]) -> list[list[dict[str, Any]]]:
period = context.get("period") or {}
dashboard = context.get("dashboard") or {}
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
trend = dashboard.get("trend") if isinstance(dashboard.get("trend"), dict) else {}
departments = list(dashboard.get("department_ranking") or [])
actions = list(context.get("action_items") or [])
insights = list(context.get("insights") or [])
pages: list[list[dict[str, Any]]] = []
pages.append(
[
{"type": "title", "text": str(period.get("title") or "财务经营报告")},
{"type": "text", "text": f"报告周期:{period.get('label') or ''}"},
{"type": "text", "text": f"生成时间:{context.get('generated_at') or ''}"},
{"type": "heading", "text": "管理摘要"},
*[{"type": "bullet", "text": str(item)} for item in insights],
{"type": "heading", "text": "关键指标"},
{
"type": "metrics",
"items": [
("报销金额", _money(totals.get("reimbursementAmount"))),
("报销单数", f"{int(totals.get('reimbursementCount') or 0)}"),
("待付款", _money(totals.get("pendingPaymentAmount"))),
("预算使用率", f"{float(totals.get('budgetUsageRate') or 0):.1f}%"),
],
},
]
)
pages.append(
[
{"type": "heading", "text": "每日报销趋势"},
{
"type": "bars",
"labels": trend.get("labels") or [],
"values": trend.get("claimAmount") or [],
},
{"type": "heading", "text": "部门费用排行"},
{
"type": "bars",
"labels": [str(item.get("name") or "") for item in departments[:8]],
"values": [float(item.get("amount") or 0) for item in departments[:8]],
},
]
)
pages.append(
[
{"type": "heading", "text": "行动清单"},
*[
{
"type": "bullet",
"text": (
f"{item.get('title')} / {item.get('owner')}"
f"{item.get('suggestion')}"
),
}
for item in actions
],
]
)
return pages
def _content_stream(self, blocks: list[dict[str, Any]]) -> bytes:
commands: list[str] = ["q", "1 1 1 rg 0 0 595 842 re f"]
y = self.height - self.margin
for block in blocks:
block_type = block["type"]
if block_type == "title":
commands.extend(self._text(block["text"], self.margin, y, 24, "0.05 0.15 0.35"))
y -= 42
elif block_type == "heading":
y -= 8
commands.extend(self._text(block["text"], self.margin, y, 15, "0.10 0.25 0.55"))
y -= 26
elif block_type == "text":
commands.extend(self._text(block["text"], self.margin, y, 10, "0.25 0.30 0.38"))
y -= 18
elif block_type == "bullet":
lines = self._wrap(str(block["text"]), 34)
for line in lines:
commands.extend(self._text(f"{line}", self.margin, y, 10, "0.12 0.16 0.22"))
y -= 17
elif block_type == "metrics":
y = self._metrics(commands, block["items"], y)
elif block_type == "bars":
y = self._bars(commands, block.get("labels") or [], block.get("values") or [], y)
commands.append("Q")
return "\n".join(commands).encode("latin-1")
def _metrics(self, commands: list[str], items: list[tuple[str, str]], y: int) -> int:
box_w = 122
for index, (label, value) in enumerate(items):
x = self.margin + index * (box_w + 8)
commands.append("0.95 0.97 1.00 rg")
commands.append(f"{x} {y - 48} {box_w} 46 re f")
commands.extend(self._text(label, x + 8, y - 18, 8, "0.35 0.42 0.50"))
commands.extend(self._text(value, x + 8, y - 36, 13, "0.05 0.15 0.35"))
return y - 68
def _bars(self, commands: list[str], labels: list[Any], values: list[Any], y: int) -> int:
pairs = [
(str(label), float(value or 0))
for label, value in zip(labels, values, strict=False)
]
max_value = max([value for _label, value in pairs] or [1])
for label, value in pairs[:10]:
width = 310 * (value / max_value) if max_value else 0
commands.extend(self._text(_trim(label, 14), self.margin, y, 9, "0.25 0.30 0.38"))
commands.append("0.88 0.92 0.96 rg")
commands.append(f"{self.margin + 90} {y - 4} 320 8 re f")
commands.append("0.18 0.44 0.93 rg")
commands.append(f"{self.margin + 90} {y - 4} {width:.1f} 8 re f")
commands.extend(self._text(_money(value), self.margin + 420, y, 8, "0.25 0.30 0.38"))
y -= 22
return y - 8
@staticmethod
def _text(text: Any, x: int | float, y: int | float, size: int, color: str) -> list[str]:
return [
f"{color} rg",
"BT",
f"/F1 {size} Tf",
f"{x:.1f} {y:.1f} Td",
f"<{_pdf_hex(str(text))}> Tj",
"ET",
]
@staticmethod
def _wrap(text: str, length: int) -> list[str]:
value = str(text or "").strip()
return [value[index : index + length] for index in range(0, len(value), length)] or [""]
@staticmethod
def _write_pdf(path: Path, objects: list[bytes]) -> None:
offsets: list[int] = []
payload = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
for index, obj in enumerate(objects, start=1):
offsets.append(len(payload))
payload.extend(f"{index} 0 obj\n".encode("latin-1"))
payload.extend(obj)
payload.extend(b"\nendobj\n")
xref_at = len(payload)
payload.extend(f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode("latin-1"))
for offset in offsets:
payload.extend(f"{offset:010d} 00000 n \n".encode("latin-1"))
payload.extend(
(
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
f"startxref\n{xref_at}\n%%EOF"
).encode("latin-1")
)
path.write_bytes(bytes(payload))
def _metric_html(label: str, value: str) -> str:
return (
f'<div class="metric"><div class="label">{_e(label)}</div>'
f'<div class="value">{_e(value)}</div></div>'
)
def _trend_html(trend: dict[str, Any]) -> str:
labels = list(trend.get("labels") or [])
values = [float(value or 0) for value in list(trend.get("claimAmount") or [])]
return _bar_html(labels, values)
def _ranking_html(rows: list[dict[str, Any]], value_key: str) -> str:
labels = [str(item.get("name") or "") for item in rows[:8]]
values = [float(item.get(value_key) or 0) for item in rows[:8]]
return _bar_html(labels, values)
def _bar_html(labels: list[Any], values: list[float]) -> str:
max_value = max(values or [1])
rows = []
for label, value in zip(labels, values, strict=False):
width = 100 * value / max_value if max_value else 0
rows.append(
'<div class="bar-row">'
f'<div class="bar-label">{_e(label)}</div>'
f'<div class="bar-track"><div class="bar-fill" style="width:{width:.1f}%"></div></div>'
f'<div>{_e(_money(value))}</div>'
"</div>"
)
return "".join(rows) or '<div class="muted">暂无数据</div>'
def _top_claims_html(rows: list[dict[str, Any]]) -> str:
body = "".join(
"<tr>"
f"<td>{_e(item.get('claimNo'))}</td>"
f"<td>{_e(item.get('employeeName'))}</td>"
f"<td>{_e(item.get('departmentName'))}</td>"
f"<td>{_e(item.get('amountLabel') or _money(item.get('amount')))}</td>"
"</tr>"
for item in rows[:6]
)
return (
"<table><thead><tr><th>单号</th><th>员工</th><th>部门</th><th>金额</th>"
f"</tr></thead><tbody>{body}</tbody></table>"
)
def _actions_html(rows: list[dict[str, Any]]) -> str:
if not rows:
return '<div class="muted">暂无需要升级的行动项。</div>'
return "".join(
(
f'<div class="insight"><strong>{_e(item.get("title"))}</strong>'
f'{_e(item.get("owner"))}<br>{_e(item.get("suggestion"))}</div>'
)
for item in rows
)
def _e(value: Any) -> str:
return html.escape(str(value or ""))
def _money(value: Any) -> str:
try:
return f"¥{float(value or 0):,.0f}"
except (TypeError, ValueError):
return "¥0"
def _trim(value: str, max_len: int) -> str:
return value if len(value) <= max_len else value[: max_len - 1] + ""
def _pdf_hex(value: str) -> str:
return value.encode("utf-16-be").hex().upper()

View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import os
import threading
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy import select
from app.core.agent_enums import AgentRunSource, AgentRunStatus
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.models.agent_run import AgentRun
from app.services.digital_employee_finance_report_task import (
FINANCE_REPORT_TASK_TYPE,
DigitalEmployeeFinanceReportTaskService,
)
logger = get_logger("app.services.finance_report_scheduler")
class FinanceReportScheduler:
def __init__(self) -> None:
timezone_name = str(os.environ.get("X_FINANCIAL_SCHEDULER_TZ") or "Asia/Shanghai").strip()
report_time = str(os.environ.get("X_FINANCIAL_FINANCE_REPORT_TIME") or "08:30").strip()
initial_delay = int(
os.environ.get("X_FINANCIAL_FINANCE_REPORT_INITIAL_DELAY_SECONDS") or "36"
)
self._timezone = ZoneInfo(timezone_name or "Asia/Shanghai")
self._report_time = self._parse_time(report_time)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="finance-report-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Finance report scheduler started timezone=%s report_time=%s",
self._timezone.key,
self._report_time.strftime("%H:%M"),
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Finance report scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
wait_seconds = self._seconds_until_next_report_time()
if self._stop_event.wait(wait_seconds):
break
self._run_due_reports()
def _run_due_reports(self) -> None:
now = datetime.now(self._timezone)
due_types = ["weekly"]
if now.day <= 7 and now.month in {1, 4, 7, 10}:
due_types.append("quarterly")
if now.day <= 7 and now.month == 1:
due_types.append("annual")
for report_type in due_types:
self._run_report_once(report_type=report_type, now=now)
def _run_report_once(self, *, report_type: str, now: datetime) -> None:
db = get_session_factory()()
try:
if self._already_generated(db, report_type=report_type, now=now):
return
result = DigitalEmployeeFinanceReportTaskService(db).generate_report(
report_type=report_type, # type: ignore[arg-type]
source=AgentRunSource.SCHEDULE.value,
)
db.commit()
logger.info(
"Finance report generated type=%s status=%s",
report_type,
(result.get("delivery") or {}).get("status"),
)
except Exception:
db.rollback()
logger.exception("Scheduled finance report failed type=%s", report_type)
finally:
db.close()
def _already_generated(self, db, *, report_type: str, now: datetime) -> bool:
day_start = datetime.combine(
now.date(),
time.min,
tzinfo=self._timezone,
).astimezone(ZoneInfo("UTC"))
day_end = day_start + timedelta(days=1)
stmt = (
select(AgentRun)
.where(AgentRun.started_at >= day_start)
.where(AgentRun.started_at < day_end)
.where(AgentRun.status == AgentRunStatus.SUCCEEDED.value)
)
for run in db.scalars(stmt).all():
route_json = run.route_json or {}
if (
str(route_json.get("task_type") or "") == FINANCE_REPORT_TASK_TYPE
and str(route_json.get("report_type") or "") == report_type
):
return True
return False
def _seconds_until_next_report_time(self) -> float:
now = datetime.now(self._timezone)
target = datetime.combine(now.date(), self._report_time, tzinfo=self._timezone)
if target <= now:
target += timedelta(days=1)
return max(1.0, (target - now).total_seconds())
@staticmethod
def _parse_time(raw_value: str) -> time:
try:
hour_text, minute_text = str(raw_value or "").split(":", 1)
return time(
hour=max(0, min(int(hour_text), 23)),
minute=max(0, min(int(minute_text), 59)),
)
except Exception:
return time(hour=8, minute=30)
finance_report_scheduler = FinanceReportScheduler()

View File

@@ -12,6 +12,9 @@ from app.schemas.agent_asset import AgentAssetListItem, AgentAssetRead
from app.schemas.ontology import OntologyParseResult
from app.schemas.orchestrator import OrchestratorRequest
from app.schemas.user_agent import UserAgentRequest, UserAgentResponse
from app.services.digital_employee_finance_report_task import (
DigitalEmployeeFinanceReportTaskService,
)
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
from app.services.hermes_risk_scanner import HermesRiskScannerService
@@ -388,6 +391,11 @@ class OrchestratorExecutionEngine:
)
if task_type == "risk_clue_collect":
return self._execute_risk_clue_collect(run_id=run_id, context_json=context_json)
if task_type == "finance_report_orchestration":
return self._execute_finance_report_orchestration(
run_id=run_id,
context_json=context_json,
)
return None
def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome:
@@ -542,6 +550,56 @@ class OrchestratorExecutionEngine:
failed_tool_count=1 if degraded else 0,
)
def _execute_finance_report_orchestration(
self,
*,
run_id: str,
context_json: dict[str, Any],
) -> ExecutionOutcome:
report_type = str(context_json.get("report_type") or "weekly").strip().lower()
if report_type not in {"weekly", "quarterly", "annual"}:
report_type = "weekly"
summary, degraded = self._invoke_tool(
run_id=run_id,
tool_type=AgentToolType.DATABASE.value,
tool_name="digital_employee.finance_report.orchestrate",
request_json={
"task_type": "finance_report_orchestration",
"report_type": report_type,
},
context_json=context_json,
executor=lambda: DigitalEmployeeFinanceReportTaskService(self.db).generate_report(
report_type=report_type, # type: ignore[arg-type]
send_email=bool(context_json.get("send_email", True)),
dry_run_email=bool(context_json.get("dry_run_email", False)),
source=AgentRunSource.SCHEDULE.value,
run_id=run_id,
record_tool_call=False,
),
fallback_factory=lambda exc: {
"message": f"财务报告生成失败,已保留失败记录:{exc}",
"degraded": True,
},
)
message = (
str(summary.get("message") or "").strip()
or "财务报告编排完成:"
f"{summary.get('title', '财务经营报告')}"
f"邮件状态 {(summary.get('delivery') or {}).get('status', 'skipped')}"
)
return ExecutionOutcome(
status=AgentRunStatus.SUCCEEDED.value,
result={
"message": message,
"report_type": "finance_report_orchestration",
"summary": summary,
"degraded": degraded,
},
degraded=degraded,
tool_count=1,
failed_tool_count=1 if degraded else 0,
)
@staticmethod
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
if task_asset is None:

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from app.algorithem.risk_graph import RiskHistoryStats, RiskObservationDraft
from app.db.base import Base
@@ -326,6 +326,7 @@ class RiskObservationService:
since = datetime.now(UTC) - timedelta(days=window_days)
stmt = (
select(RiskObservation)
.options(joinedload(RiskObservation.claim))
.where(RiskObservation.created_at >= since)
.order_by(RiskObservation.created_at.desc())
.limit(limit)

View File

@@ -178,6 +178,11 @@ class UserAgentApplicationMixin:
step = self._resolve_expense_application_step(payload, facts)
application_claim = None
if step == "submitted":
editable_claim = self._find_editable_expense_application_record(payload)
if editable_claim is not None:
application_claim = self._update_expense_application_record(payload, facts, editable_claim)
facts["application_edit_mode"] = "true"
else:
application_claim = self._find_duplicate_expense_application_record(payload, facts)
if application_claim is not None:
step = "duplicate"
@@ -229,9 +234,14 @@ class UserAgentApplicationMixin:
if step == "submitted":
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
submitted_title = (
"申请单据已修改并重新提交,已进入审批流程。"
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
else "申请单据已生成,并已进入审批流程。"
)
return "\n\n".join(
[
"申请单据已生成,并已进入审批流程。",
submitted_title,
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
f"申请单号:{application_no}",
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
@@ -930,6 +940,101 @@ class UserAgentApplicationMixin:
return "会务费用申请"
return "差旅费用申请"
@staticmethod
def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
if not isinstance(context_json, dict):
return ""
is_edit_mode = bool(context_json.get("application_edit_mode") or context_json.get("applicationEditMode"))
claim_id = str(
context_json.get("application_edit_claim_id")
or context_json.get("applicationEditClaimId")
or ""
).strip()
return claim_id if is_edit_mode and claim_id else ""
@staticmethod
def _is_expense_application_claim_like(claim: ExpenseClaim) -> bool:
expense_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper()
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
flags = []
has_application_detail = any(
isinstance(flag, dict)
and (
str(flag.get("business_stage") or "").strip() == "expense_application"
or isinstance(flag.get("application_detail"), dict)
)
for flag in flags
)
return (
expense_type in {"application", "expense_application"}
or expense_type.endswith("_application")
or claim_no.startswith("AP-")
or claim_no.startswith("APP-")
or has_application_detail
)
def _find_editable_expense_application_record(
self,
payload: UserAgentRequest,
) -> ExpenseClaim | None:
claim_id = self._resolve_application_edit_claim_id(payload.context_json or {})
if not claim_id:
return None
claim = self.db.get(ExpenseClaim, claim_id)
if claim is None:
raise ValueError("未找到要修改的申请单。")
if not self._is_expense_application_claim_like(claim):
raise ValueError("只能修改申请单。")
current_user = self._build_application_current_user(payload)
access_policy = ExpenseClaimAccessPolicy(self.db)
if not (current_user.is_admin or access_policy.is_claim_owned_by_current_user(claim, current_user)):
raise ValueError("只能修改本人被退回的申请单。")
status = str(claim.status or "").strip().lower()
if status not in {"returned", "draft", "supplement"}:
raise ValueError("当前申请单状态不支持修改。")
return claim
def _update_expense_application_record(
self,
payload: UserAgentRequest,
facts: dict[str, str],
claim: ExpenseClaim,
) -> ExpenseClaim:
current_user = self._build_application_current_user(payload)
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
flags = []
preserved_flags = [
flag
for flag in flags
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "application_detail"
)
]
claim.expense_type = self._resolve_application_expense_type_code(facts)
claim.reason = str(facts.get("reason") or "费用申请").strip() or "费用申请"
claim.location = str(facts.get("location") or "待补充").strip() or "待补充"
claim.amount = self._parse_application_amount_to_decimal(facts.get("amount", ""))
claim.occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)]
from app.services.expense_claims import ExpenseClaimService
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
if submitted is None:
raise ValueError("未找到可修改的申请单。")
return submitted
def _create_expense_application_record(
self,
payload: UserAgentRequest,

View File

@@ -0,0 +1,22 @@
---
name: digital-employee-reminder-scanner
description: 按计划扫描审批、预算、差旅申请和逾期报销,生成数字员工提醒事项。
---
# 定时提醒与待办扫描
## 功能说明
该技能面向后台数字员工运行,用于把财务流程中需要人工跟进的事项沉淀为提醒记录,支持后续通知中心、审批提醒和预算编制提醒。
## 输入范围
- 待审批报销单和审批节点。
- 预算编制周期、预算管理员和预算状态。
- 差旅申请有效期、关联报销状态和逾期情况。
## 输出要求
- 输出提醒人数、提醒事项数和提醒类型分布。
- 标记对应业务对象,便于前端跳转到审批、预算、报销或风险详情。
- 只生成提醒和待办,不代替负责人完成审批或预算编制。

View File

@@ -0,0 +1,22 @@
---
name: finance-dashboard-snapshot-analyst
description: 按计划统计报销、预算、费用结构和高额单据,刷新财务看板缓存并沉淀经营指标。
---
# 财务经营快照沉淀
## 功能说明
该技能面向后台数字员工运行,按固定周期读取报销单、费用明细、预算快照和员工画像,生成财务看板所需的经营指标缓存。
## 输入范围
- 报销单、费用明细和付款状态。
- 部门、个人、费用类型和预算维度。
- 员工画像与历史费用基线。
## 输出要求
- 输出当期报销金额、报销单数、预算使用率和费用结构。
- 输出高额单据、高费用个人和重点部门的排行摘要。
- 只沉淀事实和指标,不直接修改预算、规则或审批结论。

View File

@@ -0,0 +1,46 @@
---
name: finance-report-orchestrator
description: 按周、季、年整合费用、预算、风险、画像和提醒结果,生成图文 PDF 财务报告,并按系统邮箱设置投递给财务管理人员。
---
# 财务报告编排与邮件投递
## 定位
本技能是数字员工的后台报告编排能力,不负责审批、付款、预算调整或规则发布。
它只读取已沉淀的数据,形成管理层可阅读的财务报告。
## 输入
- 财务看板快照。
- 报销单、费用明细和高额单据。
- 预算池、预算流水和预算预占。
- 风险观察、风险反馈和待复核线索。
- 员工行为画像。
- 定时提醒扫描结果。
- 系统设置中的 SMTP 和默认收件人。
## 输出
- 周报、季报或年报 PDF。
- HTML 报告副本。
- 邮件投递状态。
- 数字员工运行记录和工具调用记录。
- 管理摘要、关键指标和行动清单。
## 工作原则
- 结论先行:先给金额、预算、风险和行动建议。
- 图表辅助:趋势、排行、预算和风险尽量用图形表达。
- 口径一致:金额、单数、预算和状态沿用财务看板口径。
- 可追踪PDF 路径、收件人、发送状态和失败原因必须写入运行记录。
- 可降级SMTP 未配置时只生成报告,不阻断任务。
## 边界
- 不自动修改单据状态。
- 不自动调整预算。
- 不生成或发布风险规则。
- 不向未配置的真实邮箱发送邮件。
- 发送失败只记录状态和建议,不重复轰炸收件人。

View File

@@ -182,7 +182,21 @@ def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume()
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
)
earliest_claim_day = db.scalar(
select(func.min(ExpenseClaim.occurred_at)).where(
ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")
)
)
latest_claim_day = db.scalar(
select(func.max(ExpenseClaim.occurred_at)).where(
ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")
)
)
assert admin_claim_count == 0
assert visible_claim_count is not None
assert 400 <= visible_claim_count <= 500
assert earliest_claim_day is not None
assert latest_claim_day is not None
assert earliest_claim_day.date() >= date(2026, 1, 1)
assert latest_claim_day.date() <= date(2026, 6, 2)

View File

@@ -157,3 +157,92 @@ def test_digital_employee_dashboard_keeps_empty_payload_without_fake_data() -> N
assert dashboard.totals["totalRuns"] == 0
assert dashboard.daily_work
assert dashboard.task_distribution == []
def test_digital_employee_dashboard_counts_finance_dashboard_snapshots() -> None:
now = datetime.now(UTC)
with build_session() as db:
db.add(
AgentRun(
run_id="run-finance-snapshot-001",
agent="hermes",
source="schedule",
user_id="digital_employee",
status="succeeded",
route_json={"task_type": "finance_dashboard_snapshot"},
result_summary="finance dashboard snapshot generated",
started_at=now - timedelta(minutes=3),
finished_at=now - timedelta(minutes=2),
tool_calls=[
AgentToolCall(
run_id="run-finance-snapshot-001",
tool_type="database",
tool_name="digital_employee.finance_dashboard.snapshot",
request_json={"task_type": "finance_dashboard_snapshot"},
response_json={
"summary": {
"finance_snapshot_count": 1,
"reimbursement_count": 534,
}
},
status="succeeded",
duration_ms=1200,
created_at=now - timedelta(minutes=3),
)
],
)
)
db.commit()
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
assert dashboard.totals["financeDashboardSnapshots"] == 1
assert dashboard.totals["businessOutputs"] == 1
assert dashboard.daily_work[-1]["financeDashboardSnapshots"] == 1
assert dashboard.task_distribution[0]["taskType"] == "finance_dashboard_snapshot"
def test_digital_employee_dashboard_counts_reminder_outputs() -> None:
now = datetime.now(UTC)
with build_session() as db:
db.add(
AgentRun(
run_id="run-reminder-scan-001",
agent="hermes",
source="schedule",
user_id="digital_employee",
status="succeeded",
route_json={"task_type": "digital_employee_reminder_scan"},
result_summary="reminder scan generated",
started_at=now - timedelta(minutes=3),
finished_at=now - timedelta(minutes=2),
tool_calls=[
AgentToolCall(
run_id="run-reminder-scan-001",
tool_type="database",
tool_name="digital_employee.reminder.scan",
request_json={"task_type": "digital_employee_reminder_scan"},
response_json={
"summary": {
"recipient_count": 3,
"reminder_count": 8,
"approval_pending_count": 2,
}
},
status="succeeded",
duration_ms=900,
created_at=now - timedelta(minutes=3),
)
],
)
)
db.commit()
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
assert dashboard.totals["reminders"] == 8
assert dashboard.totals["businessOutputs"] == 8
assert dashboard.daily_work[-1]["reminders"] == 8
assert dashboard.task_distribution[0]["taskType"] == "digital_employee_reminder_scan"

View File

@@ -0,0 +1,176 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.digital_employee_reminder_task import DigitalEmployeeReminderTaskService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_digital_employee_reminder_task_generates_actionable_report() -> None:
now = datetime(2026, 6, 2, 2, 0, tzinfo=UTC)
with build_session() as db:
_seed_reminder_data(db, now)
result = DigitalEmployeeReminderTaskService(db).refresh_reminders(now=now)
summary = result["summary"]
report = result["report"]
assert result["task_type"] == "digital_employee_reminder_scan"
assert summary["recipient_count"] >= 3
assert summary["reminder_count"] >= 4
assert summary["approval_pending_count"] == 1
assert summary["budget_reminder_count"] == 1
assert summary["travel_application_reminder_count"] == 1
assert summary["reimbursement_overdue_count"] == 1
reminder_types = {
reminder["type"]
for recipient in report["recipients"]
for reminder in recipient["reminders"]
}
assert {
"approval_pending",
"budget_compilation",
"travel_application_expiry",
"reimbursement_overdue",
}.issubset(reminder_types)
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
assert dashboard.totals["reminders"] >= 4
assert dashboard.totals["businessOutputs"] >= 4
assert dashboard.task_distribution[0]["taskType"] == "digital_employee_reminder_scan"
def _seed_reminder_data(db: Session, now: datetime) -> None:
budget_role = Role(
id="role-budget",
role_code="budget_monitor",
name="预算管理员",
description="预算编制提醒接收人",
)
manager = Employee(
id="emp-manager",
employee_no="M001",
name="审批领导",
email="manager@example.com",
position="部门负责人",
grade="M2",
)
employee = Employee(
id="emp-user",
employee_no="E001",
name="出差员工",
email="employee@example.com",
position="客户经理",
grade="P5",
manager=manager,
finance_owner_name="财务BP",
)
budget_admin = Employee(
id="emp-budget",
employee_no="B001",
name="预算管理员甲",
email="budget@example.com",
position="预算管理员",
grade="P6",
roles=[budget_role],
)
db.add_all([budget_role, manager, employee, budget_admin])
db.add_all(
[
_claim(
"claim-approval",
"EXP-APPROVAL-001",
employee,
"travel",
"12000.00",
now - timedelta(days=3),
"submitted",
"直属领导审批",
),
_claim(
"claim-travel-app",
"APP-TRAVEL-001",
employee,
"travel_application",
"8000.00",
now - timedelta(days=1),
"approved",
"已审批",
risk_flags=[
{
"source": "application_detail",
"application_detail": {
"application_type": "差旅申请",
"time": "2026-06-01",
},
}
],
),
_claim(
"claim-supplement",
"EXP-SUPPLEMENT-001",
employee,
"meal",
"600.00",
now - timedelta(days=2),
"returned",
"材料待补",
),
]
)
db.commit()
def _claim(
claim_id: str,
claim_no: str,
employee: Employee,
expense_type: str,
amount: str,
happened_at: datetime,
status: str,
approval_stage: str,
*,
risk_flags: list[dict] | None = None,
) -> ExpenseClaim:
return ExpenseClaim(
id=claim_id,
claim_no=claim_no,
employee_id=employee.id,
employee_name=employee.name,
department_name="市场部",
expense_type=expense_type,
reason="客户拜访",
location="上海",
amount=Decimal(amount),
invoice_count=1,
occurred_at=happened_at,
submitted_at=happened_at,
status=status,
approval_stage=approval_stage,
risk_flags_json=risk_flags or [],
created_at=happened_at,
updated_at=happened_at,
)

View File

@@ -5,14 +5,17 @@ from typing import Any
from app.core.agent_enums import AgentName
from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE,
DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE,
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP,
)
from app.services.agent_foundation_digital_employee_tasks import (
AgentFoundationDigitalEmployeeTaskMixin,
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
AgentFoundationDigitalEmployeeTaskMixin,
)
@@ -56,11 +59,17 @@ def test_digital_employee_skill_catalog_has_complete_categories_and_packages() -
categories = [str(spec["skill_category"]) for spec in specs]
skill_names = [str(dict(spec["config"])["skill_name"]) for spec in specs]
assert len(specs) == 16
assert len(specs) == 19
assert len(set(codes)) == len(codes)
assert set(categories) == set(DIGITAL_EMPLOYEE_SKILL_CATEGORIES)
assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE] == "积累"
assert len(set(codes + [DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE])) == 17
assert (
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE]
== "整理"
)
assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE] == "升级"
assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE] == "整理"
assert len(set(codes + [DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE])) == 20
for skill_name in ["finance-policy-knowledge-organizer", *skill_names]:
skill_file = _skill_root() / skill_name / "SKILL.md"
@@ -114,6 +123,9 @@ def test_digital_employee_skills_do_not_cross_rule_governance_boundary() -> None
)
assert "risk-clue-collector" in skill_names
assert "finance-dashboard-snapshot-analyst" in skill_names
assert "digital-employee-reminder-scanner" in skill_names
assert "finance-report-orchestrator" in skill_names
assert "rule-execution-case-organizer" in skill_names
assert "policy-reference-gap-hinter" in skill_names
assert "risk-rule-discovery" not in skill_names

View File

@@ -3541,12 +3541,63 @@ def test_direct_manager_cannot_delete_application_claim() -> None:
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="申请单只有系统管理员可以删除"):
with pytest.raises(ValueError, match="只有草稿、待补充或退回待提交状态的单据"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_applicant_can_delete_returned_application_claim() -> None:
current_user = CurrentUserContext(
username="zhangsan-application-return-delete@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
employee = Employee(
employee_no="E-APP-DEL-RETURN",
name="张三",
email="zhangsan-application-return-delete@example.com",
)
db.add(employee)
db.flush()
claim = ExpenseClaim(
claim_no="APP-DEL-RETURN-101",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="travel_application",
reason="差旅申请",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="returned",
approval_stage="待提交",
risk_flags_json=[
{
"source": "manual_return",
"event_type": "expense_application_return",
"message": "请补充出差事由",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert deleted.claim_no == "APP-DEL-RETURN-101"
assert db.get(ExpenseClaim, claim_id) is None
def test_admin_can_delete_application_claim() -> None:
current_user = CurrentUserContext(
username="superadmin",

View File

@@ -8,10 +8,12 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.agent_run import AgentRun
from app.models.budget import BudgetAllocation, BudgetTransaction
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.services.finance_dashboard import FinanceDashboardService
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
def build_session() -> Session:
@@ -165,12 +167,16 @@ def test_finance_dashboard_service_aggregates_claim_budget_and_payment_data() ->
assert dashboard.totals["reimbursementCount"] == 2
assert dashboard.totals["reimbursementAmount"] == 2000.0
assert dashboard.totals["pendingPaymentAmount"] == 0.0
assert dashboard.trend["applications"][-1] >= 1
assert sum(dashboard.trend["applications"]) >= 1
assert "AP-DASH-ADMIN-001" not in str(dashboard.trend)
assert dashboard.spend_by_category[0]["value"] == 1200.0
assert dashboard.department_ranking[0]["name"] == "财务部"
assert dashboard.department_ranking[0]["amount"] == 1200.0
assert dashboard.department_ranking[0]["employeeCount"] == 1
assert dashboard.department_employee_mix[0]["name"] == "财务部 · 陈雨晴"
assert dashboard.department_employee_mix[0]["amount"] == 1200.0
assert dashboard.employee_ranking[0]["name"] == "陈雨晴"
assert dashboard.employee_ranking[0]["count"] == 1
assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001"
assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims)
assert dashboard.budget_summary["ratio"] == 40.0
@@ -226,7 +232,7 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N
ExpenseClaim(
claim_no="CLM-DASH-LABEL-003",
employee_name="reimbursement-user",
department_name="甯傚満閮?,
department_name="Market",
expense_type="travel",
reason="real travel reimbursement",
location="Shanghai",
@@ -327,10 +333,150 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N
assert dashboard.trend["claimCount"][-1] == 1
assert dashboard.trend["claimAmount"][-1] == 700.0
assert dashboard.trend["applications"] == dashboard.trend["claimCount"]
assert dashboard.department_ranking[0]["name"] == "市场部"
assert dashboard.department_ranking[0]["name"] == "Market"
assert dashboard.department_ranking[0]["amount"] == 700.0
assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names)
assert "风险金额" not in focus_names
assert "材料待补" not in focus_names
assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks)
assert len(dashboard.budget_metrics) == 6
def test_finance_dashboard_ranking_range_supports_year_and_all_scope() -> None:
now = datetime.now(UTC)
previous_year_time = now - timedelta(days=420)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="CLM-RANGE-CURRENT-001",
employee_name="王明",
department_name="销售部",
expense_type="travel",
reason="本年差旅",
location="北京",
amount=Decimal("1000.00"),
invoice_count=1,
occurred_at=now - timedelta(days=5),
submitted_at=now - timedelta(days=5),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(days=5),
updated_at=now - timedelta(days=4),
),
ExpenseClaim(
claim_no="CLM-RANGE-CURRENT-002",
employee_name="赵琳",
department_name="销售部",
expense_type="meal",
reason="本年招待",
location="上海",
amount=Decimal("500.00"),
invoice_count=1,
occurred_at=now - timedelta(days=8),
submitted_at=now - timedelta(days=8),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(days=8),
updated_at=now - timedelta(days=7),
),
ExpenseClaim(
claim_no="CLM-RANGE-OLD-001",
employee_name="钱远",
department_name="销售部",
expense_type="office",
reason="历史办公",
location="广州",
amount=Decimal("9000.00"),
invoice_count=1,
occurred_at=previous_year_time,
submitted_at=previous_year_time,
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=previous_year_time,
updated_at=previous_year_time,
),
]
)
db.commit()
year_dashboard = FinanceDashboardService(db).build_dashboard(
range_key="近10日",
trend_range="近7天",
department_range="本年",
)
all_dashboard = FinanceDashboardService(db).build_dashboard(
range_key="近10日",
trend_range="近7天",
department_range="全部",
)
assert year_dashboard.department_ranking[0]["amount"] == 1500.0
assert year_dashboard.department_ranking[0]["employeeCount"] == 2
assert "CLM-RANGE-OLD-001" not in str(year_dashboard.top_claims)
assert {item["employee"] for item in year_dashboard.department_employee_mix} == {
"王明",
"赵琳",
}
assert all_dashboard.department_ranking[0]["amount"] == 10500.0
assert all_dashboard.department_ranking[0]["employeeCount"] == 3
assert all_dashboard.top_claims[0]["claimNo"] == "CLM-RANGE-OLD-001"
assert all_dashboard.department_employee_mix[0]["employee"] == "钱远"
def test_finance_dashboard_snapshot_service_persists_digital_employee_snapshot() -> None:
now = datetime.now(UTC)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="CLM-SNAPSHOT-001",
employee_name="snapshot-user",
department_name="Finance",
expense_type="travel",
reason="snapshot test",
location="Shanghai",
amount=Decimal("880.00"),
invoice_count=1,
occurred_at=now - timedelta(hours=1),
submitted_at=now - timedelta(minutes=50),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(hours=1),
updated_at=now - timedelta(minutes=40),
)
)
db.commit()
service = FinanceDashboardSnapshotService(db)
first = service.build_dashboard(
range_key="近30日",
trend_range="近12天",
department_range="本月",
)
second = service.build_dashboard(
range_key="近30日",
trend_range="近12天",
department_range="本月",
)
runs = [
run
for run in db.query(AgentRun).filter(AgentRun.agent == "hermes").all()
if (run.route_json or {}).get("task_type") == "finance_dashboard_snapshot"
]
assert first.totals["reimbursementCount"] == 1
assert second.generated_at == first.generated_at
assert len(runs) == 1
assert runs[0].status == "succeeded"
assert runs[0].route_json["task_type"] == "finance_dashboard_snapshot"
assert runs[0].route_json["snapshot_payload"]["totals"]["reimbursementAmount"] == 880.0

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import get_settings
from app.db.base import Base
from app.models.agent_run import AgentRun
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.services.digital_employee_finance_report_task import (
FINANCE_REPORT_TASK_TYPE,
DigitalEmployeeFinanceReportTaskService,
)
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_finance_report_task_generates_pdf_and_agent_record(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path))
get_settings.cache_clear()
now = datetime.now(UTC)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="RE-REPORT-001",
employee_name="林嘉宁",
department_name="市场部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("3600.00"),
invoice_count=2,
occurred_at=now - timedelta(days=2),
submitted_at=now - timedelta(days=2),
status="paid",
approval_stage="已付款",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(days=2),
updated_at=now - timedelta(days=1),
)
)
db.add(
RiskObservation(
observation_key="risk-report-001",
subject_type="expense_claim",
subject_key="RE-REPORT-001",
subject_label="RE-REPORT-001",
claim_no="RE-REPORT-001",
risk_type="policy",
risk_signal="amount_outlier",
title="金额异常",
risk_level="high",
status="pending_review",
created_at=now - timedelta(days=1),
updated_at=now - timedelta(days=1),
)
)
db.commit()
result = DigitalEmployeeFinanceReportTaskService(db).generate_report(
report_type="weekly",
send_email=True,
dry_run_email=True,
)
pdf_path = tmp_path / result["pdf"]["storage_key"]
html_path = pdf_path.with_name("report.html")
runs = [
run
for run in db.query(AgentRun).filter(AgentRun.agent == "hermes").all()
if (run.route_json or {}).get("task_type") == FINANCE_REPORT_TASK_TYPE
]
assert pdf_path.exists()
assert pdf_path.read_bytes().startswith(b"%PDF")
assert html_path.exists()
assert result["delivery"]["status"] in {"dry_run", "pending_configuration"}
assert result["summary"]["reimbursement_count"] >= 1
assert runs
assert runs[0].status == "succeeded"
assert runs[0].route_json["report_delivery"]["pdf"]["storage_key"].endswith("report.pdf")
get_settings.cache_clear()

View File

@@ -12,6 +12,8 @@ from app.models.employee import Employee
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.employee_profile_scan_task import EmployeeProfileScanTaskService
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
@@ -35,6 +37,24 @@ def test_hermes_employee_profile_scan_returns_profile_baseline_summary() -> None
)
def test_employee_profile_scan_task_records_digital_employee_run() -> None:
session_factory = _build_session_factory()
with session_factory() as db:
_seed_scan_data(db)
result = EmployeeProfileScanTaskService(db).refresh_profiles()
summary = result["summary"]
assert result["task_type"] == "employee_behavior_profile_scan"
assert summary["target_employee_count"] == 3
assert summary["snapshot_count"] >= 12
assert db.query(EmployeeBehaviorProfileSnapshot).count() >= 12
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
assert dashboard.totals["profileSnapshots"] >= 12
assert dashboard.task_distribution[0]["taskType"] == "employee_behavior_profile_scan"
def _build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",

View File

@@ -693,6 +693,92 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
assert second_response.draft_payload is None
def test_user_agent_application_edit_resubmits_returned_application_claim() -> None:
session_factory = build_session_factory()
with session_factory() as db:
claim = ExpenseClaim(
id="application-edit-1",
claim_no="AP-20260220-EDIT",
employee_name="pytest",
department_name="技术部",
expense_type="travel_application",
reason="旧事由",
location="上海",
amount=Decimal("1000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
status="returned",
approval_stage="待提交",
risk_flags_json=[
{
"source": "manual_return",
"event_type": "expense_application_return",
"message": "请修改事由",
},
{
"source": "application_detail",
"application_detail": {
"reason": "旧事由",
"time": "2026-02-20 至 2026-02-23",
},
},
],
)
db.add(claim)
db.commit()
response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={
"manager_name": "向万红",
"application_edit_mode": True,
"application_edit_claim_id": claim.id,
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-02-20 至 2026-02-23",
"location": "上海市",
"reason": "支撑国网仿生产环境建设",
"days": "4天",
"transportMode": "火车",
"amount": "4660元",
"grade": "P5",
"department": "技术部",
"position": "财务智能化产品经理",
"managerName": "向万红",
}
},
},
)
db.refresh(claim)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "申请单据已修改并重新提交" in response.answer
assert response.draft_payload is not None
assert response.draft_payload.claim_id == claim.id
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert claim.reason == "支撑国网仿生产环境建设"
assert claim.location == "上海市"
assert claim.amount == Decimal("4660.00")
assert claim.occurred_at.date().isoformat() == "2026-02-20"
flags = list(claim.risk_flags_json or [])
assert any(flag.get("event_type") == "expense_application_return" for flag in flags)
assert any(flag.get("event_type") == "expense_application_submission" for flag in flags)
detail_flags = [
flag.get("application_detail")
for flag in flags
if isinstance(flag, dict) and flag.get("source") == "application_detail"
]
assert len(detail_flags) == 1
assert detail_flags[0]["reason"] == "支撑国网仿生产环境建设"
assert detail_flags[0]["transport_mode"] == "火车"
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
session_factory = build_session_factory()
with session_factory() as db:

View File

@@ -123,7 +123,6 @@
z-index: 1;
}
.todo-row,
.progress-row {
position: relative;
border-top: 0;
@@ -131,12 +130,10 @@
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
}
.todo-row:first-child,
.progress-row:first-child {
box-shadow: none;
}
.todo-row:hover,
.progress-row:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)),

View File

@@ -63,7 +63,7 @@
}
.assistant-copy {
width: min(1040px, 92%);
width: min(940px, 92%);
}
.assistant-copy h1 {
@@ -71,11 +71,11 @@
}
.capability-grid--privileged {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.capability-grid--standard {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.capability-card {
@@ -87,7 +87,7 @@
}
.workbench-content-grid {
grid-template-columns: minmax(300px, 0.92fr) minmax(480px, 1.34fr) minmax(270px, 0.76fr);
grid-template-columns: minmax(480px, 1.34fr) minmax(270px, 0.76fr);
gap: 14px;
}
@@ -202,25 +202,15 @@
grid-template-columns: 1fr;
}
.todo-row,
.progress-row {
grid-template-columns: 1fr;
justify-items: start;
}
.todo-row {
grid-template-columns: 48px minmax(0, 1fr);
}
.todo-meta,
.progress-result {
justify-items: start;
}
.todo-meta {
grid-column: 2;
}
.progress-steps {
width: 100%;
}
@@ -332,30 +322,6 @@
font-size: 11px;
}
/* 我的待办列表项更精致 */
.todo-row {
padding: 5px 0;
gap: 6px;
}
.todo-copy strong {
font-size: 12.5px;
}
.todo-copy small {
font-size: 11px;
}
.todo-status {
font-size: 11px;
min-height: 18px;
padding: 0 5px;
}
.todo-meta small {
font-size: 10.5px;
}
/* 重点优化费用进度行的网格区域Grid Area双行重构 */
.progress-row {
display: grid;

View File

@@ -97,7 +97,7 @@
.assistant-copy {
position: relative;
z-index: 3;
width: min(1120px, 94%);
width: min(980px, 94%);
display: grid;
gap: var(--hero-copy-gap);
}
@@ -130,6 +130,7 @@
z-index: 5;
display: grid;
gap: 6px;
max-width: 920px;
min-height: var(--composer-min-height);
padding: var(--composer-padding-block) 18px 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
@@ -416,7 +417,7 @@
.workbench-content-grid {
display: grid;
grid-template-columns: minmax(360px, 0.95fr) minmax(560px, 1.4fr) minmax(320px, 0.82fr);
grid-template-columns: minmax(560px, 1.45fr) minmax(320px, 0.82fr);
gap: 14px;
align-items: stretch;
min-height: 0;
@@ -434,7 +435,6 @@
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
}
.todo-panel,
.progress-panel,
.side-panel {
display: grid;
@@ -493,7 +493,6 @@
color: var(--workbench-muted);
}
.todo-list,
.progress-list {
display: grid;
min-height: 0;
@@ -501,45 +500,11 @@
grid-auto-rows: minmax(0, 1fr);
}
.todo-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
width: 100%;
padding: 2px 0;
border-top: 1px solid var(--workbench-line-soft);
text-align: left;
}
.todo-row:first-child,
.progress-row:first-child {
padding-top: 2px;
border-top: 0;
}
.todo-row :deep(.workbench-list-icon) {
width: 28px;
height: 28px;
}
.todo-row :deep(.workbench-list-icon__panel) {
border-radius: 4px;
}
.todo-row :deep(.workbench-list-icon__art),
.todo-row :deep(.workbench-heroicon) {
width: 16px;
height: 16px;
}
.todo-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.todo-copy strong,
.progress-identity strong,
.progress-result strong {
overflow: hidden;
@@ -551,8 +516,6 @@
white-space: nowrap;
}
.todo-copy small,
.todo-meta small,
.progress-identity small {
overflow: hidden;
color: var(--workbench-muted);
@@ -562,14 +525,6 @@
white-space: nowrap;
}
.todo-meta {
min-width: 96px;
display: grid;
justify-items: end;
gap: 2px;
}
.todo-status,
.progress-status {
display: inline-flex;
align-items: center;
@@ -582,33 +537,16 @@
white-space: nowrap;
}
.todo-status--warning,
.progress-status--warning {
background: var(--warning-soft);
color: var(--warning);
}
.todo-status--success,
.progress-status--success {
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
}
.todo-status--danger {
background: var(--danger-soft);
color: var(--danger);
}
.todo-status--info {
background: var(--info-soft);
color: var(--info);
}
.todo-status--orange {
background: var(--warning-soft);
color: var(--warning);
}
.progress-status--muted {
background: var(--info-soft);
color: var(--workbench-muted);
@@ -708,7 +646,6 @@
}
.capability-card:hover,
.todo-row:hover,
.progress-row:hover,
.quick-prompts button:hover,
.composer-icon-button:hover {

View File

@@ -403,6 +403,11 @@
color: var(--theme-primary-active);
}
.notification-wrap {
position: relative;
display: inline-flex;
}
.notification-badge {
position: absolute;
top: 2px;
@@ -423,6 +428,179 @@
box-shadow: 0 5px 10px rgba(239, 68, 68, .22);
}
.notification-popover {
position: absolute;
top: calc(100% + 10px);
right: -8px;
z-index: 60;
width: min(360px, calc(100vw - 32px));
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #e5edf5;
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
}
.notification-popover::before {
content: "";
position: absolute;
top: -6px;
right: 18px;
width: 10px;
height: 10px;
border-top: 1px solid #e5edf5;
border-left: 1px solid #e5edf5;
background: #fff;
transform: rotate(45deg);
}
.notification-head,
.notification-tabs {
position: relative;
z-index: 1;
display: flex;
align-items: center;
}
.notification-head {
justify-content: space-between;
gap: 10px;
}
.notification-head strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.notification-head button {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 4px;
color: #64748b;
}
.notification-head button:hover {
background: #f1f5f9;
color: #0f172a;
}
.notification-tabs {
gap: 6px;
padding: 3px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.notification-tabs button {
flex: 1 1 0;
height: 28px;
border-radius: 3px;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.notification-tabs button.active {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.notification-list {
position: relative;
z-index: 1;
display: grid;
max-height: 320px;
overflow: auto;
}
.notification-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) 16px;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-top: 1px solid #edf2f7;
text-align: left;
}
.notification-row:first-child {
border-top: 0;
}
.notification-row:hover {
background: #f8fafc;
}
.notification-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--theme-primary);
}
.notification-dot.danger { background: #ef4444; }
.notification-dot.warning { background: #f59e0b; }
.notification-dot.success { background: var(--success); }
.notification-dot.info { background: #3b82f6; }
.notification-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.notification-copy strong,
.notification-copy small,
.notification-copy em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-copy strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.notification-copy small {
color: #475569;
font-size: 12px;
}
.notification-copy em {
color: #94a3b8;
font-size: 11px;
font-style: normal;
}
.notification-row > .mdi {
color: #94a3b8;
font-size: 16px;
}
.notification-empty {
min-height: 112px;
display: grid;
place-items: center;
gap: 8px;
color: #94a3b8;
font-size: 13px;
}
.notification-empty .mdi {
font-size: 24px;
}
.company-switcher {
max-width: min(220px, 28vw);
height: 38px;
@@ -593,6 +771,61 @@
.title-group {
padding-right: 56px;
}
.topbar.detail-mode {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 10px 14px;
}
.topbar.detail-mode .title-group {
min-width: 0;
padding-right: 50px;
}
.topbar.detail-mode .eyebrow {
display: none;
}
.topbar.detail-mode h1 {
font-size: 22px;
line-height: 1.12;
}
.topbar.detail-mode p {
max-width: 100%;
margin-top: 3px;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-size: 12px;
line-height: 1.35;
}
.topbar.detail-mode .top-actions,
.topbar.detail-mode .detail-topbar-actions {
width: 100%;
min-width: 0;
justify-content: flex-end;
}
.topbar.detail-mode .detail-alert-strip {
width: auto;
max-width: 100%;
min-width: 0;
justify-content: flex-end;
gap: 6px;
}
.topbar.detail-mode .detail-alert-pill {
min-height: 26px;
max-width: 100%;
padding: 0 9px;
border-radius: 4px;
font-size: 11px;
}
}
@media (max-width: 640px) {
@@ -676,3 +909,22 @@
grid-template-columns: 1fr;
}
}
@media (max-width: 420px) {
.topbar.detail-mode {
gap: 6px;
padding: 8px 12px;
}
.topbar.detail-mode h1 {
font-size: 20px;
}
.topbar.detail-mode p {
font-size: 11.5px;
}
.topbar.detail-mode .detail-alert-pill {
min-height: 24px;
}
}

View File

@@ -142,19 +142,16 @@
.trend-count-panel,
.donut-panel,
.rank-panel,
.employee-rank-panel,
.top-claim-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel,
.model-panel,
.feedback-panel {
grid-column: span 3;
}
.bottleneck-panel,
.rank-panel,
.employee-rank-panel,
.top-claim-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 6;
}
@@ -188,6 +185,21 @@
width: 110px;
}
.card-range-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), .18);
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), .07);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.panel-note {
margin-top: 8px;
color: #64748b;
@@ -581,6 +593,42 @@
.top-claim-list {
display: grid;
gap: 10px;
align-content: start;
}
.top-claim-split {
flex: 1;
display: grid;
grid-template-columns: minmax(260px, .92fr) minmax(0, 1.08fr);
gap: 18px;
align-items: stretch;
min-height: 0;
}
.department-employee-mix {
min-width: 0;
padding-right: 18px;
border-right: 1px solid #f1f5f9;
}
.department-employee-mix :deep(.donut-chart) {
min-height: 100%;
}
.department-employee-mix :deep(.donut-body) {
height: 150px;
}
.department-employee-mix :deep(.donut-legend) {
grid-template-columns: 1fr;
gap: 7px;
}
.department-employee-mix :deep(.legend-name) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-claim-row {
@@ -873,6 +921,17 @@
grid-template-columns: 24px 64px minmax(0, 1fr);
}
.top-claim-split {
grid-template-columns: 1fr;
}
.department-employee-mix {
padding-right: 0;
padding-bottom: 14px;
border-right: 0;
border-bottom: 1px solid #f1f5f9;
}
.budget-metric-grid {
grid-template-columns: 1fr;
}

View File

@@ -0,0 +1,69 @@
@media (max-width: 760px) {
.approval-page,
.approval-detail,
.detail-scroll,
.detail-hero,
.progress-card,
.detail-grid,
.detail-left,
.detail-card {
width: 100%;
min-width: 0;
max-width: 100%;
}
.detail-scroll {
padding-right: 0;
overflow-x: hidden;
}
.detail-scroll > * {
min-width: 0;
max-width: 100%;
}
.hero-banner-main,
.hero-fact-grid,
.applicant-card,
.detail-card-head {
min-width: 0;
max-width: 100%;
}
.progress-card,
.progress-block {
min-width: 0;
max-width: 100%;
}
.progress-line {
width: 100%;
min-width: 0;
max-width: 100%;
}
.detail-expense-table {
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.detail-actions {
width: 100%;
min-width: 0;
max-width: 100%;
}
.approval-action-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
gap: 8px;
}
.back-action,
.approval-action-group > button {
width: 100%;
min-width: 0;
}
}

View File

@@ -704,6 +704,7 @@
.ai-preview-secondary:disabled,
.ai-preview-primary:disabled,
.approve-action:disabled,
.secondary-action:disabled,
.return-action:disabled,
.ai-send-btn:disabled {
opacity: .45;

View File

@@ -1645,6 +1645,13 @@
color: #ef4444;
}
.secondary-action {
min-width: 98px;
border: 1px solid #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.return-action {
min-width: 98px;
border: 1px solid #fed7aa;

View File

@@ -16,7 +16,6 @@
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pageNumbers"
:show-page-size="true"
:summary="paginationSummary"
:total="visibleSkills.length"
@@ -326,14 +325,6 @@ const pagedSkills = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return props.visibleSkills.slice(start, start + pageSize.value)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
)

View File

@@ -10,7 +10,6 @@
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pageNumbers"
:show-page-size="true"
:summary="paginationSummary"
:total="visibleEmployees.length"
@@ -225,14 +224,6 @@ const pagedEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return props.visibleEmployees.slice(start, start + pageSize.value)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)

View File

@@ -110,6 +110,26 @@
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察</p>
</section>
<section v-else-if="productKind === 'finance_snapshot'" class="run-product-section">
<div class="run-product-section-head">
<h4>财务经营快照</h4>
<span>{{ summary.period || summary.month || '本期' }}</span>
</div>
<p class="run-product-copy">
本次产物已刷新财务看板缓存沉淀报销金额预算使用费用结构和高额单据等经营指标
</p>
</section>
<section v-else-if="productKind === 'reminder_scan'" class="run-product-section">
<div class="run-product-section-head">
<h4>提醒与待办沉淀</h4>
<span>{{ summary.reminder_count || summary.reminders || 0 }} </span>
</div>
<p class="run-product-copy">
本次产物已生成审批提醒预算编制提醒报销逾期提醒和差旅申请闭环提醒
</p>
</section>
<section v-else-if="productKind === 'risk_clue'" class="run-product-section">
<div class="run-product-section-head">
<h4>待复核线索</h4>
@@ -230,6 +250,12 @@ const productSubtitle = computed(() => {
if (productKind.value === 'risk_graph') {
return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。'
}
if (productKind.value === 'finance_snapshot') {
return '展示本次财务经营快照沉淀的预算、费用和报销统计。'
}
if (productKind.value === 'reminder_scan') {
return '展示本次定时提醒扫描生成的待办和触达结果。'
}
if (productKind.value === 'employee_profile') {
return '展示本次画像巡检写入的员工画像快照摘要。'
}
@@ -245,6 +271,12 @@ const productBadge = computed(() => {
if (productKind.value === 'risk_graph') {
return '风险观察'
}
if (productKind.value === 'finance_snapshot') {
return '财务快照'
}
if (productKind.value === 'reminder_scan') {
return '提醒事项'
}
if (productKind.value === 'employee_profile') {
return '画像快照'
}
@@ -281,6 +313,25 @@ const metrics = computed(() => {
buildMetric('图谱关系', payload.graph_edge_count)
]
}
if (productKind.value === 'finance_snapshot') {
return [
buildMetric('报销单数', payload.claim_count ?? payload.claims ?? payload.total_claims),
buildMetric(
'报销金额',
formatMoney(payload.claim_amount ?? payload.reimbursement_amount ?? payload.total_amount)
),
buildMetric('预算使用率', formatPercent(payload.budget_usage_rate ?? payload.budget_rate)),
buildMetric('高额单据', payload.high_value_claim_count ?? payload.high_amount_claims)
]
}
if (productKind.value === 'reminder_scan') {
return [
buildMetric('提醒人数', payload.recipient_count),
buildMetric('提醒事项', payload.reminder_count),
buildMetric('待审批', payload.approval_pending_count),
buildMetric('逾期报销', payload.reimbursement_overdue_count)
]
}
if (productKind.value === 'employee_profile') {
return [
buildMetric('目标员工', payload.target_employee_count),
@@ -376,6 +427,23 @@ function formatWindowDays(value) {
return days.length ? days.map((item) => `${item}`).join(' / ') : '-'
}
function formatMoney(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return '-'
}
return `¥${amount.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`
}
function formatPercent(value) {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) {
return '-'
}
const percent = numericValue > 1 ? numericValue : numericValue * 100
return `${Math.round(percent)}%`
}
function observationGraphCount(item) {
return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length
}

View File

@@ -14,7 +14,6 @@
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:pages="pageNumbers"
:show-page-size="false"
:summary="paginationSummary"
:total="filteredRuns.length"
@@ -303,6 +302,7 @@ import {
import {
formatWorkRecordDateTime,
formatWorkRecordSummary,
compactDigitalEmployeeWorkRecords,
resolveWorkRecordModuleLabel,
resolveWorkRecordSourceLabel,
resolveWorkRecordStatusLabel,
@@ -456,14 +456,6 @@ const visibleRuns = computed(() => {
return filteredRuns.value.slice(start, start + pageSize)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)
@@ -523,7 +515,7 @@ async function loadWorkRecords(showToast = false) {
try {
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
runs.value = Array.isArray(payload) ? payload : []
runs.value = Array.isArray(payload) ? compactDigitalEmployeeWorkRecords(payload) : []
emit('summary-change', {
total: workRecordSummary.value.total,
succeeded: workRecordSummary.value.succeeded,

View File

@@ -191,41 +191,6 @@
</div>
<div class="workbench-content-grid">
<article class="panel workbench-card todo-panel">
<div class="section-head">
<div class="title-with-badge">
<h2>我的待办</h2>
<span class="soft-badge">{{ todoAlertCount }}</span>
</div>
<button type="button" class="link-action">全部待办 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="todo-list">
<button
v-for="item in visibleTodoItems"
:key="item.title"
type="button"
class="todo-row"
@click="openPromptAssistant(`帮我处理:${item.title}${item.description}`)"
>
<WorkbenchListIcon
:icon-key="item.iconKey"
:color="item.color"
:accent="item.accent"
/>
<span class="todo-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small>
</span>
<span class="todo-meta">
<span class="todo-status" :class="`todo-status--${item.statusTone}`">{{ item.status }}</span>
<small>{{ item.due }}</small>
</span>
</button>
</div>
</article>
<article class="panel workbench-card progress-panel">
<div class="section-head">
<h2>费用进度</h2>
@@ -238,7 +203,7 @@
:key="item.id"
type="button"
class="progress-row"
@click="openPromptAssistant(`查询 ${item.id} 的费用进度`)"
@click="openWorkbenchTarget(item)"
>
<span class="progress-identity">
<strong>{{ item.id }}</strong>
@@ -247,17 +212,17 @@
<span class="progress-steps" aria-hidden="true">
<span
v-for="(step, index) in progressSteps"
:key="step"
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': index < item.activeStep,
'is-current': index === item.activeStep,
'is-future': index > item.activeStep
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="index <= item.activeStep ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step }}</small>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
@@ -357,7 +322,6 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -365,11 +329,8 @@ import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposer
import {
buildExpenseStatItems,
filterAssistantCapabilitiesForUser,
progressItems,
progressSteps,
quickPromptItems,
resolveWorkbenchCapabilityGridClass,
todoItems,
} from '../../data/personalWorkbench.js'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
@@ -394,7 +355,7 @@ const props = defineProps({
workbenchSummary: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['open-assistant'])
const emit = defineEmits(['open-assistant', 'open-document'])
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
@@ -494,9 +455,12 @@ const currentUserProfileKey = computed(() => {
user.employee_no
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
})
const visibleTodoItems = computed(() => todoItems.slice(0, 5))
const visibleProgressItems = computed(() => progressItems.slice(0, 5))
const todoAlertCount = computed(() => visibleTodoItems.value.length)
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.workbenchSummary.progressItems)
? props.workbenchSummary.progressItems
: []
return rows.slice(0, 5)
})
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
@@ -625,6 +589,20 @@ function openPromptAssistant(prompt) {
emitAssistant(payload)
}
function openWorkbenchTarget(item) {
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('open-document', {
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo
})
return
}
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`)
}
function openCapabilityAssistant(item) {
if (pendingAction.value) {
return

View File

@@ -9,7 +9,10 @@
</svg>
<template v-else>{{ idx + 1 }}</template>
</span>
<span class="rank-copy">
<span class="rank-name">{{ item.name || item.shortName }}</span>
<small v-if="item.meta" class="rank-meta">{{ item.meta }}</small>
</span>
</div>
</div>
<div ref="chartElement" class="chart-area" role="img" :aria-label="ariaLabel"></div>
@@ -90,7 +93,11 @@ const chartOptions = computed(() => ({
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => `${params.marker}${params.name}: ${formatValue(params.value)}`
formatter: (params) => {
const item = resolvedItems.value.find((row) => (row.name || row.shortName) === params.name)
const meta = item?.meta ? `<br/>${item.meta}` : ''
return `${params.marker}${params.name}: ${formatValue(params.value)}${meta}`
}
},
xAxis: {
type: 'value',
@@ -180,7 +187,8 @@ const formatValue = (value) => {
}
.rank-labels {
flex: 0 0 auto;
flex: 0 0 min(34%, 150px);
min-width: 112px;
display: flex;
flex-direction: column;
justify-content: space-around;
@@ -214,10 +222,24 @@ const formatValue = (value) => {
display: block;
}
.rank-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.rank-name {
color: #475569;
font-size: 13px;
font-weight: 500;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
}
.rank-meta {
color: #94a3b8;
font-size: 11px;
font-weight: 650;
}
.chart-area {

View File

@@ -1,9 +1,14 @@
<template>
<section class="risk-observation-dashboard">
<section class="risk-observation-dashboard" :class="{ 'is-loading': loading }">
<div v-if="loading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ loadingLabel }}</span>
</div>
<article class="panel dashboard-card risk-trend-panel">
<div class="card-head">
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
<div class="risk-window-controls">
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
<span class="risk-window-label"> {{ dashboard.windowDays }} </span>
<EnterpriseSelect
class="risk-window-select"
@@ -174,11 +179,29 @@ const props = defineProps({
signalRanking: { type: Array, default: () => [] },
dailyRows: { type: Array, default: () => [] },
windowOptions: { type: Array, default: () => [] },
activeWindowDays: { type: Number, default: 30 }
activeWindowDays: { type: Number, default: 30 },
lastUpdatedAt: { type: String, default: '' }
})
const emit = defineEmits(['update:windowDays'])
const router = useRouter()
const loadingLabel = computed(() => (
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
))
const lastUpdatedLabel = computed(() => {
if (!props.lastUpdatedAt) {
return ''
}
const date = new Date(props.lastUpdatedAt)
if (Number.isNaN(date.getTime())) {
return ''
}
return `上次同步 ${date.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})}`
})
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
const dimensionGroups = computed(() => [
@@ -315,12 +338,39 @@ function openClaim(item) {
<style scoped>
.risk-observation-dashboard {
position: relative;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 18px;
min-width: 0;
}
.risk-dashboard-loading-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: rgba(248, 250, 252, .82);
color: #334155;
font-size: 13px;
font-weight: 800;
backdrop-filter: blur(2px);
}
.risk-dashboard-loading-overlay i {
color: var(--theme-primary);
font-size: 26px;
}
.risk-observation-dashboard.is-loading .dashboard-card {
pointer-events: none;
}
.dashboard-card {
min-width: 0;
padding: 18px;
@@ -359,6 +409,13 @@ function openClaim(item) {
font-weight: 700;
}
.risk-refresh-label {
flex: 0 0 auto;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.risk-window-controls {
flex: 0 0 auto;
display: flex;

View File

@@ -1,5 +1,5 @@
<template>
<header class="topbar" :class="{ 'chat-mode': isChat }">
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
<div class="title-group">
<div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1>
@@ -123,11 +123,72 @@
<template v-else-if="isWorkbench">
<div class="topbar-toolset" aria-label="工作台快捷工具">
<button class="topbar-icon-btn notification-btn" type="button" aria-label="通知">
<div class="notification-wrap">
<button
class="topbar-icon-btn notification-btn"
type="button"
aria-label="通知"
:aria-expanded="notificationOpen"
aria-haspopup="dialog"
@click="notificationOpen = !notificationOpen"
>
<i class="mdi mdi-bell-outline"></i>
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
</button>
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
<header class="notification-head">
<strong>通知</strong>
<button type="button" aria-label="关闭通知" @click="notificationOpen = false">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="notification-tabs" role="tablist" aria-label="通知状态">
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'unread'"
:class="{ active: notificationTab === 'unread' }"
@click="notificationTab = 'unread'"
>
未读 {{ unreadNotifications.length }}
</button>
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'read'"
:class="{ active: notificationTab === 'read' }"
@click="notificationTab = 'read'"
>
已读 {{ readNotifications.length }}
</button>
</div>
<div v-if="activeNotifications.length" class="notification-list">
<button
v-for="item in activeNotifications"
:key="item.id"
type="button"
class="notification-row"
@click="openNotification(item)"
>
<span class="notification-dot" :class="item.tone"></span>
<span class="notification-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small>
<em>{{ item.time }}</em>
</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div v-else class="notification-empty">
<i class="mdi mdi-bell-check-outline"></i>
<span>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</span>
</div>
</div>
</div>
<button class="topbar-icon-btn" type="button" aria-label="帮助">
<i class="mdi mdi-help-circle-outline"></i>
</button>
@@ -243,6 +304,10 @@ const props = defineProps({
type: Object,
default: () => null
},
workbenchSummary: {
type: Object,
default: () => null
},
companyName: {
type: String,
default: ''
@@ -276,7 +341,8 @@ const emit = defineEmits([
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication'
'newApplication',
'openDocument'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
@@ -294,10 +360,34 @@ const eyebrowLabel = computed(() => (
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const topbarNotificationCount = computed(() => {
const summary = props.documentSummary ?? {}
const count = Number(summary.toProcess ?? summary.toSubmit ?? 8)
const summary = props.workbenchSummary ?? {}
const count = Number(summary.unreadNotificationCount ?? 0)
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0
})
const notificationOpen = ref(false)
const notificationTab = ref('unread')
const notificationItems = computed(() => (
Array.isArray(props.workbenchSummary?.notifications)
? props.workbenchSummary.notifications
: []
))
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
function openNotification(item) {
notificationOpen.value = false
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('openDocument', {
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo
})
}
}
const requestKpis = computed(() => {
const summary = props.requestSummary ?? {}

View File

@@ -99,7 +99,6 @@
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pages"
:show-page-size="showPageSize"
:summary="summary"
:total="total"
@@ -139,10 +138,6 @@ const props = defineProps({
default: () => []
},
panel: { type: Boolean, default: true },
pages: {
type: Array,
default: () => []
},
retryLabel: { type: String, default: '重新加载' },
searchable: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '搜索' },

View File

@@ -78,10 +78,6 @@ const props = defineProps({
type: Array,
default: () => []
},
pages: {
type: Array,
default: () => []
},
showPageSize: { type: Boolean, default: true },
summary: { type: String, default: '' },
total: { type: Number, default: 0 },
@@ -106,7 +102,7 @@ const summaryText = computed(() => {
return props.summary
}
return `${props.total} 条,当前第 ${props.currentPage}`
return `${props.total} 条,当前第 ${props.currentPage} / ${props.totalPages}`
})
function setPage(page) {
@@ -142,3 +138,140 @@ watch(
}
)
</script>
<style scoped>
.list-foot.enterprise-pagination {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 16px;
margin-top: 12px;
}
.enterprise-pagination .page-summary {
min-width: 0;
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.enterprise-pagination .pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.enterprise-pagination .pager button {
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 3px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.enterprise-pagination .pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
}
.enterprise-pagination .pager button.active {
background: var(--theme-primary);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.enterprise-pagination .pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.enterprise-pagination .page-ellipsis {
width: 28px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 13px;
font-weight: 850;
}
.enterprise-pagination .page-tools {
justify-self: end;
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.enterprise-pagination .page-size-select {
width: 112px;
}
.enterprise-pagination .page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
color: #64748b;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.enterprise-pagination .page-jump input {
width: 54px;
height: 32px;
padding: 0 8px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
font-weight: 800;
text-align: center;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.enterprise-pagination .page-jump input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none;
}
@media (max-width: 760px) {
.list-foot.enterprise-pagination {
grid-template-columns: 1fr;
justify-items: stretch;
}
.enterprise-pagination .pager {
width: 100%;
max-width: 100%;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: thin;
}
.enterprise-pagination .pager button,
.enterprise-pagination .page-ellipsis {
flex: 0 0 auto;
}
.enterprise-pagination .page-tools {
justify-self: stretch;
justify-content: space-between;
flex-wrap: wrap;
}
}
</style>

View File

@@ -34,12 +34,15 @@ export function useAppShell() {
conversation: null,
scope: null,
sessionType: '',
budgetContext: null
budgetContext: null,
initialPromptAutoSubmit: true,
initialApplicationPreview: null
})
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const selectedRequestSnapshot = ref(null)
const documentCenterRefreshToken = ref(0)
const { activeView, currentView, setView } = useNavigation()
const {
@@ -98,12 +101,19 @@ export function useAppShell() {
: []
))
const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))
async function reloadDocumentCenterRequests() {
documentCenterRefreshToken.value += 1
return reloadRequests()
}
watch(
requestsNeeded,
(isNeeded) => {
if (isNeeded) {
() => [activeView.value, route.name],
([view]) => {
if (view === 'documents') {
void reloadDocumentCenterRequests()
return
}
if (view === 'workbench') {
void ensureRequestsLoaded()
}
},
@@ -168,7 +178,14 @@ export function useAppShell() {
function handleNavigate(view) {
smartEntryOpen.value = false
const shouldRefreshCurrentDocumentCenter =
view === 'documents'
&& activeView.value === 'documents'
&& route.name === 'app-documents'
setView(view)
if (shouldRefreshCurrentDocumentCenter) {
void reloadDocumentCenterRequests()
}
}
function openFinancialAssistantCreate(source) {
@@ -185,7 +202,9 @@ export function useAppShell() {
conversation: null,
scope: null,
sessionType: '',
budgetContext: null
budgetContext: null,
initialPromptAutoSubmit: true,
initialApplicationPreview: null
}
smartEntrySessionId.value += 1
}
@@ -320,6 +339,7 @@ export function useAppShell() {
|| String(payload?.prompt || '').trim()
|| (Array.isArray(payload?.files) && payload.files.length)
|| payload?.conversation
|| payload?.applicationPreview
)
if (smartEntryOpen.value && !shouldReplaceOpenEntry) {
smartEntryRevealToken.value += 1
@@ -342,6 +362,10 @@ export function useAppShell() {
sessionType,
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
? payload.budgetContext
: null,
initialPromptAutoSubmit: payload.initialPromptAutoSubmit !== false,
initialApplicationPreview: payload.applicationPreview && typeof payload.applicationPreview === 'object'
? payload.applicationPreview
: null
}
smartEntrySessionId.value += 1
@@ -410,6 +434,7 @@ export function useAppShell() {
currentView,
customRange,
detailMode,
documentCenterRefreshToken,
filteredRequests,
filters,
handleApprove,
@@ -429,6 +454,7 @@ export function useAppShell() {
requestsError,
requestsLoading,
reloadRequests,
reloadDocumentCenterRequests,
requests,
search,
selectedRequest,

View File

@@ -1,4 +1,4 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
fetchDigitalEmployeeDashboard,
@@ -92,6 +92,9 @@ export function useOverviewView(options = {}) {
const riskDashboardPayload = ref(null)
const riskDashboardLoading = ref(false)
const riskDashboardError = ref(null)
const riskDashboardLastUpdatedAt = ref('')
let riskDashboardRefreshTimer = 0
let riskDashboardRequestSeq = 0
const digitalEmployeeDashboardPayload = ref(null)
const digitalEmployeeDashboardLoading = ref(false)
const digitalEmployeeDashboardError = ref(null)
@@ -178,21 +181,52 @@ export function useOverviewView(options = {}) {
}
const loadRiskDashboard = async () => {
const requestSeq = ++riskDashboardRequestSeq
riskDashboardLoading.value = true
riskDashboardError.value = null
try {
riskDashboardPayload.value = await fetchRiskObservationDashboard({
const payload = await fetchRiskObservationDashboard({
windowDays: activeRiskWindowDays.value,
limit: 500
})
if (requestSeq !== riskDashboardRequestSeq) {
return
}
riskDashboardPayload.value = payload
riskDashboardLastUpdatedAt.value = new Date().toISOString()
} catch (error) {
if (requestSeq !== riskDashboardRequestSeq) {
return
}
riskDashboardPayload.value = null
riskDashboardError.value = error
} finally {
if (requestSeq === riskDashboardRequestSeq) {
riskDashboardLoading.value = false
}
}
}
const startRiskDashboardRealtimeRefresh = () => {
if (riskDashboardRefreshTimer) {
window.clearInterval(riskDashboardRefreshTimer)
}
riskDashboardRefreshTimer = window.setInterval(() => {
if (document.visibilityState === 'hidden' || riskDashboardLoading.value) {
return
}
void loadRiskDashboard()
}, 30_000)
}
const stopRiskDashboardRealtimeRefresh = () => {
if (!riskDashboardRefreshTimer) {
return
}
window.clearInterval(riskDashboardRefreshTimer)
riskDashboardRefreshTimer = 0
}
const loadDigitalEmployeeDashboard = async () => {
digitalEmployeeDashboardLoading.value = true
@@ -222,6 +256,11 @@ export function useOverviewView(options = {}) {
void loadSystemDashboard()
void loadRiskDashboard()
void loadDigitalEmployeeDashboard()
startRiskDashboardRealtimeRefresh()
})
onBeforeUnmount(() => {
stopRiskDashboardRealtimeRefresh()
})
watch(
@@ -323,6 +362,9 @@ export function useOverviewView(options = {}) {
const financeDepartmentRanking = computed(() => (
financeDashboardPayload.value?.departmentRanking || []
))
const financeDepartmentEmployeeMix = computed(() => (
financeDashboardPayload.value?.departmentEmployeeMix || emptyFinanceDonut
))
const financeEmployeeRanking = computed(() => (
financeDashboardPayload.value?.employeeRanking || []
))
@@ -501,7 +543,11 @@ export function useOverviewView(options = {}) {
const activeTrend = computed(() => financeTrend.value)
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const riskTotal = computed(() => financeExceptionMix.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const departmentEmployeeTotal = computed(() => (
financeDepartmentEmployeeMix.value.reduce((sum, item) => sum + Number(item.value || item.amount || 0), 0)
))
const spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
const departmentEmployeeCenterValue = computed(() => formatCurrency(Math.round(departmentEmployeeTotal.value)))
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
...item,
@@ -513,6 +559,14 @@ export function useOverviewView(options = {}) {
display: `${item.value}`
})))
const departmentEmployeeLegend = computed(() => financeDepartmentEmployeeMix.value.map((item) => ({
...item,
value: Number(item.value || item.amount || 0),
display: departmentEmployeeTotal.value
? `${Math.round((Number(item.value || item.amount || 0) / departmentEmployeeTotal.value) * 100)}%`
: '0%'
})))
const systemToolTotal = computed(() =>
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
)
@@ -542,6 +596,7 @@ export function useOverviewView(options = {}) {
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
meta: `${Number(item.employeeCount || 0)} 人 / ${Number(item.count || 0)}`,
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
@@ -561,6 +616,7 @@ export function useOverviewView(options = {}) {
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
meta: `${item.department || '未归属部门'} / ${Number(item.count || 0)}`,
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
@@ -738,6 +794,8 @@ export function useOverviewView(options = {}) {
bottlenecks,
budgetMetrics,
budgetSummary,
departmentEmployeeCenterValue,
departmentEmployeeLegend,
departmentRangeOptions,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
@@ -760,6 +818,7 @@ export function useOverviewView(options = {}) {
rankedEmployees,
riskDashboard,
riskDashboardError,
riskDashboardLastUpdatedAt,
riskDashboardLoading,
riskDailyTrendRows,
riskLegend,

View File

@@ -163,7 +163,7 @@ export const exceptionMix = [
{ name: '已入账', value: 9, color: 'var(--chart-blue)' }
]
export const departmentRangeOptions = ['本周', '本月', '本季度']
export const departmentRangeOptions = ['本月', '本季度', '本年', '全部']
export const bottlenecks = [
{

View File

@@ -20,6 +20,7 @@ const FINANCE_DASHBOARD_FALLBACK = {
spendByCategory: null,
exceptionMix: null,
departmentRanking: null,
departmentEmployeeMix: null,
employeeRanking: null,
topClaims: null,
bottlenecks: null,
@@ -69,6 +70,7 @@ function normalizeFinanceDashboardPayload(payload = {}) {
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
departmentEmployeeMix: payload.department_employee_mix || payload.departmentEmployeeMix || null,
employeeRanking: payload.employee_ranking || payload.employeeRanking || null,
topClaims: payload.top_claims || payload.topClaims || null,
bottlenecks: payload.bottlenecks || null,
@@ -129,7 +131,7 @@ export async function fetchFinanceDashboard(options = {}) {
if (options.endDate) search.set('end_date', String(options.endDate))
const payload = await apiRequest(`/analytics/finance-dashboard?${search.toString()}`, {
timeoutMs: Number(options.timeoutMs || 3500),
timeoutMs: Number(options.timeoutMs || 10000),
timeoutMessage: '财务看板真实数据加载超时,已保留本地展示数据。'
})

View File

@@ -121,6 +121,10 @@ function resolveDaysFromDateRange(rangeText) {
return diffDays >= 0 ? `${diffDays + 1}` : ''
}
export function resolveApplicationDaysFromDateRange(rangeText) {
return resolveDaysFromDateRange(rangeText)
}
function resolvePreviewToday(options = {}) {
const explicitToday = String(options.today || options.currentDate || '').trim()
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)

View File

@@ -0,0 +1,119 @@
export const TRAVEL_PLANNING_ACTION_GENERATE = 'generate_travel_application_plan'
export const TRAVEL_PLANNING_ACTION_SKIP = 'skip_travel_application_plan'
function normalizeText(value) {
return String(value || '').trim()
}
function isTravelApplication(applicationType = '') {
return /差旅|出差/.test(normalizeText(applicationType))
}
function extractDateParts(timeText = '') {
const dates = normalizeText(timeText).match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
return {
startDate: dates[0] || '',
endDate: dates[dates.length - 1] || dates[0] || ''
}
}
function resolveTravelPlanningContext(preview = {}, draftPayload = {}) {
const fields = preview?.fields && typeof preview.fields === 'object' ? preview.fields : {}
const applicationType = normalizeText(fields.applicationType)
if (!isTravelApplication(applicationType)) {
return null
}
const location = normalizeText(fields.location)
const time = normalizeText(fields.time)
if (!location || !time) {
return null
}
const dates = extractDateParts(time)
return {
applicationType,
location,
time,
startDate: dates.startDate,
endDate: dates.endDate,
days: normalizeText(fields.days),
transportMode: normalizeText(fields.transportMode),
reason: normalizeText(fields.reason),
claimNo: normalizeText(draftPayload?.claim_no || draftPayload?.claimNo)
}
}
export function buildTravelPlanningNudgeMessage(preview = {}, draftPayload = {}) {
const context = resolveTravelPlanningContext(preview, draftPayload)
if (!context) {
return ''
}
const timeCopy = context.startDate && context.endDate && context.startDate !== context.endDate
? `${context.startDate}${context.endDate}`
: context.time
const transportCopy = context.transportMode ? `${context.transportMode}时间窗口` : '、交通方式比选'
return [
`本次${context.location}差旅申请已经提交。`,
`如果你愿意,我可以继续按 ${timeCopy} 帮你整理一版行程规划,包括出发/返程${transportCopy}、酒店区域建议和还需要确认的事项。`
].join('\n')
}
export function buildTravelPlanningSuggestedActions(preview = {}, draftPayload = {}) {
const context = resolveTravelPlanningContext(preview, draftPayload)
if (!context) {
return []
}
return [
{
label: '生成行程规划',
action_type: TRAVEL_PLANNING_ACTION_GENERATE,
description: '按本次申请的地点和时间给出交通、酒店和待确认事项。',
icon: 'mdi mdi-map-clock-outline',
emphasis: 'primary',
payload: {
context
}
},
{
label: '暂不需要',
action_type: TRAVEL_PLANNING_ACTION_SKIP,
description: '保留申请结果,不继续生成规划。',
icon: 'mdi mdi-check-outline',
payload: {
context
}
}
]
}
export function buildTravelPlanningRecommendation(preview = {}, draftPayload = {}) {
const context = resolveTravelPlanningContext(preview, draftPayload)
if (!context) {
return ''
}
const outboundDate = context.startDate || '出发当天'
const returnDate = context.endDate || '返回当天'
const transport = context.transportMode || '火车/飞机'
const reasonLine = context.reason ? `业务安排:${context.reason}` : '业务安排:以申请事由为准,出发前再确认具体到场时间。'
const hotelArea = `${context.location}核心办公区、客户现场周边或交通枢纽 30 分钟通勤范围内`
const claimLine = context.claimNo ? `关联申请单:${context.claimNo}` : ''
return [
'可以,先给你一版轻量行程规划,后续你可以继续补充偏好。',
'',
claimLine,
`行程时间:${context.time}${context.days ? `${context.days}` : ''}`,
reasonLine,
'',
`交通建议:${outboundDate} 优先看上午到中午抵达 ${context.location}${transport}班次,预留到达后 1.5 小时交通和现场准备时间;${returnDate} 优先看下午或晚间返程,避免压缩最后一天工作安排。`,
`酒店建议:优先选择${hotelArea},同时关注可开发票、可取消、早餐和离现场距离。`,
'需要确认:出发城市、客户现场地址、是否需要同行人、是否有指定住宿协议酒店、是否需要提前准备会议室或网络环境。',
'',
'你也可以继续告诉我出发城市、偏好的交通方式或预算,我再把规划细化成更具体的时间段。'
].filter(Boolean).join('\n')
}

View File

@@ -3,6 +3,10 @@ function parseNumber(value) {
return Number.isFinite(nextValue) ? nextValue : 0
}
function normalizeText(value) {
return String(value ?? '').trim()
}
function toDate(value) {
if (!value) {
return null
@@ -60,6 +64,174 @@ function formatCurrency(value) {
}).format(parseNumber(value))
}
function resolveRequestIdentity(request) {
return normalizeText(request?.claimNo || request?.claim_no || request?.id || request?.claimId)
}
function resolveRequestTarget(request) {
return {
type: 'document',
id: normalizeText(request?.claimId || request?.id),
claimNo: resolveRequestIdentity(request)
}
}
function resolveStatusTone(approvalKey) {
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
if (approvalKey === 'draft') return 'success'
if (approvalKey === 'pending_payment') return 'warning'
if (approvalKey === 'in_progress') return 'info'
return 'muted'
}
function resolveTodoAction(request) {
const approvalKey = normalizeText(request?.approvalKey)
const status = normalizeText(request?.status || request?.approvalStatus)
if (approvalKey === 'supplement' || approvalKey === 'rejected') {
return {
title: '补充或修改单据',
status: approvalKey === 'rejected' ? '退回修改' : '待补充',
statusTone: 'danger',
iconKey: 'receipts',
color: 'var(--danger)',
accent: 'var(--danger-soft)'
}
}
if (approvalKey === 'draft' || /draft|草稿|待提交/i.test(status)) {
return {
title: '提交草稿单据',
status: '待提交',
statusTone: 'success',
iconKey: 'travelDraft',
color: 'var(--theme-primary)',
accent: 'var(--theme-primary-soft)'
}
}
return null
}
function buildTodoItems(ownedRequests) {
return ownedRequests
.map((request) => {
const action = resolveTodoAction(request)
if (!action) {
return null
}
const requestId = resolveRequestIdentity(request)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || requestId
return {
...action,
id: requestId,
requestId,
title: action.title,
description: `${requestId || '单据'} · ${title || '费用单据'}`,
due: normalizeText(request?.updatedAt || request?.applyTime || request?.submittedAt) || '待处理',
target: resolveRequestTarget(request),
prompt: `帮我处理 ${requestId || title}${action.status}`
}
})
.filter(Boolean)
.sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due)))
}
function resolveProgressStatusTone(approvalKey) {
if (approvalKey === 'completed') return 'muted'
if (approvalKey === 'pending_payment') return 'warning'
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
return 'success'
}
function resolveCurrentProgressIndex(steps) {
const currentIndex = steps.findIndex((step) => step?.current)
if (currentIndex >= 0) {
return currentIndex
}
const activeIndex = steps.findLastIndex((step) => step?.active || step?.done)
return Math.max(0, activeIndex)
}
export function buildAdjacentProgressSteps(steps = [], windowSize = 4) {
const rows = Array.isArray(steps) ? steps : []
if (!rows.length) {
return []
}
const currentIndex = resolveCurrentProgressIndex(rows)
const safeWindowSize = Math.max(1, Number(windowSize) || 4)
let start = Math.max(0, currentIndex - 1)
let end = Math.min(rows.length, start + safeWindowSize)
if (end - start < safeWindowSize) {
start = Math.max(0, end - safeWindowSize)
}
return rows.slice(start, end).map((step) => ({
label: normalizeText(step.label || step.rawLabel),
done: Boolean(step.done),
current: Boolean(step.current),
title: normalizeText(step.title || step.time || step.detail)
}))
}
function buildProgressItems(ownedRequests) {
return ownedRequests
.filter((request) => Array.isArray(request?.progressSteps) && request.progressSteps.length)
.map((request) => {
const requestId = resolveRequestIdentity(request)
const steps = buildAdjacentProgressSteps(request.progressSteps, 4)
const currentStep = steps.find((step) => step.current)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
return {
id: requestId,
requestId,
title,
amount: formatCurrency(request?.amount),
status: normalizeText(request?.approvalStatus || currentStep?.label) || '处理中',
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
steps,
target: resolveRequestTarget(request),
prompt: `查询 ${requestId || title} 的费用进度`
}
})
.sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt)))
}
function buildNotifications(todoItems, progressItems) {
const todoNotifications = todoItems.map((item) => ({
id: `todo:${item.requestId || item.description}`,
title: item.status,
description: item.description,
time: item.due,
unread: true,
tone: item.statusTone,
target: item.target,
prompt: item.prompt
}))
const progressNotifications = progressItems
.filter((item) => ['danger', 'warning'].includes(item.statusTone))
.map((item) => ({
id: `progress:${item.requestId || item.title}`,
title: item.status,
description: `${item.requestId || '单据'} · ${item.title}`,
time: item.updatedAt || '最近更新',
unread: false,
tone: item.statusTone,
target: item.target,
prompt: item.prompt
}))
return [...todoNotifications, ...progressNotifications]
}
export function buildWorkbenchSummary(requests, currentUser) {
const ownedRequests = Array.isArray(requests)
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
@@ -79,6 +251,9 @@ export function buildWorkbenchSummary(requests, currentUser) {
const completedCount = ownedRequests.filter((item) => item.approvalKey === 'completed').length
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
const todoItems = buildTodoItems(ownedRequests)
const progressItems = buildProgressItems(ownedRequests)
const notifications = buildNotifications(todoItems, progressItems)
return {
monthlyCount,
@@ -91,6 +266,10 @@ export function buildWorkbenchSummary(requests, currentUser) {
pendingPaymentCount,
completedCount,
returnCount,
highRiskCount
highRiskCount,
todoItems,
progressItems,
notifications,
unreadNotificationCount: notifications.filter((item) => item.unread).length
}
}

View File

@@ -71,6 +71,7 @@
:knowledge-summary="knowledgeSummary"
:request-summary="requestSummary"
:document-summary="documentSummary"
:workbench-summary="workbenchSummary"
:digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="resolvedDetailMode"
@@ -84,6 +85,7 @@
@update:overview-dashboard="overviewDashboard = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument"
/>
<FilterBar
@@ -124,6 +126,7 @@
:assistant-modal-open="smartEntryOpen"
:workbench-summary="workbenchSummary"
@open-assistant="openSmartEntry"
@open-document="openWorkbenchDocument"
/>
<TravelRequestDetailView
@@ -142,6 +145,7 @@
:has-data="requests.length > 0"
:loading="requestsLoading"
:error="requestsError"
:refresh-token="documentCenterRefreshToken"
@open-document="openRequestDetail"
@create-request="openTravelCreate"
@create-application="openExpenseApplicationCreate"
@@ -188,6 +192,8 @@
:initial-conversation="smartEntryContext.conversation"
:initial-session-type="smartEntryContext.sessionType"
:initial-budget-context="smartEntryContext.budgetContext"
:initial-prompt-auto-submit="smartEntryContext.initialPromptAutoSubmit"
:initial-application-preview="smartEntryContext.initialApplicationPreview"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
@@ -274,6 +280,7 @@ const {
customRange,
detailAlerts,
detailMode,
documentCenterRefreshToken,
filteredRequests,
filters,
handleApprove,
@@ -292,6 +299,7 @@ const {
workbenchSummary,
requestsError,
requestsLoading,
reloadDocumentCenterRequests,
reloadRequests,
requests,
search,
@@ -351,6 +359,20 @@ const resolvedDetailKpis = computed(() => (
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
))
function openWorkbenchDocument(payload = {}) {
const requestId = String(payload.claimId || payload.id || payload.claimNo || '').trim()
if (!requestId) {
return
}
const request = requests.value.find((item) => (
String(item.claimId || '').trim() === requestId
|| String(item.id || '').trim() === requestId
|| String(item.claimNo || '').trim() === requestId
))
openRequestDetail(request || payload)
}
function handleLogout() {
logout('manual')
}

View File

@@ -196,35 +196,16 @@
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary">{{ pageSummary }}</span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="budgetPage === 1" aria-label="上一页" @click="goToBudgetPage(budgetPage - 1)">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in budgetPageNumbers"
:key="page"
class="page-number"
:class="{ active: budgetPage === page }"
type="button"
:aria-current="budgetPage === page ? 'page' : undefined"
@click="goToBudgetPage(page)"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="budgetPage === totalBudgetPages" aria-label="下一页" @click="goToBudgetPage(budgetPage + 1)">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-model="budgetPageSize"
class="page-size-select"
:options="budgetPageSizeOptions"
size="small"
@change="changeBudgetPageSize"
<EnterprisePagination
v-if="showTable"
:current-page="budgetPage"
:page-size="budgetPageSize"
:page-size-options="budgetPageSizeOptions"
:summary="pageSummary"
:total-pages="totalBudgetPages"
@page-size-change="changeBudgetPageSize"
@update:current-page="goToBudgetPage"
/>
</footer>
</article>
<EnterpriseDetailPage

View File

@@ -154,7 +154,8 @@ import { isPlatformAdminUser } from '../utils/accessControl.js'
import {
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
formatDigitalEmployeeCron,
isDigitalEmployeeAsset
isDigitalEmployeeAsset,
shouldDisplayDigitalEmployeeAsset
} from './scripts/auditViewDigitalEmployeeModel.js'
import {
buildDigitalEmployeeScheduleConfig,
@@ -336,7 +337,9 @@ async function loadEmployees() {
try {
const payload = await fetchAgentAssets({ assetType: 'task' })
const items = Array.isArray(payload)
? payload.filter(isDigitalEmployeeAsset).map(buildEmployeeListItem)
? payload
.filter((asset) => isDigitalEmployeeAsset(asset) && shouldDisplayDigitalEmployeeAsset(asset))
.map(buildEmployeeListItem)
: []
employees.value = sortEmployees(items)

View File

@@ -215,36 +215,23 @@
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ filteredRows.length }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="changePageSize" />
</footer>
<EnterprisePagination
v-if="showTable"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:summary="pageSummary"
:total-pages="totalPages"
@page-size-change="changePageSize"
@update:current-page="currentPage = $event"
/>
</article>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
@@ -318,7 +305,8 @@ const props = defineProps({
filteredRequests: { type: Array, required: true },
hasData: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
error: { type: String, default: '' },
refreshToken: { type: Number, default: 0 }
})
const emit = defineEmits([
'open-document',
@@ -463,6 +451,7 @@ const filteredRows = computed(() => {
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const pageSummary = computed(() => `${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
@@ -832,6 +821,15 @@ watch(documentSummary, (summary) => {
onMounted(() => {
void loadSupportingRows()
})
watch(
() => props.refreshToken,
(token, previousToken) => {
if (token && token !== previousToken) {
void loadSupportingRows()
}
}
)
</script>
<style scoped src="../assets/styles/components/document-list-shared.css"></style>

View File

@@ -657,47 +657,16 @@
</table>
</div>
<footer v-if="!loading && !errorMessage && totalCount" class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button
class="page-nav"
type="button"
:disabled="currentPage === 1"
aria-label="上一页"
@click="currentPage--"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button
class="page-nav"
type="button"
:disabled="currentPage === totalPages"
aria-label="下一页"
@click="currentPage++"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-model="pageSize"
class="page-size-select"
:options="pageSizeOptions"
size="small"
@change="changePageSize"
<EnterprisePagination
v-if="!loading && !errorMessage && totalCount"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:summary="pageSummary"
:total-pages="totalPages"
@page-size-change="changePageSize"
@update:current-page="currentPage = $event"
/>
</footer>
</article>
</Transition>

View File

@@ -14,9 +14,8 @@
:empty="!systemLogLoading && !visibleSystemLogEntries.length"
:total="totalCount"
:total-pages="totalPages"
:pages="visiblePageItems"
:page-size-options="pageSizeOptions"
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} 页`"
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} / ${totalPages} 页`"
:show-pagination="!systemLogLoading && filteredSystemLogEntries.length > 0"
loading-title="系统日志同步中"
loading-message="正在加载系统运行日志记录"

View File

@@ -69,7 +69,7 @@
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行费用金额<i class="mdi mdi-information-outline"></i></h3>
<h3>部门报销排行 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
@@ -84,7 +84,14 @@
<article class="panel dashboard-card employee-rank-panel">
<div class="card-head">
<h3>个人报销排行本月<i class="mdi mdi-information-outline"></i></h3>
<h3>个人报销排行 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
:options="departmentRangeOptions"
aria-label="个人排行时间范围"
size="small"
/>
</div>
<BarChart :items="rankedEmployees" />
@@ -92,7 +99,17 @@
<article class="panel dashboard-card top-claim-panel">
<div class="card-head">
<h3>本月高额单据 <i class="mdi mdi-information-outline"></i></h3>
<h3>高额单据 <i class="mdi mdi-information-outline"></i></h3>
<span class="card-range-chip">{{ activeDepartmentRange }}</span>
</div>
<div class="top-claim-split">
<div class="department-employee-mix">
<DonutChart
:items="departmentEmployeeLegend"
:center-value="departmentEmployeeCenterValue"
center-label="人员占比"
/>
</div>
<div class="top-claim-list">
@@ -111,6 +128,7 @@
</div>
</div>
</div>
</div>
</article>
<article class="panel dashboard-card budget-metrics-panel">
@@ -158,6 +176,7 @@
:dashboard="riskDashboard"
:loading="riskDashboardLoading"
:error="riskDashboardError"
:last-updated-at="riskDashboardLastUpdatedAt"
:level-legend="riskLevelLegend"
:source-legend="riskSourceLegend"
:signal-ranking="riskSignalRanking"
@@ -358,6 +377,8 @@ const {
activeTrendRange,
budgetMetrics,
budgetSummary,
departmentEmployeeCenterValue,
departmentEmployeeLegend,
departmentRangeOptions,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
@@ -371,6 +392,7 @@ const {
rankedEmployees,
riskDashboard,
riskDashboardError,
riskDashboardLastUpdatedAt,
riskDashboardLoading,
riskDailyTrendRows,
riskKpiMetrics,

View File

@@ -4,6 +4,7 @@
:assistant-modal-open="assistantModalOpen"
:workbench-summary="workbenchSummary"
@open-assistant="emit('open-assistant', $event)"
@open-document="emit('open-document', $event)"
/>
</template>
@@ -15,5 +16,5 @@ defineProps({
workbenchSummary: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['open-assistant'])
const emit = defineEmits(['open-assistant', 'open-document'])
</script>

View File

@@ -146,35 +146,15 @@
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-model="pageSize"
class="page-size-select"
:options="pageSizeOptions"
size="small"
@change="changePageSize"
<EnterprisePagination
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:summary="pageSummary"
:total-pages="totalPages"
@page-size-change="changePageSize"
@update:current-page="currentPage = $event"
/>
</footer>
</section>
</div>
</article>

View File

@@ -112,28 +112,16 @@
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ filteredRows.length }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="currentPage = 1" />
</footer>
<EnterprisePagination
v-if="showTable"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:summary="pageSummary"
:total-pages="totalPages"
@page-size-change="changePageSize"
@update:current-page="currentPage = $event"
/>
</article>
<EnterpriseDetailPage
@@ -395,7 +383,7 @@ import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox
import { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
@@ -477,6 +465,7 @@ const filteredRows = computed(() => {
].filter(Boolean).join('').toLowerCase().includes(normalized))
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const pageSummary = computed(() => `${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
@@ -599,6 +588,11 @@ function switchStatus(status) {
activeStatus.value = status
}
function changePageSize(size) {
pageSize.value = Number(size) || pageSize.value
currentPage.value = 1
}
async function reloadReceipts() {
loading.value = true
error.value = ''

View File

@@ -476,6 +476,16 @@
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : deleteActionLabel }}
</button>
<button
v-if="canModifyReturnedApplication"
class="secondary-action"
type="button"
:disabled="actionBusy"
@click="handleModifyApplication"
>
<i class="mdi mdi-pencil-outline"></i>
修改申请
</button>
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
<i :class="submitActionIcon"></i>
{{ submitActionLabel }}
@@ -773,3 +783,4 @@
<script src="./scripts/TravelRequestDetailView.js"></script>
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-responsive.css"></style>

View File

@@ -4,6 +4,7 @@ import { ElButton } from 'element-plus/es/components/button/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
@@ -98,6 +99,7 @@ export default {
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
EnterprisePagination,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
@@ -169,9 +171,6 @@ export default {
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
)
const budgetPageNumbers = computed(() =>
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 8)
const start = (currentBudgetPage.value - 1) * pageSize
@@ -227,7 +226,7 @@ export default {
artLabel: '预算列表为空',
tips: ['可以调整年度、季度、状态或关键词后重试。']
}))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`)
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} / ${totalBudgetPages.value}`)
function buildBudgetAssistantContext(row, mode = 'edit') {
if (!row) return null
@@ -425,7 +424,6 @@ export default {
budgetKeyword,
budgetLoading,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions,
budgetScopeTabs,

View File

@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
@@ -452,6 +453,7 @@ export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
EnterprisePagination,
EnterpriseSelect,
TableLoadingState,
TableEmptyState
@@ -672,6 +674,7 @@ export default {
const totalCount = computed(() => filteredEmployees.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const pageSummary = computed(() => `${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
@@ -1469,6 +1472,7 @@ export default {
hasEmployeeFilters,
totalCount,
totalPages,
pageSummary,
resetFilters,
handleEmployeeEmptyAction,
openEmployeeDetail,

View File

@@ -152,12 +152,6 @@ export default {
filteredSystemLogEntries.value.filter((entry) => entry.level === 'INFO').length
)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visiblePageItems = computed(() => {
if (totalPages.value <= 6) {
return Array.from({ length: totalPages.value }, (_, index) => index + 1)
}
return [1, 2, 3, 4, 5, 'ellipsis', totalPages.value]
})
const visibleSystemLogEntries = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSystemLogEntries.value.slice(start, start + pageSize.value)
@@ -300,7 +294,6 @@ export default {
systemSearchKeyword,
totalCount,
totalPages,
visiblePageItems,
visibleSystemLogEntries
}
}

View File

@@ -1,7 +1,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -87,7 +87,7 @@ export default {
name: 'PoliciesView',
components: {
ConfirmDialog,
EnterpriseSelect,
EnterprisePagination,
TableLoadingState
},
emits: ['summary-change'],
@@ -184,6 +184,7 @@ export default {
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const pageSummary = computed(() => `${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleDocuments = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredDocuments.value.slice(start, start + pageSize.value)
@@ -636,6 +637,7 @@ export default {
loading,
pageSize,
pageSizeOptions,
pageSummary,
pageSizes,
onlyOfficeError,
onlyOfficeHostId,

View File

@@ -49,6 +49,13 @@ import {
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningNudgeMessage,
buildTravelPlanningRecommendation,
buildTravelPlanningSuggestedActions
} from '../../utils/travelApplicationPlanning.js'
import {
calculateTravelReimbursement,
createExpenseClaimItem,
@@ -524,6 +531,14 @@ export default {
type: String,
default: ''
},
initialPromptAutoSubmit: {
type: Boolean,
default: true
},
initialApplicationPreview: {
type: Object,
default: null
},
initialFiles: {
type: Array,
default: () => []
@@ -629,7 +644,9 @@ export default {
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState,
toast
toast,
calculateTravelReimbursement,
currentUser
})
function applyLinkedApplicationPreviewDateSelection(selection) {
@@ -1372,6 +1389,14 @@ export default {
currentInsight.value =
currentInsight.value
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
meta: ['修改申请'],
applicationPreview
}))
persistSessionState()
}
if (props.initialPrompt?.trim() || props.initialFiles.length) {
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
composerDraft.value = props.initialPrompt.trim()
@@ -1380,8 +1405,13 @@ export default {
if (initialMerge.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
nextTick(() => {
adjustComposerTextareaHeight()
})
if (props.initialPromptAutoSubmit !== false) {
submitComposer()
}
}
})
onBeforeUnmount(() => {
@@ -1576,6 +1606,32 @@ export default {
if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
if (!lockSuggestedActionMessage(message, action)) return
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
if (recommendation) {
messages.value.push(createMessage('user', '生成行程规划'))
messages.value.push(createMessage('assistant', recommendation, [], {
meta: ['行程规划建议']
}))
nextTick(scrollToBottom)
persistSessionState()
}
return
}
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
if (!lockSuggestedActionMessage(message, action)) return
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
meta: ['暂不规划']
}))
nextTick(scrollToBottom)
persistSessionState()
return
}
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const targetSessionType = String(actionPayload.session_type || '').trim()
@@ -2033,6 +2089,17 @@ export default {
}
}
function resolveApplicationEditClaimId() {
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
return ''
}
const request = linkedRequest.value || {}
if (!request.applicationEditMode) {
return ''
}
return String(request.claimId || request.claim_id || '').trim()
}
async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
@@ -2044,6 +2111,7 @@ export default {
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
const applicationEditClaimId = resolveApplicationEditClaimId()
applicationSubmitConfirmDialog.value = {
open: false,
message: null
@@ -2059,7 +2127,16 @@ export default {
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
@@ -2074,6 +2151,23 @@ export default {
documentType: 'application'
})
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
...action,
payload: {
...(action.payload || {}),
applicationPreview,
draftPayload
}
}))
if (planningText && planningActions.length) {
messages.value.push(createMessage('assistant', planningText, [], {
meta: ['行程规划推荐'],
suggestedActions: planningActions
}))
persistSessionState()
nextTick(scrollToBottom)
}
} finally {
reviewActionBusy.value = false
}

View File

@@ -460,11 +460,17 @@ export default {
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canModifyReturnedApplication = computed(() => (
isApplicationDocument.value
&& isEditableRequest.value
&& isCurrentApplicant.value
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value)
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
}
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
@@ -1007,7 +1013,7 @@ export default {
if (analysis) {
return {
label: analysis.label || '已上传',
tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
tone: normalizeRiskTone(analysis.severity || 'low'),
headline: analysis.headline || 'AI提示',
summary: analysis.summary || '',
points: Array.isArray(analysis.points) ? analysis.points : [],
@@ -1858,6 +1864,8 @@ export default {
toast(
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: isApplicationDocument.value
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。'
)
return
@@ -2019,6 +2027,76 @@ export default {
})
}
function buildApplicationEditPreview() {
const factEntries = applicationDetailFactItems.value
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
.filter(([label, value]) => label && value)
const facts = new Map(factEntries)
const pickFact = (...labels) => {
for (const label of labels) {
const value = facts.get(label)
if (value) {
return value
}
}
return ''
}
const tripStart = pickFact('出发时间')
const tripReturn = pickFact('返回时间')
const time = tripStart && tripReturn && tripStart !== tripReturn
? `${tripStart}${tripReturn}`
: pickFact('行程时间', '申请时间', '招待时间', '发生时间') || tripStart
return {
sourceText: '修改申请',
modelReviewStatus: 'template',
fields: {
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
grade: pickFact('职级') || request.value.profileGrade || '',
department: request.value.profileDepartment || request.value.departmentName || request.value.department || '',
position: request.value.profilePosition || request.value.employeePosition || request.value.position || '',
managerName: request.value.profileManager || request.value.managerName || request.value.manager || '',
time,
location: pickFact('地点') || request.value.location || request.value.city || '',
reason: pickFact('事由') || request.value.reason || '',
days: pickFact('天数'),
transportMode: pickFact('出行方式'),
lodgingDailyCap: pickFact('住宿上限/天'),
subsidyDailyCap: pickFact('补贴标准/天'),
transportPolicy: pickFact('交通费用口径'),
policyEstimate: pickFact('规则测算参考'),
amount: pickFact('系统预估费用', '用户预估费用', '预计金额') || request.value.amountDisplay || request.value.amount || ''
}
}
}
function handleModifyApplication() {
if (!canModifyReturnedApplication.value) {
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'application',
sessionType: 'application',
prompt: '',
applicationPreview: buildApplicationEditPreview(),
request: {
...request.value,
applicationEditMode: true
},
restoreLatestConversation: false,
initialPromptAutoSubmit: false,
scope: claimId
? {
type: 'claim',
claimId
}
: null
})
}
onBeforeUnmount(() => {
closeAttachmentPreview()
})
@@ -2032,7 +2110,7 @@ export default {
applicationDetailFactItems, relatedApplicationFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
@@ -2046,6 +2124,7 @@ export default {
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
handleModifyApplication,
handlePayRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk,

View File

@@ -1,10 +1,28 @@
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
export const DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS = ['积累', '升级', '整理', '评估']
export const DIGITAL_EMPLOYEE_VISIBLE_TASK_TYPES = new Set([
'finance_dashboard_snapshot',
'digital_employee_reminder_scan',
'employee_behavior_profile_scan',
'department_expense_baseline_accumulate',
'budget_overrun_precontrol_evaluate',
'multi_evidence_consistency_evaluate',
'travel_spatiotemporal_consistency_evaluate',
'global_risk_scan',
'finance_policy_knowledge_organize'
])
const TASK_TYPE_LABELS = {
finance_dashboard_snapshot: '财务经营快照沉淀',
digital_employee_reminder_scan: '定时提醒与待办扫描',
daily_risk_scan: '每日风险巡检',
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
department_expense_baseline_accumulate: '部门费用基线沉淀',
budget_overrun_precontrol_evaluate: '预算占用与超标预警',
multi_evidence_consistency_evaluate: '单据多凭证一致性评估',
travel_spatiotemporal_consistency_evaluate: '差旅时空一致性评估',
weekly_ar_summary: '周度应收账龄汇总',
weekly_expense_report: '周度费用洞察',
rule_review_digest: '规则待审摘要',
@@ -15,9 +33,15 @@ const TASK_TYPE_LABELS = {
}
const TASK_TYPE_SKILL_CATEGORIES = {
finance_dashboard_snapshot: '整理',
digital_employee_reminder_scan: '升级',
daily_risk_scan: '评估',
global_risk_scan: '评估',
employee_behavior_profile_scan: '评估',
employee_behavior_profile_scan: '积累',
department_expense_baseline_accumulate: '积累',
budget_overrun_precontrol_evaluate: '评估',
multi_evidence_consistency_evaluate: '评估',
travel_spatiotemporal_consistency_evaluate: '评估',
weekly_ar_summary: '整理',
weekly_expense_report: '整理',
rule_review_digest: '升级',
@@ -145,6 +169,12 @@ export function isDigitalEmployeeAsset(source = {}) {
)
}
export function shouldDisplayDigitalEmployeeAsset(source = {}) {
const content = parseDigitalEmployeeContent(source.current_version_content)
const taskType = resolveDigitalEmployeeTaskType(source, content)
return DIGITAL_EMPLOYEE_VISIBLE_TASK_TYPES.has(taskType)
}
export function formatDigitalEmployeeCron(value) {
const raw = normalizeDigitalEmployeeText(value)
if (!raw) {

View File

@@ -18,9 +18,31 @@ const KNOWLEDGE_JOB_TYPES = new Set([
'finance_policy_knowledge_organize'
])
export const VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES = new Set([
'finance_dashboard_snapshot',
'digital_employee_reminder_scan',
'employee_behavior_profile_scan',
'department_expense_baseline_accumulate',
'budget_overrun_precontrol_evaluate',
'multi_evidence_consistency_evaluate',
'travel_spatiotemporal_consistency_evaluate',
'global_risk_scan',
'finance_policy_knowledge_organize'
])
const DAILY_COMPACT_TASK_TYPES = new Set([
'finance_dashboard_snapshot'
])
const TASK_TYPE_LABELS = {
finance_dashboard_snapshot: '财务经营快照沉淀',
digital_employee_reminder_scan: '定时提醒与待办扫描',
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
department_expense_baseline_accumulate: '部门费用基线沉淀',
budget_overrun_precontrol_evaluate: '预算占用与超标预警',
multi_evidence_consistency_evaluate: '单据多凭证一致性评估',
travel_spatiotemporal_consistency_evaluate: '差旅时空一致性评估',
risk_clue_collect: '风险线索归集',
finance_policy_knowledge_organize: '知识制度整理',
knowledge_index_sync: '知识制度整理',
@@ -29,10 +51,16 @@ const TASK_TYPE_LABELS = {
}
const TASK_CODE_TO_TYPE = {
'task.hermes.finance_dashboard_snapshot': 'finance_dashboard_snapshot',
'task.hermes.digital_employee_reminder_scan': 'digital_employee_reminder_scan',
'task.hermes.global_risk_scan': 'global_risk_scan',
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
'task.hermes.risk_rule_discovery': 'risk_clue_collect',
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
'task.hermes.department_expense_baseline_accumulate': 'department_expense_baseline_accumulate',
'task.hermes.budget_overrun_precontrol_evaluate': 'budget_overrun_precontrol_evaluate',
'task.hermes.multi_evidence_consistency_evaluate': 'multi_evidence_consistency_evaluate',
'task.hermes.travel_spatiotemporal_consistency_evaluate': 'travel_spatiotemporal_consistency_evaluate',
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize',
'task.hermes.risk_rule_discovery': 'risk_clue_collect'
}
function toObject(value) {
@@ -52,6 +80,12 @@ function resolveTaskTypeFromToolName(value) {
if (name.includes('financial_risk_graph')) {
return 'global_risk_scan'
}
if (name.includes('finance_dashboard_snapshot') || name.includes('finance_dashboard')) {
return 'finance_dashboard_snapshot'
}
if (name.includes('digital_employee_reminder') || name.includes('reminder')) {
return 'digital_employee_reminder_scan'
}
if (name.includes('employee_behavior_profile')) {
return 'employee_behavior_profile_scan'
}
@@ -128,6 +162,43 @@ export function resolveWorkRecordTaskType(run) {
return ''
}
export function isVisibleDigitalEmployeeWorkRecord(run) {
const taskType = resolveWorkRecordTaskType(run)
return VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES.has(taskType)
}
function resolveWorkRecordDayKey(run) {
const date = new Date(run?.started_at || run?.finished_at || '')
if (Number.isNaN(date.getTime())) {
return 'unknown'
}
return date.toISOString().slice(0, 10)
}
export function compactDigitalEmployeeWorkRecords(items = []) {
const rows = []
const compactedKeys = new Set()
for (const run of items) {
const taskType = resolveWorkRecordTaskType(run)
if (!VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES.has(taskType)) {
continue
}
if (DAILY_COMPACT_TASK_TYPES.has(taskType)) {
const key = `${taskType}:${resolveWorkRecordDayKey(run)}`
if (compactedKeys.has(key)) {
continue
}
compactedKeys.add(key)
}
rows.push(run)
}
return rows
}
export function resolveWorkRecordTaskLabel(run) {
const taskType = resolveWorkRecordTaskType(run)
return TASK_TYPE_LABELS[taskType] || ''
@@ -135,6 +206,12 @@ export function resolveWorkRecordTaskLabel(run) {
export function resolveWorkRecordProductKind(run) {
const taskType = resolveWorkRecordTaskType(run)
if (taskType === 'finance_dashboard_snapshot') {
return 'finance_snapshot'
}
if (taskType === 'digital_employee_reminder_scan') {
return 'reminder_scan'
}
if (taskType === 'global_risk_scan') {
return 'risk_graph'
}

View File

@@ -458,6 +458,7 @@ export function sanitizeRequest(request) {
const normalized = {
claimId: String(request.claimId || request.claim_id || '').trim(),
claimNo: String(request.claimNo || request.claim_no || request.documentNo || '').trim(),
id: String(request.id || '').trim(),
typeLabel: String(request.typeLabel || request.category || '').trim(),
reason: String(request.reason || request.title || '').trim(),
@@ -468,7 +469,8 @@ export function sanitizeRequest(request) {
amount: String(request.amount || '').trim(),
node: String(request.node || '').trim(),
approval: String(request.approval || '').trim(),
travel: String(request.travel || '').trim()
travel: String(request.travel || '').trim(),
applicationEditMode: Boolean(request.applicationEditMode || request.application_edit_mode)
}
return Object.values(normalized).some(Boolean) ? normalized : null

View File

@@ -150,7 +150,7 @@ function resolveRequestBusinessStage(request = {}) {
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (tone === 'pass') return 'pass'
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
if (tone === 'low') return 'low'

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