feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# 差旅申请后行程规划推荐
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
用户完成差旅申请后,当前流程直接结束,交互偏机械。差旅申请本身已经包含地点、行程时间、出行方式、天数等信息,系统可以在申请提交成功后继续以对话形式询问是否需要行程规划。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 仅在差旅费用申请提交成功后追加一条对话式推荐。
|
||||||
|
- 推荐内容应基于本次申请的已知字段,不要求用户重新输入地点和时间。
|
||||||
|
- 用户同意后,在当前申请助手对话中生成规划建议。
|
||||||
|
- 规划建议只提供交通时间窗口、酒店区域、待确认事项,不创建订单、不保存草稿、不调用真实订票接口。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不接入真实火车、机票、酒店预订。
|
||||||
|
- 不改变申请单提交和审批状态。
|
||||||
|
- 不强制用户继续规划。
|
||||||
|
|
||||||
|
## 交互
|
||||||
|
|
||||||
|
1. 用户确认提交差旅申请。
|
||||||
|
2. 系统返回申请提交成功结果。
|
||||||
|
3. 系统追加一条轻量对话:询问是否需要行程规划。
|
||||||
|
4. 用户点击“生成行程规划”后,系统在对话中给出推荐。
|
||||||
|
5. 用户点击“暂不需要”后,系统简短确认,不再继续追问。
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 差旅申请后行程规划推荐 TODO
|
||||||
|
|
||||||
|
- [x] 新增差旅规划推荐工具,按申请预览字段生成提示、动作和规划正文。
|
||||||
|
- [x] 申请提交成功后追加规划推荐对话。
|
||||||
|
- [x] 支持“生成行程规划”和“暂不需要”两个对话动作。
|
||||||
|
- [x] 增加前端静态测试覆盖,防止回退成死板结束流程。
|
||||||
|
- [x] 运行定向测试和前端构建验证。
|
||||||
|
|
||||||
328
document/development/数字员工财务报告体系/CONCEPT.md
Normal file
328
document/development/数字员工财务报告体系/CONCEPT.md
Normal 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` 内运行定向 pytest,60s 超时。
|
||||||
|
- 手工验证:生成一份周报 PDF,检查图文布局、中文显示、金额格式和页码。
|
||||||
|
|
||||||
|
## 指标与验收
|
||||||
|
|
||||||
|
- 可以生成一份周报 PDF,包含摘要、趋势图、部门排行、预算、风险和行动清单。
|
||||||
|
- PDF 文件路径写入数字员工运行记录。
|
||||||
|
- 邮件 mock 测试能验证附件发送。
|
||||||
|
- SMTP 未配置时任务不失败,降级为“生成成功、投递待配置”。
|
||||||
|
- 周报、季报、年报模板均有独立章节定义。
|
||||||
|
- 报告中的单号、部门、金额、状态来自真实数据库聚合。
|
||||||
|
- 数字员工看板能看到报告生成任务和结果摘要。
|
||||||
|
|
||||||
|
## 风险与开放问题
|
||||||
|
|
||||||
|
- PDF 渲染依赖中文字体和浏览器/渲染库环境,必须在容器内验证。
|
||||||
|
- 真实 SMTP 投递涉及外部邮件服务器,需要先用测试收件人验证。
|
||||||
|
- 若后续要求报告下载、重发、审阅状态和历史归档,建议新增 `finance_reports` 表。
|
||||||
|
- 季报和年报需要更稳定的画像和风险反馈数据,否则前期只能展示模拟或有限结论。
|
||||||
|
- 图表渲染要避免依赖前端 ECharts 截图,优先后端生成可控 SVG/HTML 图表。
|
||||||
80
document/development/数字员工财务报告体系/TODO.md
Normal file
80
document/development/数字员工财务报告体系/TODO.md
Normal 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`。
|
||||||
227
document/development/数字员工财务经验沉淀与定时提醒/CONCEPT.md
Normal file
227
document/development/数字员工财务经验沉淀与定时提醒/CONCEPT.md
Normal 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` 内运行定向 pytest,60s 超时。
|
||||||
|
- 运行时验证:重启容器后查询 `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`。
|
||||||
|
- 审批责任人目前主要通过员工直属领导推断,复杂动态审批流需要后续对接审批路由结果。
|
||||||
|
- 如果后续需要“已读/已处理/重复提醒抑制”,必须新增提醒表或消息表,并进行数据库迁移确认。
|
||||||
39
document/development/数字员工财务经验沉淀与定时提醒/TODO.md
Normal file
39
document/development/数字员工财务经验沉淀与定时提醒/TODO.md
Normal 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: 行为沉淀技能]
|
||||||
153
document/development/财务看板排行口径与部门人员占比/CONCEPT.md
Normal file
153
document/development/财务看板排行口径与部门人员占比/CONCEPT.md
Normal 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 聚合优化。
|
||||||
35
document/development/财务看板排行口径与部门人员占比/TODO.md
Normal file
35
document/development/财务看板排行口径与部门人员占比/TODO.md
Normal 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: 风险与开放问题] 阻塞:工作区已有大量未提交改动,且本次相关后端文件依赖未跟踪的财务看板快照/常量文件,直接提交会混入既有改动,单独提交又可能缺依赖。
|
||||||
@@ -111,7 +111,7 @@ def _doc_type(claim: ExpenseClaim) -> str:
|
|||||||
expense_type = str(claim.expense_type or "").strip().lower()
|
expense_type = str(claim.expense_type or "").strip().lower()
|
||||||
if claim_no.startswith(("AP-", "APP-")) or expense_type.endswith("_application"):
|
if claim_no.startswith(("AP-", "APP-")) or expense_type.endswith("_application"):
|
||||||
return "application"
|
return "application"
|
||||||
if claim_no.startswith("SIM-EXP-2026"):
|
if str(claim.project_code or "").strip().upper() == "SIM-DEMO":
|
||||||
return "sim_reimbursement"
|
return "sim_reimbursement"
|
||||||
return "reimbursement"
|
return "reimbursement"
|
||||||
|
|
||||||
|
|||||||
52
server/scripts/generate_finance_report.py
Normal file
52
server/scripts/generate_finance_report.py
Normal 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()
|
||||||
@@ -20,7 +20,7 @@ if str(SRC_DIR) not in sys.path:
|
|||||||
|
|
||||||
from app.db.session import get_session_factory # noqa: E402
|
from app.db.session import get_session_factory # noqa: E402
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem # 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
|
from app.services.expense_claim_attachment_storage import ( # noqa: E402
|
||||||
ExpenseClaimAttachmentStorage,
|
ExpenseClaimAttachmentStorage,
|
||||||
)
|
)
|
||||||
@@ -135,8 +135,8 @@ def _sim_claims(db) -> list[ExpenseClaim]:
|
|||||||
db.scalars(
|
db.scalars(
|
||||||
select(ExpenseClaim)
|
select(ExpenseClaim)
|
||||||
.options(selectinload(ExpenseClaim.items))
|
.options(selectinload(ExpenseClaim.items))
|
||||||
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
||||||
.order_by(ExpenseClaim.claim_no.asc())
|
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ def _write_mock_attachment(
|
|||||||
violated=violated,
|
violated=violated,
|
||||||
)
|
)
|
||||||
file_path.write_text(ocr_text, encoding="utf-8")
|
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(
|
storage.write_meta(
|
||||||
file_path,
|
file_path,
|
||||||
_meta_payload(
|
_meta_payload(
|
||||||
|
|||||||
347
server/scripts/rename_half_year_expense_demo_claim_numbers.py
Normal file
347
server/scripts/rename_half_year_expense_demo_claim_numbers.py
Normal 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()
|
||||||
@@ -29,7 +29,6 @@ from app.services.demo_company_simulation_catalog import ( # noqa: E402
|
|||||||
BUDGETED_STATUSES,
|
BUDGETED_STATUSES,
|
||||||
PENDING_STATUSES,
|
PENDING_STATUSES,
|
||||||
SIM_BUDGET_PREFIX,
|
SIM_BUDGET_PREFIX,
|
||||||
SIM_CLAIM_PREFIX,
|
|
||||||
SIM_EMPLOYEE_PREFIX,
|
SIM_EMPLOYEE_PREFIX,
|
||||||
SIM_PROJECT_CODE,
|
SIM_PROJECT_CODE,
|
||||||
SIM_RESERVATION_PREFIX,
|
SIM_RESERVATION_PREFIX,
|
||||||
@@ -60,6 +59,8 @@ RECENT_DATES = (
|
|||||||
datetime(2026, 6, 1, 15, 0, tzinfo=UTC),
|
datetime(2026, 6, 1, 15, 0, tzinfo=UTC),
|
||||||
datetime(2026, 6, 2, 6, 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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -139,6 +140,7 @@ def repair_distribution(db, *, apply: bool) -> RepairSummary:
|
|||||||
|
|
||||||
if apply:
|
if apply:
|
||||||
_normalize_sim_claim_workflow(sim_claims)
|
_normalize_sim_claim_workflow(sim_claims)
|
||||||
|
_clamp_sim_claim_dates(sim_claims)
|
||||||
_redistribute_employees(sim_employees, departments, employee_plan)
|
_redistribute_employees(sim_employees, departments, employee_plan)
|
||||||
db.flush()
|
db.flush()
|
||||||
employees_by_dept = _employees_by_department(db)
|
employees_by_dept = _employees_by_department(db)
|
||||||
@@ -235,8 +237,8 @@ def _sim_claims(db) -> list[ExpenseClaim]:
|
|||||||
db.scalars(
|
db.scalars(
|
||||||
select(ExpenseClaim)
|
select(ExpenseClaim)
|
||||||
.options(selectinload(ExpenseClaim.items))
|
.options(selectinload(ExpenseClaim.items))
|
||||||
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
||||||
.order_by(ExpenseClaim.claim_no.asc())
|
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -254,6 +256,23 @@ def _normalize_sim_claim_workflow(claims: list[ExpenseClaim]) -> None:
|
|||||||
claim.approval_stage = normalized.approval_stage
|
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]:
|
def _counts_by_weight(total: int) -> dict[str, int]:
|
||||||
raw = [(code, total * weight) for code, weight in DEPARTMENT_PLAN]
|
raw = [(code, total * weight) for code, weight in DEPARTMENT_PLAN]
|
||||||
counts = {code: int(value) for code, value in raw}
|
counts = {code: int(value) for code, value in raw}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
)
|
)
|
||||||
parser.add_argument("--target-employees", type=int, default=100)
|
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("--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("--months", type=int, default=6)
|
||||||
parser.add_argument("--seed", type=int, default=20260602)
|
parser.add_argument("--seed", type=int, default=20260602)
|
||||||
parser.add_argument("--apply", action="store_true", help="Write data. Default is dry-run only.")
|
parser.add_argument("--apply", action="store_true", help="Write data. Default is dry-run only.")
|
||||||
@@ -49,6 +50,7 @@ def main() -> None:
|
|||||||
config = SimulationConfig(
|
config = SimulationConfig(
|
||||||
target_employees=args.target_employees,
|
target_employees=args.target_employees,
|
||||||
start_date=args.start_date,
|
start_date=args.start_date,
|
||||||
|
end_date=args.end_date,
|
||||||
months=args.months,
|
months=args.months,
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
|
|||||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||||
from app.schemas.system_dashboard import SystemDashboardRead
|
from app.schemas.system_dashboard import SystemDashboardRead
|
||||||
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
|
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
|
from app.services.system_dashboard import SystemDashboardService
|
||||||
|
|
||||||
router = APIRouter(prefix="/analytics")
|
router = APIRouter(prefix="/analytics")
|
||||||
@@ -65,10 +65,14 @@ def get_finance_dashboard(
|
|||||||
range_key: Annotated[str, Query(max_length=30, description="顶部时间范围。")] = "近10日",
|
range_key: Annotated[str, Query(max_length=30, description="顶部时间范围。")] = "近10日",
|
||||||
start_date: Annotated[date | None, Query(description="自定义开始日期。")] = None,
|
start_date: Annotated[date | None, Query(description="自定义开始日期。")] = None,
|
||||||
end_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天",
|
trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = (
|
||||||
department_range: Annotated[str, Query(max_length=30, description="部门排行时间范围。")] = "本月",
|
"近12天"
|
||||||
|
),
|
||||||
|
department_range: Annotated[str, Query(max_length=30, description="排行分析时间范围。")] = (
|
||||||
|
"本月"
|
||||||
|
),
|
||||||
) -> FinanceDashboardRead:
|
) -> FinanceDashboardRead:
|
||||||
return FinanceDashboardService(db).build_dashboard(
|
return FinanceDashboardSnapshotService(db).build_dashboard(
|
||||||
range_key=range_key,
|
range_key=range_key,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
|
|||||||
@@ -703,7 +703,7 @@ def pay_expense_claim(
|
|||||||
"/claims/{claim_id}",
|
"/claims/{claim_id}",
|
||||||
response_model=ExpenseClaimActionResponse,
|
response_model=ExpenseClaimActionResponse,
|
||||||
summary="删除报销单",
|
summary="删除报销单",
|
||||||
description="申请单仅系统管理员可删除;报销单申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
|
description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_404_NOT_FOUND: {
|
status.HTTP_404_NOT_FOUND: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS
|
|||||||
from app.middleware.logging import AccessLogMiddleware
|
from app.middleware.logging import AccessLogMiddleware
|
||||||
from app.schemas.common import RootStatusRead
|
from app.schemas.common import RootStatusRead
|
||||||
from app.services.agent_foundation import prepare_agent_foundation
|
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 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.hermes_sync import sync_repository_hermes_skills
|
||||||
from app.services.knowledge import prepare_knowledge_library
|
from app.services.knowledge import prepare_knowledge_library
|
||||||
from app.services.knowledge_index_tasks import knowledge_index_task_manager
|
from app.services.knowledge_index_tasks import knowledge_index_task_manager
|
||||||
@@ -31,6 +35,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
prepare_knowledge_library()
|
prepare_knowledge_library()
|
||||||
sync_repository_hermes_skills()
|
sync_repository_hermes_skills()
|
||||||
knowledge_index_scheduler.start()
|
knowledge_index_scheduler.start()
|
||||||
|
finance_dashboard_scheduler.start()
|
||||||
|
employee_profile_scheduler.start()
|
||||||
|
digital_employee_reminder_scheduler.start()
|
||||||
|
finance_report_scheduler.start()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Server ready - host=%s port=%s prefix=%s",
|
"Server ready - host=%s port=%s prefix=%s",
|
||||||
settings.app_host,
|
settings.app_host,
|
||||||
@@ -38,6 +46,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
settings.api_v1_prefix,
|
settings.api_v1_prefix,
|
||||||
)
|
)
|
||||||
yield
|
yield
|
||||||
|
finance_report_scheduler.shutdown()
|
||||||
|
digital_employee_reminder_scheduler.shutdown()
|
||||||
|
employee_profile_scheduler.shutdown()
|
||||||
|
finance_dashboard_scheduler.shutdown()
|
||||||
knowledge_index_scheduler.shutdown()
|
knowledge_index_scheduler.shutdown()
|
||||||
knowledge_index_task_manager.shutdown()
|
knowledge_index_task_manager.shutdown()
|
||||||
shutdown_knowledge_rag_runtime()
|
shutdown_knowledge_rag_runtime()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class FinanceDashboardRead(BaseModel):
|
|||||||
spend_by_category: list[dict[str, Any]] = Field(default_factory=list)
|
spend_by_category: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
exception_mix: 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_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)
|
employee_ranking: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
top_claims: 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)
|
bottlenecks: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
|
|||||||
|
|
||||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"
|
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"
|
||||||
|
|
||||||
|
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_RISK_GRAPH_SCAN_TASK_CODE = "task.hermes.global_risk_scan"
|
||||||
|
|
||||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile_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_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"
|
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_TASK_CATEGORY_MAP = {
|
||||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理",
|
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_RISK_GRAPH_SCAN_TASK_CODE: "评估",
|
||||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累",
|
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累",
|
||||||
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级",
|
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级",
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ from app.services.agent_foundation_constants import (
|
|||||||
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
|
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
|
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_FEEDBACK_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_MULTI_EVIDENCE_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
|
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
|
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
|
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
||||||
|
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
|
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
|
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_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_SUPPLIER_RELATION_TASK_CODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
|
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
|
||||||
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
|
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
|
||||||
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
|
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
|
||||||
@@ -40,6 +42,65 @@ DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
|
|||||||
class AgentFoundationDigitalEmployeeTaskMixin:
|
class AgentFoundationDigitalEmployeeTaskMixin:
|
||||||
def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]:
|
def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]:
|
||||||
return (
|
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(
|
self._digital_employee_task_spec(
|
||||||
code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
|
code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
|
||||||
name="制度条款结构化抽取",
|
name="制度条款结构化抽取",
|
||||||
@@ -134,7 +195,10 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
|||||||
{
|
{
|
||||||
"code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
|
"code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
|
||||||
"name": "财务风险图谱巡检",
|
"name": "财务风险图谱巡检",
|
||||||
"description": "按计划扫描报销单、票据、审批链、员工画像和规则命中结果,生成风险观察与可复核证据链。",
|
"description": (
|
||||||
|
"按计划扫描报销单、票据、审批链、员工画像和规则命中结果,"
|
||||||
|
"生成风险观察与可复核证据链。"
|
||||||
|
),
|
||||||
"scenario_json": ["schedule", "expense", "risk_graph", "risk_observation"],
|
"scenario_json": ["schedule", "expense", "risk_graph", "risk_observation"],
|
||||||
"owner": "风控与审计部",
|
"owner": "风控与审计部",
|
||||||
"reviewer": "顾承宇",
|
"reviewer": "顾承宇",
|
||||||
@@ -167,7 +231,10 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
|||||||
{
|
{
|
||||||
"code": DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
"code": DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
||||||
"name": "员工行为画像巡检",
|
"name": "员工行为画像巡检",
|
||||||
"description": "按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,为风险图谱提供画像基线。",
|
"description": (
|
||||||
|
"按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,"
|
||||||
|
"为风险图谱提供画像基线。"
|
||||||
|
),
|
||||||
"scenario_json": ["schedule", "employee_profile", "baseline", "risk_graph"],
|
"scenario_json": ["schedule", "employee_profile", "baseline", "risk_graph"],
|
||||||
"owner": "风控与审计部",
|
"owner": "风控与审计部",
|
||||||
"reviewer": "顾承宇",
|
"reviewer": "顾承宇",
|
||||||
@@ -219,7 +286,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
|||||||
skill_category="评估",
|
skill_category="评估",
|
||||||
skill_name="travel-spatiotemporal-consistency-evaluator",
|
skill_name="travel-spatiotemporal-consistency-evaluator",
|
||||||
output_format="spatiotemporal_consistency_report",
|
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",
|
execution_strategy="reuse_financial_risk_graph_scan",
|
||||||
),
|
),
|
||||||
self._digital_employee_task_spec(
|
self._digital_employee_task_spec(
|
||||||
@@ -232,7 +304,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
|||||||
skill_category="评估",
|
skill_category="评估",
|
||||||
skill_name="budget-overrun-precontrol-evaluator",
|
skill_name="budget-overrun-precontrol-evaluator",
|
||||||
output_format="budget_precontrol_warning_report",
|
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",
|
execution_strategy="definition_ready",
|
||||||
),
|
),
|
||||||
self._digital_employee_task_spec(
|
self._digital_employee_task_spec(
|
||||||
@@ -245,13 +322,21 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
|||||||
skill_category="评估",
|
skill_category="评估",
|
||||||
skill_name="supplier-abnormal-relation-evaluator",
|
skill_name="supplier-abnormal-relation-evaluator",
|
||||||
output_format="supplier_abnormal_relation_report",
|
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",
|
execution_strategy="reuse_financial_risk_graph_scan",
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
|
"code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
|
||||||
"name": "风险线索归集",
|
"name": "风险线索归集",
|
||||||
"description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则。",
|
"description": (
|
||||||
|
"按计划复盘申请、报销、规则命中和人工反馈,"
|
||||||
|
"归集带事实依据的潜在线索,提交人工复核,不生成规则。"
|
||||||
|
),
|
||||||
"scenario_json": ["schedule", "application", "reimbursement", "risk_clue"],
|
"scenario_json": ["schedule", "application", "reimbursement", "risk_clue"],
|
||||||
"owner": "风控与审计部",
|
"owner": "风控与审计部",
|
||||||
"reviewer": "顾承宇",
|
"reviewer": "顾承宇",
|
||||||
@@ -291,7 +376,11 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
|||||||
skill_category="升级",
|
skill_category="升级",
|
||||||
skill_name="risk-algorithm-replay-evaluator",
|
skill_name="risk-algorithm-replay-evaluator",
|
||||||
output_format="algorithm_replay_evaluation_report",
|
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",
|
execution_strategy="definition_ready",
|
||||||
),
|
),
|
||||||
self._digital_employee_task_spec(
|
self._digital_employee_task_spec(
|
||||||
@@ -304,7 +393,12 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
|||||||
skill_category="升级",
|
skill_category="升级",
|
||||||
skill_name="policy-reference-gap-hinter",
|
skill_name="policy-reference-gap-hinter",
|
||||||
output_format="policy_reference_gap_hint_report",
|
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",
|
execution_strategy="definition_ready",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ from datetime import date, datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
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_EMPLOYEE_PREFIX = "SIM2026"
|
||||||
SIM_CLAIM_PREFIX = "SIM-EXP-2026"
|
# 历史模拟数据已用这个命名空间生成 UUID;这里只用于保持幂等,不再作为业务单号。
|
||||||
|
SIM_CLAIM_ID_NAMESPACE = "SIM-EXP-2026"
|
||||||
SIM_BUDGET_PREFIX = "SIM-BUD-2026"
|
SIM_BUDGET_PREFIX = "SIM-BUD-2026"
|
||||||
SIM_TRANSACTION_PREFIX = "SIM-BTX-2026"
|
SIM_TRANSACTION_PREFIX = "SIM-BTX-2026"
|
||||||
SIM_RESERVATION_PREFIX = "SIM-BRS-2026"
|
SIM_RESERVATION_PREFIX = "SIM-BRS-2026"
|
||||||
@@ -31,6 +38,7 @@ BUDGETED_STATUSES = SUCCESS_STATUSES | PENDING_STATUSES
|
|||||||
class SimulationConfig:
|
class SimulationConfig:
|
||||||
target_employees: int = 100
|
target_employees: int = 100
|
||||||
start_date: date = date(2026, 1, 1)
|
start_date: date = date(2026, 1, 1)
|
||||||
|
end_date: date = date(2026, 6, 2)
|
||||||
months: int = 6
|
months: int = 6
|
||||||
seed: int = 20260602
|
seed: int = 20260602
|
||||||
|
|
||||||
@@ -269,6 +277,25 @@ def updated_at_for_claim_plan(plan: ClaimPlan) -> datetime:
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
base = plan.submitted_at or plan.occurred_at
|
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"}:
|
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)
|
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))
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ APPLICATION_EXPENSE_TYPES = {
|
|||||||
}
|
}
|
||||||
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
|
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
|
||||||
RECENT_VISIBLE_CLAIM_START = 501
|
RECENT_VISIBLE_CLAIM_START = 501
|
||||||
RECENT_VISIBLE_CLAIM_END = 950
|
RECENT_VISIBLE_CLAIM_END = 817
|
||||||
|
|
||||||
|
|
||||||
def is_admin_identity(*values: Any) -> bool:
|
def is_admin_identity(*values: Any) -> bool:
|
||||||
@@ -70,10 +70,79 @@ def recent_visible_claim_day(
|
|||||||
*,
|
*,
|
||||||
employee_index: int,
|
employee_index: int,
|
||||||
claim_index: int,
|
claim_index: int,
|
||||||
|
period_end: date,
|
||||||
) -> date | None:
|
) -> date | None:
|
||||||
if not months or not (RECENT_VISIBLE_CLAIM_START <= claim_index <= RECENT_VISIBLE_CLAIM_END):
|
if not months or not (RECENT_VISIBLE_CLAIM_START <= claim_index <= RECENT_VISIBLE_CLAIM_END):
|
||||||
return None
|
return None
|
||||||
month = months[-1]
|
month = months[-1]
|
||||||
_, max_day = calendar.monthrange(month.year, month.month)
|
_, 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)
|
day = min(2, max_day)
|
||||||
return month.replace(day=1 + ((employee_index + claim_index) % 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)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import calendar
|
|
||||||
import random
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
@@ -28,7 +27,7 @@ from app.services.demo_company_simulation_catalog import (
|
|||||||
MONTH_FACTORS,
|
MONTH_FACTORS,
|
||||||
PENDING_STATUSES,
|
PENDING_STATUSES,
|
||||||
SIM_BUDGET_PREFIX,
|
SIM_BUDGET_PREFIX,
|
||||||
SIM_CLAIM_PREFIX,
|
SIM_CLAIM_ID_NAMESPACE,
|
||||||
SIM_EMPLOYEE_PREFIX,
|
SIM_EMPLOYEE_PREFIX,
|
||||||
SIM_PROJECT_CODE,
|
SIM_PROJECT_CODE,
|
||||||
SIM_RESERVATION_PREFIX,
|
SIM_RESERVATION_PREFIX,
|
||||||
@@ -45,6 +44,7 @@ from app.services.demo_company_simulation_catalog import (
|
|||||||
SimulationConfig,
|
SimulationConfig,
|
||||||
SimulationSummary,
|
SimulationSummary,
|
||||||
build_employee_name,
|
build_employee_name,
|
||||||
|
build_simulation_reimbursement_no,
|
||||||
claim_location,
|
claim_location,
|
||||||
claim_reason,
|
claim_reason,
|
||||||
department_from_row,
|
department_from_row,
|
||||||
@@ -57,7 +57,11 @@ from app.services.demo_company_simulation_catalog import (
|
|||||||
)
|
)
|
||||||
from app.services.demo_company_simulation_filters import (
|
from app.services.demo_company_simulation_filters import (
|
||||||
is_admin_employee_like,
|
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,
|
budget_reservations_to_create=reservation_count,
|
||||||
risk_observations_to_create=risk_count,
|
risk_observations_to_create=risk_count,
|
||||||
period_start=self.config.start_date.isoformat(),
|
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]:
|
def _department_refs(self, *, apply: bool) -> list[DepartmentRef]:
|
||||||
@@ -275,16 +279,19 @@ class HalfYearExpenseSimulationSeeder:
|
|||||||
|
|
||||||
def _build_claim_plans(self, employees: list[EmployeeRef]) -> list[ClaimPlan]:
|
def _build_claim_plans(self, employees: list[EmployeeRef]) -> list[ClaimPlan]:
|
||||||
plans: 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
|
claim_index = 1
|
||||||
for employee_index, employee in enumerate(employees):
|
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):
|
for local_index in range(count):
|
||||||
occurred_day = self._claim_day(
|
occurred_day = simulation_claim_day(
|
||||||
|
self.rng,
|
||||||
months,
|
months,
|
||||||
employee_index,
|
employee_index=employee_index,
|
||||||
local_index,
|
local_index=local_index,
|
||||||
claim_index,
|
claim_index=claim_index,
|
||||||
|
period_end=period_end,
|
||||||
)
|
)
|
||||||
expense_type = self._expense_type_for_employee(employee)
|
expense_type = self._expense_type_for_employee(employee)
|
||||||
amount = self._claim_amount(employee, expense_type, occurred_day)
|
amount = self._claim_amount(employee, expense_type, occurred_day)
|
||||||
@@ -301,10 +308,10 @@ class HalfYearExpenseSimulationSeeder:
|
|||||||
id=str(
|
id=str(
|
||||||
uuid.uuid5(
|
uuid.uuid5(
|
||||||
uuid.NAMESPACE_DNS,
|
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,
|
employee=employee,
|
||||||
expense_type=expense_type,
|
expense_type=expense_type,
|
||||||
reason=claim_reason(
|
reason=claim_reason(
|
||||||
@@ -372,12 +379,25 @@ class HalfYearExpenseSimulationSeeder:
|
|||||||
) -> tuple[dict[tuple[int, str, str, str, str], str], int]:
|
) -> tuple[dict[tuple[int, str, str, str, str], str], int]:
|
||||||
allocation_map: dict[tuple[int, str, str, str, str], str] = {}
|
allocation_map: dict[tuple[int, str, str, str, str], str] = {}
|
||||||
created_count = 0
|
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)
|
existing = self._find_sim_allocation(plan)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
allocation_map[plan.key] = existing.id
|
allocation_map[plan.key] = existing.id
|
||||||
continue
|
continue
|
||||||
created_count += 1
|
created_count += 1
|
||||||
|
budget_no, budget_no_cursor = next_simulation_number(
|
||||||
|
SIM_BUDGET_PREFIX,
|
||||||
|
used_budget_nos,
|
||||||
|
budget_no_cursor,
|
||||||
|
)
|
||||||
allocation_id = str(
|
allocation_id = str(
|
||||||
uuid.uuid5(
|
uuid.uuid5(
|
||||||
uuid.NAMESPACE_DNS,
|
uuid.NAMESPACE_DNS,
|
||||||
@@ -390,7 +410,7 @@ class HalfYearExpenseSimulationSeeder:
|
|||||||
self.db.add(
|
self.db.add(
|
||||||
BudgetAllocation(
|
BudgetAllocation(
|
||||||
id=allocation_id,
|
id=allocation_id,
|
||||||
budget_no=f"{SIM_BUDGET_PREFIX}-{index:04d}",
|
budget_no=budget_no,
|
||||||
fiscal_year=plan.key[0],
|
fiscal_year=plan.key[0],
|
||||||
period_type="quarter",
|
period_type="quarter",
|
||||||
period_key=plan.period_key,
|
period_key=plan.period_key,
|
||||||
@@ -415,15 +435,19 @@ class HalfYearExpenseSimulationSeeder:
|
|||||||
return allocation_map, created_count
|
return allocation_map, created_count
|
||||||
|
|
||||||
def _ensure_claims(self, plans: list[ClaimPlan], *, apply: bool) -> tuple[int, int]:
|
def _ensure_claims(self, plans: list[ClaimPlan], *, apply: bool) -> tuple[int, int]:
|
||||||
existing_claim_nos = set(
|
existing_rows = list(
|
||||||
self.db.scalars(
|
self.db.execute(
|
||||||
select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
select(ExpenseClaim.id, ExpenseClaim.claim_no).where(
|
||||||
|
ExpenseClaim.project_code == SIM_PROJECT_CODE
|
||||||
|
)
|
||||||
).all()
|
).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
|
claim_count = 0
|
||||||
item_count = 0
|
item_count = 0
|
||||||
for plan in plans:
|
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
|
continue
|
||||||
claim_count += 1
|
claim_count += 1
|
||||||
item_count += len(plan.items)
|
item_count += len(plan.items)
|
||||||
@@ -645,40 +669,6 @@ class HalfYearExpenseSimulationSeeder:
|
|||||||
plan.budget_subject_code,
|
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:
|
def _weighted_department(self, departments: list[DepartmentRef], index: int) -> DepartmentRef:
|
||||||
weighted: list[DepartmentRef] = []
|
weighted: list[DepartmentRef] = []
|
||||||
by_code = {item.unit_code: item for item in departments}
|
by_code = {item.unit_code: item for item in departments}
|
||||||
@@ -696,16 +686,6 @@ class HalfYearExpenseSimulationSeeder:
|
|||||||
subjects = list(weights)
|
subjects = list(weights)
|
||||||
return self.rng.choices(subjects, weights=[weights[item] for item in subjects], k=1)[0]
|
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(
|
def _claim_amount(
|
||||||
self,
|
self,
|
||||||
employee: EmployeeRef,
|
employee: EmployeeRef,
|
||||||
@@ -726,6 +706,10 @@ class HalfYearExpenseSimulationSeeder:
|
|||||||
Decimal("0.01")
|
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]:
|
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
|
selector = (employee_index * 11 + local_index * 17 + self.config.seed) % 100
|
||||||
if selector < 42:
|
if selector < 42:
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ TASK_CODE_TO_TYPE = {
|
|||||||
"task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate",
|
"task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate",
|
||||||
"task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate",
|
"task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate",
|
||||||
"task.hermes.multi_evidence_consistency_evaluate": "multi_evidence_consistency_evaluate",
|
"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.budget_overrun_precontrol_evaluate": "budget_overrun_precontrol_evaluate",
|
||||||
"task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate",
|
"task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate",
|
||||||
"task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate",
|
"task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate",
|
||||||
@@ -136,6 +138,16 @@ TASK_SPECS: dict[str, dict[str, str]] = {
|
|||||||
"category": "升级",
|
"category": "升级",
|
||||||
"color": "var(--chart-amber)",
|
"color": "var(--chart-amber)",
|
||||||
},
|
},
|
||||||
|
"finance_dashboard_snapshot": {
|
||||||
|
"label": "财务看板指标快照",
|
||||||
|
"category": "积累",
|
||||||
|
"color": "var(--chart-blue)",
|
||||||
|
},
|
||||||
|
"digital_employee_reminder_scan": {
|
||||||
|
"label": "定时提醒扫描",
|
||||||
|
"category": "整理",
|
||||||
|
"color": "var(--success)",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
CATEGORY_SPECS = {
|
CATEGORY_SPECS = {
|
||||||
@@ -203,6 +215,8 @@ class DigitalEmployeeDashboardService:
|
|||||||
+ metrics["risk_clues"]
|
+ metrics["risk_clues"]
|
||||||
+ metrics["profile_snapshots"]
|
+ metrics["profile_snapshots"]
|
||||||
+ metrics["knowledge_documents"]
|
+ metrics["knowledge_documents"]
|
||||||
|
+ metrics["finance_snapshots"]
|
||||||
|
+ metrics["reminders"]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -216,6 +230,8 @@ class DigitalEmployeeDashboardService:
|
|||||||
"riskClues": metrics["risk_clues"],
|
"riskClues": metrics["risk_clues"],
|
||||||
"profileSnapshots": metrics["profile_snapshots"],
|
"profileSnapshots": metrics["profile_snapshots"],
|
||||||
"knowledgeDocuments": metrics["knowledge_documents"],
|
"knowledgeDocuments": metrics["knowledge_documents"],
|
||||||
|
"financeDashboardSnapshots": metrics["finance_snapshots"],
|
||||||
|
"reminders": metrics["reminders"],
|
||||||
"successRate": self._percent(success_runs, total_runs),
|
"successRate": self._percent(success_runs, total_runs),
|
||||||
"failureRate": self._percent(failed_runs, total_runs),
|
"failureRate": self._percent(failed_runs, total_runs),
|
||||||
}
|
}
|
||||||
@@ -232,6 +248,8 @@ class DigitalEmployeeDashboardService:
|
|||||||
"riskClues": 0,
|
"riskClues": 0,
|
||||||
"profileSnapshots": 0,
|
"profileSnapshots": 0,
|
||||||
"knowledgeDocuments": 0,
|
"knowledgeDocuments": 0,
|
||||||
|
"financeDashboardSnapshots": 0,
|
||||||
|
"reminders": 0,
|
||||||
"businessOutputs": 0,
|
"businessOutputs": 0,
|
||||||
}
|
}
|
||||||
for label in labels
|
for label in labels
|
||||||
@@ -254,11 +272,15 @@ class DigitalEmployeeDashboardService:
|
|||||||
row["riskClues"] += metrics["risk_clues"]
|
row["riskClues"] += metrics["risk_clues"]
|
||||||
row["profileSnapshots"] += metrics["profile_snapshots"]
|
row["profileSnapshots"] += metrics["profile_snapshots"]
|
||||||
row["knowledgeDocuments"] += metrics["knowledge_documents"]
|
row["knowledgeDocuments"] += metrics["knowledge_documents"]
|
||||||
|
row["financeDashboardSnapshots"] += metrics["finance_snapshots"]
|
||||||
|
row["reminders"] += metrics["reminders"]
|
||||||
row["businessOutputs"] += (
|
row["businessOutputs"] += (
|
||||||
metrics["risk_observations"]
|
metrics["risk_observations"]
|
||||||
+ metrics["risk_clues"]
|
+ metrics["risk_clues"]
|
||||||
+ metrics["profile_snapshots"]
|
+ metrics["profile_snapshots"]
|
||||||
+ metrics["knowledge_documents"]
|
+ metrics["knowledge_documents"]
|
||||||
|
+ metrics["finance_snapshots"]
|
||||||
|
+ metrics["reminders"]
|
||||||
)
|
)
|
||||||
|
|
||||||
return [rows[label] for label in labels]
|
return [rows[label] for label in labels]
|
||||||
@@ -374,6 +396,12 @@ class DigitalEmployeeDashboardService:
|
|||||||
summary,
|
summary,
|
||||||
("snapshot_count", "profile_snapshot_count", "profile_snapshots"),
|
("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(
|
metrics["knowledge_documents"] = max(
|
||||||
self._first_int(
|
self._first_int(
|
||||||
summary,
|
summary,
|
||||||
@@ -383,9 +411,21 @@ class DigitalEmployeeDashboardService:
|
|||||||
self._list_length(route_json, ("document_ids", "requested_document_ids")),
|
self._list_length(route_json, ("document_ids", "requested_document_ids")),
|
||||||
)
|
)
|
||||||
metrics["scanned_claims"] = self._first_int(summary, ("scanned_claim_count", "claim_count"))
|
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["rule_hits"] = self._first_int(summary, ("rule_hit_count", "rule_hits"))
|
||||||
metrics["facts"] = self._first_int(summary, ("fact_count", "facts"))
|
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
|
return metrics
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -394,11 +434,13 @@ class DigitalEmployeeDashboardService:
|
|||||||
"risk_observations": 0,
|
"risk_observations": 0,
|
||||||
"risk_clues": 0,
|
"risk_clues": 0,
|
||||||
"profile_snapshots": 0,
|
"profile_snapshots": 0,
|
||||||
|
"finance_snapshots": 0,
|
||||||
"knowledge_documents": 0,
|
"knowledge_documents": 0,
|
||||||
"scanned_claims": 0,
|
"scanned_claims": 0,
|
||||||
"target_employees": 0,
|
"target_employees": 0,
|
||||||
"rule_hits": 0,
|
"rule_hits": 0,
|
||||||
"facts": 0,
|
"facts": 0,
|
||||||
|
"reminders": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _extract_run_summary(self, run: AgentRun) -> dict[str, Any]:
|
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:
|
def _matched_tool_call(self, run: AgentRun, task_type: str) -> AgentToolCall | None:
|
||||||
digital_tools = [
|
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:
|
for tool in run.tool_calls:
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -441,7 +485,10 @@ class DigitalEmployeeDashboardService:
|
|||||||
route_json = run.route_json or {}
|
route_json = run.route_json or {}
|
||||||
if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value:
|
if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value:
|
||||||
return True
|
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:
|
def _resolve_task_type(self, run: AgentRun) -> str:
|
||||||
route_json = run.route_json or {}
|
route_json = run.route_json or {}
|
||||||
@@ -491,6 +538,8 @@ class DigitalEmployeeDashboardService:
|
|||||||
return "global_risk_scan"
|
return "global_risk_scan"
|
||||||
if "employee_behavior_profile" in name:
|
if "employee_behavior_profile" in name:
|
||||||
return "employee_behavior_profile_scan"
|
return "employee_behavior_profile_scan"
|
||||||
|
if "reminder" in name:
|
||||||
|
return "digital_employee_reminder_scan"
|
||||||
if "finance_policy_knowledge" in name:
|
if "finance_policy_knowledge" in name:
|
||||||
return "finance_policy_knowledge_organize"
|
return "finance_policy_knowledge_organize"
|
||||||
if "risk_clue" in name:
|
if "risk_clue" in name:
|
||||||
|
|||||||
163
server/src/app/services/digital_employee_finance_report_task.py
Normal file
163
server/src/app/services/digital_employee_finance_report_task.py
Normal 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'}。"
|
||||||
|
)
|
||||||
102
server/src/app/services/digital_employee_reminder_scheduler.py
Normal file
102
server/src/app/services/digital_employee_reminder_scheduler.py
Normal 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()
|
||||||
547
server/src/app/services/digital_employee_reminder_task.py
Normal file
547
server/src/app/services/digital_employee_reminder_task.py
Normal 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),
|
||||||
|
}
|
||||||
123
server/src/app/services/employee_profile_scan_task.py
Normal file
123
server/src/app/services/employee_profile_scan_task.py
Normal 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 {},
|
||||||
|
}
|
||||||
88
server/src/app/services/employee_profile_scheduler.py
Normal file
88
server/src/app/services/employee_profile_scheduler.py
Normal 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()
|
||||||
@@ -245,7 +245,7 @@ class ExpenseClaimReadModelMixin:
|
|||||||
|
|
||||||
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
|
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
|
||||||
if not self._is_editable_claim_status(claim.status):
|
if not self._is_editable_claim_status(claim.status):
|
||||||
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
|
raise ValueError("只有草稿、待补充或退回待提交状态的单据才允许执行该操作。")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_draft_pending_claim(claim: ExpenseClaim) -> None:
|
def _ensure_draft_pending_claim(claim: ExpenseClaim) -> None:
|
||||||
|
|||||||
@@ -562,9 +562,6 @@ class ExpenseClaimService(
|
|||||||
if claim is None:
|
if claim is None:
|
||||||
return 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:
|
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
|
||||||
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
|
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
|
||||||
|
|
||||||
@@ -756,6 +753,5 @@ class ExpenseClaimService(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,75 +9,23 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
from app.models.budget import BudgetAllocation
|
from app.models.budget import BudgetAllocation
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||||
from app.services.budget_support import BudgetSupportMixin
|
from app.services.budget_support import BudgetSupportMixin
|
||||||
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
|
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
|
||||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||||
|
from app.services.finance_dashboard_constants import (
|
||||||
SLA_TARGET_HOURS = Decimal("8.0")
|
CHART_COLORS,
|
||||||
PENDING_STATUSES = {
|
EMPTY_DONUT,
|
||||||
"submitted",
|
EXCLUDED_SPEND_STATUSES,
|
||||||
"review",
|
EXPENSE_TYPE_ALIASES,
|
||||||
"pending_review",
|
PENDING_STATUSES,
|
||||||
"manager_review",
|
RISK_SIGNAL_LABELS,
|
||||||
"budget_review",
|
SLA_TARGET_HOURS,
|
||||||
"finance_review",
|
STAGE_LABELS,
|
||||||
"approving",
|
SUCCESS_STATUSES,
|
||||||
}
|
)
|
||||||
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": "人工复核",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FinanceDashboardService(BudgetSupportMixin):
|
class FinanceDashboardService(BudgetSupportMixin):
|
||||||
@@ -93,7 +41,6 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
trend_range: str = "近12天",
|
trend_range: str = "近12天",
|
||||||
department_range: str = "本月",
|
department_range: str = "本月",
|
||||||
) -> FinanceDashboardRead:
|
) -> FinanceDashboardRead:
|
||||||
self._ensure_storage_ready()
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
start, end, resolved_key = self._resolve_scope(
|
start, end, resolved_key = self._resolve_scope(
|
||||||
range_key=range_key,
|
range_key=range_key,
|
||||||
@@ -103,7 +50,7 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
)
|
)
|
||||||
previous_start = start - (end - start)
|
previous_start = start - (end - start)
|
||||||
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
|
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 = [
|
claims = [
|
||||||
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
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)
|
scope_claims = self._claims_between(claims, start, end)
|
||||||
previous_claims = self._claims_between(claims, previous_start, start)
|
previous_claims = self._claims_between(claims, previous_start, start)
|
||||||
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
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)
|
totals = self._totals(scope_claims)
|
||||||
previous_totals = self._totals(previous_claims)
|
previous_totals = self._totals(previous_claims)
|
||||||
@@ -127,17 +74,15 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
trend=self._trend(trend_labels, trend_claims, now),
|
trend=self._trend(trend_labels, trend_claims, now),
|
||||||
spend_by_category=self._spend_by_category(scope_claims),
|
spend_by_category=self._spend_by_category(scope_claims),
|
||||||
exception_mix=self._payment_status_mix(scope_claims),
|
exception_mix=self._payment_status_mix(scope_claims),
|
||||||
department_ranking=self._department_ranking(department_claims),
|
department_ranking=self._department_ranking(ranking_claims),
|
||||||
employee_ranking=self._employee_ranking(department_claims),
|
department_employee_mix=self._department_employee_mix(ranking_claims),
|
||||||
top_claims=self._top_claims(department_claims),
|
employee_ranking=self._employee_ranking(ranking_claims),
|
||||||
|
top_claims=self._top_claims(ranking_claims),
|
||||||
bottlenecks=self._bottlenecks(scope_claims),
|
bottlenecks=self._bottlenecks(scope_claims),
|
||||||
budget_summary=self._budget_summary(now.year),
|
budget_summary=self._budget_summary(now.year),
|
||||||
budget_metrics=self._budget_metrics(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]:
|
def _fetch_claims(self) -> list[ExpenseClaim]:
|
||||||
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
||||||
return list(self.db.scalars(stmt).all())
|
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)]
|
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
|
return self._day_start(start_day), self._day_after(end_day), labels
|
||||||
|
|
||||||
def _resolve_department_scope(
|
def _resolve_ranking_scope(
|
||||||
self,
|
self,
|
||||||
department_range: str,
|
department_range: str,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
) -> tuple[datetime, datetime]:
|
) -> tuple[datetime, datetime]:
|
||||||
today = now.date()
|
today = now.date()
|
||||||
key = str(department_range or "").strip()
|
key = str(department_range or "").strip()
|
||||||
if key == "本周":
|
if key == "全部":
|
||||||
start_day = today - timedelta(days=today.weekday())
|
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
|
||||||
elif key == "本季度":
|
if key == "本季度":
|
||||||
quarter_month = ((today.month - 1) // 3) * 3 + 1
|
quarter_month = ((today.month - 1) // 3) * 3 + 1
|
||||||
start_day = today.replace(month=quarter_month, day=1)
|
start_day = today.replace(month=quarter_month, day=1)
|
||||||
|
elif key == "本年":
|
||||||
|
start_day = today.replace(month=1, day=1)
|
||||||
else:
|
else:
|
||||||
start_day = today.replace(day=1)
|
start_day = today.replace(day=1)
|
||||||
return self._day_start(start_day), self._day_after(today)
|
return self._day_start(start_day), self._day_after(today)
|
||||||
@@ -347,6 +294,7 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
counts: dict[str, int] = defaultdict(int)
|
counts: dict[str, int] = defaultdict(int)
|
||||||
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
|
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
employees: dict[str, set[str]] = defaultdict(set)
|
||||||
for claim in claims:
|
for claim in claims:
|
||||||
status = self._status(claim)
|
status = self._status(claim)
|
||||||
if status in EXCLUDED_SPEND_STATUSES:
|
if status in EXCLUDED_SPEND_STATUSES:
|
||||||
@@ -357,6 +305,9 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
amount = self._claim_amount(claim)
|
amount = self._claim_amount(claim)
|
||||||
buckets[department_name] += amount
|
buckets[department_name] += amount
|
||||||
counts[department_name] += 1
|
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:
|
if status in PENDING_STATUSES:
|
||||||
pending_amounts[department_name] += amount
|
pending_amounts[department_name] += amount
|
||||||
|
|
||||||
@@ -366,6 +317,7 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
"amount": self._decimal_number(amount),
|
"amount": self._decimal_number(amount),
|
||||||
"value": self._decimal_number(amount),
|
"value": self._decimal_number(amount),
|
||||||
"count": counts[name],
|
"count": counts[name],
|
||||||
|
"employeeCount": len(employees[name]),
|
||||||
"pendingAmount": self._decimal_number(pending_amounts[name]),
|
"pendingAmount": self._decimal_number(pending_amounts[name]),
|
||||||
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||||
}
|
}
|
||||||
@@ -375,6 +327,34 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
]
|
]
|
||||||
return rows
|
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]]:
|
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
counts: dict[str, int] = defaultdict(int)
|
counts: dict[str, int] = defaultdict(int)
|
||||||
|
|||||||
65
server/src/app/services/finance_dashboard_constants.py
Normal file
65
server/src/app/services/finance_dashboard_constants.py
Normal 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": "人工复核",
|
||||||
|
}
|
||||||
85
server/src/app/services/finance_dashboard_scheduler.py
Normal file
85
server/src/app/services/finance_dashboard_scheduler.py
Normal 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()
|
||||||
268
server/src/app/services/finance_dashboard_snapshot.py
Normal file
268
server/src/app/services/finance_dashboard_snapshot.py
Normal 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)
|
||||||
319
server/src/app/services/finance_report_context.py
Normal file
319
server/src/app/services/finance_report_context.py
Normal 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}"
|
||||||
205
server/src/app/services/finance_report_mailer.py
Normal file
205
server/src/app/services/finance_report_mailer.py
Normal 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)
|
||||||
397
server/src/app/services/finance_report_renderer.py
Normal file
397
server/src/app/services/finance_report_renderer.py
Normal 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()
|
||||||
143
server/src/app/services/finance_report_scheduler.py
Normal file
143
server/src/app/services/finance_report_scheduler.py
Normal 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()
|
||||||
@@ -12,6 +12,9 @@ from app.schemas.agent_asset import AgentAssetListItem, AgentAssetRead
|
|||||||
from app.schemas.ontology import OntologyParseResult
|
from app.schemas.ontology import OntologyParseResult
|
||||||
from app.schemas.orchestrator import OrchestratorRequest
|
from app.schemas.orchestrator import OrchestratorRequest
|
||||||
from app.schemas.user_agent import UserAgentRequest, UserAgentResponse
|
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_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||||
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
|
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
|
||||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||||
@@ -388,6 +391,11 @@ class OrchestratorExecutionEngine:
|
|||||||
)
|
)
|
||||||
if task_type == "risk_clue_collect":
|
if task_type == "risk_clue_collect":
|
||||||
return self._execute_risk_clue_collect(run_id=run_id, context_json=context_json)
|
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
|
return None
|
||||||
|
|
||||||
def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome:
|
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,
|
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
|
@staticmethod
|
||||||
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
|
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
|
||||||
if task_asset is None:
|
if task_asset is None:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from decimal import Decimal
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
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.algorithem.risk_graph import RiskHistoryStats, RiskObservationDraft
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -326,6 +326,7 @@ class RiskObservationService:
|
|||||||
since = datetime.now(UTC) - timedelta(days=window_days)
|
since = datetime.now(UTC) - timedelta(days=window_days)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(RiskObservation)
|
select(RiskObservation)
|
||||||
|
.options(joinedload(RiskObservation.claim))
|
||||||
.where(RiskObservation.created_at >= since)
|
.where(RiskObservation.created_at >= since)
|
||||||
.order_by(RiskObservation.created_at.desc())
|
.order_by(RiskObservation.created_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
@@ -178,6 +178,11 @@ class UserAgentApplicationMixin:
|
|||||||
step = self._resolve_expense_application_step(payload, facts)
|
step = self._resolve_expense_application_step(payload, facts)
|
||||||
application_claim = None
|
application_claim = None
|
||||||
if step == "submitted":
|
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)
|
application_claim = self._find_duplicate_expense_application_record(payload, facts)
|
||||||
if application_claim is not None:
|
if application_claim is not None:
|
||||||
step = "duplicate"
|
step = "duplicate"
|
||||||
@@ -229,9 +234,14 @@ class UserAgentApplicationMixin:
|
|||||||
if step == "submitted":
|
if step == "submitted":
|
||||||
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
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 "直属领导"
|
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(
|
return "\n\n".join(
|
||||||
[
|
[
|
||||||
"申请单据已生成,并已进入审批流程。",
|
submitted_title,
|
||||||
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
|
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
|
||||||
f"申请单号:{application_no}",
|
f"申请单号:{application_no}",
|
||||||
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
|
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
|
||||||
@@ -930,6 +940,101 @@ class UserAgentApplicationMixin:
|
|||||||
return "会务费用申请"
|
return "会务费用申请"
|
||||||
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(
|
def _create_expense_application_record(
|
||||||
self,
|
self,
|
||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: digital-employee-reminder-scanner
|
||||||
|
description: 按计划扫描审批、预算、差旅申请和逾期报销,生成数字员工提醒事项。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 定时提醒与待办扫描
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
该技能面向后台数字员工运行,用于把财务流程中需要人工跟进的事项沉淀为提醒记录,支持后续通知中心、审批提醒和预算编制提醒。
|
||||||
|
|
||||||
|
## 输入范围
|
||||||
|
|
||||||
|
- 待审批报销单和审批节点。
|
||||||
|
- 预算编制周期、预算管理员和预算状态。
|
||||||
|
- 差旅申请有效期、关联报销状态和逾期情况。
|
||||||
|
|
||||||
|
## 输出要求
|
||||||
|
|
||||||
|
- 输出提醒人数、提醒事项数和提醒类型分布。
|
||||||
|
- 标记对应业务对象,便于前端跳转到审批、预算、报销或风险详情。
|
||||||
|
- 只生成提醒和待办,不代替负责人完成审批或预算编制。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: finance-dashboard-snapshot-analyst
|
||||||
|
description: 按计划统计报销、预算、费用结构和高额单据,刷新财务看板缓存并沉淀经营指标。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 财务经营快照沉淀
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
该技能面向后台数字员工运行,按固定周期读取报销单、费用明细、预算快照和员工画像,生成财务看板所需的经营指标缓存。
|
||||||
|
|
||||||
|
## 输入范围
|
||||||
|
|
||||||
|
- 报销单、费用明细和付款状态。
|
||||||
|
- 部门、个人、费用类型和预算维度。
|
||||||
|
- 员工画像与历史费用基线。
|
||||||
|
|
||||||
|
## 输出要求
|
||||||
|
|
||||||
|
- 输出当期报销金额、报销单数、预算使用率和费用结构。
|
||||||
|
- 输出高额单据、高费用个人和重点部门的排行摘要。
|
||||||
|
- 只沉淀事实和指标,不直接修改预算、规则或审批结论。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: finance-report-orchestrator
|
||||||
|
description: 按周、季、年整合费用、预算、风险、画像和提醒结果,生成图文 PDF 财务报告,并按系统邮箱设置投递给财务管理人员。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 财务报告编排与邮件投递
|
||||||
|
|
||||||
|
## 定位
|
||||||
|
|
||||||
|
本技能是数字员工的后台报告编排能力,不负责审批、付款、预算调整或规则发布。
|
||||||
|
|
||||||
|
它只读取已沉淀的数据,形成管理层可阅读的财务报告。
|
||||||
|
|
||||||
|
## 输入
|
||||||
|
|
||||||
|
- 财务看板快照。
|
||||||
|
- 报销单、费用明细和高额单据。
|
||||||
|
- 预算池、预算流水和预算预占。
|
||||||
|
- 风险观察、风险反馈和待复核线索。
|
||||||
|
- 员工行为画像。
|
||||||
|
- 定时提醒扫描结果。
|
||||||
|
- 系统设置中的 SMTP 和默认收件人。
|
||||||
|
|
||||||
|
## 输出
|
||||||
|
|
||||||
|
- 周报、季报或年报 PDF。
|
||||||
|
- HTML 报告副本。
|
||||||
|
- 邮件投递状态。
|
||||||
|
- 数字员工运行记录和工具调用记录。
|
||||||
|
- 管理摘要、关键指标和行动清单。
|
||||||
|
|
||||||
|
## 工作原则
|
||||||
|
|
||||||
|
- 结论先行:先给金额、预算、风险和行动建议。
|
||||||
|
- 图表辅助:趋势、排行、预算和风险尽量用图形表达。
|
||||||
|
- 口径一致:金额、单数、预算和状态沿用财务看板口径。
|
||||||
|
- 可追踪:PDF 路径、收件人、发送状态和失败原因必须写入运行记录。
|
||||||
|
- 可降级:SMTP 未配置时只生成报告,不阻断任务。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 不自动修改单据状态。
|
||||||
|
- 不自动调整预算。
|
||||||
|
- 不生成或发布风险规则。
|
||||||
|
- 不向未配置的真实邮箱发送邮件。
|
||||||
|
- 发送失败只记录状态和建议,不重复轰炸收件人。
|
||||||
@@ -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, 1, tzinfo=UTC))
|
||||||
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, 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 admin_claim_count == 0
|
||||||
assert visible_claim_count is not None
|
assert visible_claim_count is not None
|
||||||
assert 400 <= visible_claim_count <= 500
|
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)
|
||||||
|
|||||||
@@ -157,3 +157,92 @@ def test_digital_employee_dashboard_keeps_empty_payload_without_fake_data() -> N
|
|||||||
assert dashboard.totals["totalRuns"] == 0
|
assert dashboard.totals["totalRuns"] == 0
|
||||||
assert dashboard.daily_work
|
assert dashboard.daily_work
|
||||||
assert dashboard.task_distribution == []
|
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"
|
||||||
|
|||||||
176
server/tests/test_digital_employee_reminder_task.py
Normal file
176
server/tests/test_digital_employee_reminder_task.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -5,14 +5,17 @@ from typing import Any
|
|||||||
|
|
||||||
from app.core.agent_enums import AgentName
|
from app.core.agent_enums import AgentName
|
||||||
from app.services.agent_foundation_constants import (
|
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_FINANCE_POLICY_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
||||||
|
DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE,
|
||||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
|
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
|
||||||
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP,
|
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP,
|
||||||
)
|
)
|
||||||
from app.services.agent_foundation_digital_employee_tasks import (
|
from app.services.agent_foundation_digital_employee_tasks import (
|
||||||
AgentFoundationDigitalEmployeeTaskMixin,
|
|
||||||
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
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]
|
categories = [str(spec["skill_category"]) for spec in specs]
|
||||||
skill_names = [str(dict(spec["config"])["skill_name"]) 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 len(set(codes)) == len(codes)
|
||||||
assert set(categories) == set(DIGITAL_EMPLOYEE_SKILL_CATEGORIES)
|
assert set(categories) == set(DIGITAL_EMPLOYEE_SKILL_CATEGORIES)
|
||||||
assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE] == "积累"
|
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]:
|
for skill_name in ["finance-policy-knowledge-organizer", *skill_names]:
|
||||||
skill_file = _skill_root() / skill_name / "SKILL.md"
|
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 "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 "rule-execution-case-organizer" in skill_names
|
||||||
assert "policy-reference-gap-hinter" in skill_names
|
assert "policy-reference-gap-hinter" in skill_names
|
||||||
assert "risk-rule-discovery" not in skill_names
|
assert "risk-rule-discovery" not in skill_names
|
||||||
|
|||||||
@@ -3541,12 +3541,63 @@ def test_direct_manager_cannot_delete_application_claim() -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
claim_id = claim.id
|
claim_id = claim.id
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="申请单只有系统管理员可以删除"):
|
with pytest.raises(ValueError, match="只有草稿、待补充或退回待提交状态的单据"):
|
||||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||||
|
|
||||||
assert db.get(ExpenseClaim, claim_id) is not None
|
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:
|
def test_admin_can_delete_application_claim() -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="superadmin",
|
username="superadmin",
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
from app.models.agent_run import AgentRun
|
||||||
from app.models.budget import BudgetAllocation, BudgetTransaction
|
from app.models.budget import BudgetAllocation, BudgetTransaction
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.models.risk_observation import RiskObservation
|
from app.models.risk_observation import RiskObservation
|
||||||
from app.services.finance_dashboard import FinanceDashboardService
|
from app.services.finance_dashboard import FinanceDashboardService
|
||||||
|
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
|
||||||
|
|
||||||
|
|
||||||
def build_session() -> Session:
|
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["reimbursementCount"] == 2
|
||||||
assert dashboard.totals["reimbursementAmount"] == 2000.0
|
assert dashboard.totals["reimbursementAmount"] == 2000.0
|
||||||
assert dashboard.totals["pendingPaymentAmount"] == 0.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 "AP-DASH-ADMIN-001" not in str(dashboard.trend)
|
||||||
assert dashboard.spend_by_category[0]["value"] == 1200.0
|
assert dashboard.spend_by_category[0]["value"] == 1200.0
|
||||||
assert dashboard.department_ranking[0]["name"] == "财务部"
|
assert dashboard.department_ranking[0]["name"] == "财务部"
|
||||||
assert dashboard.department_ranking[0]["amount"] == 1200.0
|
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]["name"] == "陈雨晴"
|
||||||
|
assert dashboard.employee_ranking[0]["count"] == 1
|
||||||
assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001"
|
assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001"
|
||||||
assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims)
|
assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims)
|
||||||
assert dashboard.budget_summary["ratio"] == 40.0
|
assert dashboard.budget_summary["ratio"] == 40.0
|
||||||
@@ -226,7 +232,7 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N
|
|||||||
ExpenseClaim(
|
ExpenseClaim(
|
||||||
claim_no="CLM-DASH-LABEL-003",
|
claim_no="CLM-DASH-LABEL-003",
|
||||||
employee_name="reimbursement-user",
|
employee_name="reimbursement-user",
|
||||||
department_name="甯傚満閮?,
|
department_name="Market",
|
||||||
expense_type="travel",
|
expense_type="travel",
|
||||||
reason="real travel reimbursement",
|
reason="real travel reimbursement",
|
||||||
location="Shanghai",
|
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["claimCount"][-1] == 1
|
||||||
assert dashboard.trend["claimAmount"][-1] == 700.0
|
assert dashboard.trend["claimAmount"][-1] == 700.0
|
||||||
assert dashboard.trend["applications"] == dashboard.trend["claimCount"]
|
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 dashboard.department_ranking[0]["amount"] == 700.0
|
||||||
assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names)
|
assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names)
|
||||||
assert "风险金额" not in focus_names
|
assert "风险金额" not in focus_names
|
||||||
assert "材料待补" not in focus_names
|
assert "材料待补" not in focus_names
|
||||||
assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks)
|
assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks)
|
||||||
assert len(dashboard.budget_metrics) == 6
|
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
|
||||||
|
|||||||
99
server/tests/test_finance_report_task.py
Normal file
99
server/tests/test_finance_report_task.py
Normal 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()
|
||||||
@@ -12,6 +12,8 @@ from app.models.employee import Employee
|
|||||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
from app.models.organization import OrganizationUnit
|
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
|
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]:
|
def _build_session_factory() -> sessionmaker[Session]:
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite+pysqlite:///:memory:",
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
|||||||
@@ -693,6 +693,92 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
|
|||||||
assert second_response.draft_payload is None
|
assert second_response.draft_payload is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_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:
|
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -123,7 +123,6 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-row,
|
|
||||||
.progress-row {
|
.progress-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
@@ -131,12 +130,10 @@
|
|||||||
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
|
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-row:first-child,
|
|
||||||
.progress-row:first-child {
|
.progress-row:first-child {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-row:hover,
|
|
||||||
.progress-row:hover {
|
.progress-row:hover {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)),
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy {
|
.assistant-copy {
|
||||||
width: min(1040px, 92%);
|
width: min(940px, 92%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy h1 {
|
.assistant-copy h1 {
|
||||||
@@ -71,11 +71,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.capability-grid--privileged {
|
.capability-grid--privileged {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-grid--standard {
|
.capability-grid--standard {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-card {
|
.capability-card {
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-content-grid {
|
.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;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,25 +202,15 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-row,
|
|
||||||
.progress-row {
|
.progress-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-row {
|
|
||||||
grid-template-columns: 48px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-meta,
|
|
||||||
.progress-result {
|
.progress-result {
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-meta {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-steps {
|
.progress-steps {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -332,30 +322,6 @@
|
|||||||
font-size: 11px;
|
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)双行重构 */
|
/* 重点优化:费用进度行的网格区域(Grid Area)双行重构 */
|
||||||
.progress-row {
|
.progress-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
.assistant-copy {
|
.assistant-copy {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: min(1120px, 94%);
|
width: min(980px, 94%);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--hero-copy-gap);
|
gap: var(--hero-copy-gap);
|
||||||
}
|
}
|
||||||
@@ -130,6 +130,7 @@
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
max-width: 920px;
|
||||||
min-height: var(--composer-min-height);
|
min-height: var(--composer-min-height);
|
||||||
padding: var(--composer-padding-block) 18px 10px;
|
padding: var(--composer-padding-block) 18px 10px;
|
||||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
|
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
|
||||||
@@ -416,7 +417,7 @@
|
|||||||
|
|
||||||
.workbench-content-grid {
|
.workbench-content-grid {
|
||||||
display: 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;
|
gap: 14px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -434,7 +435,6 @@
|
|||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-panel,
|
|
||||||
.progress-panel,
|
.progress-panel,
|
||||||
.side-panel {
|
.side-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -493,7 +493,6 @@
|
|||||||
color: var(--workbench-muted);
|
color: var(--workbench-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-list,
|
|
||||||
.progress-list {
|
.progress-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -501,45 +500,11 @@
|
|||||||
grid-auto-rows: minmax(0, 1fr);
|
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 {
|
.progress-row:first-child {
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
border-top: 0;
|
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-identity strong,
|
||||||
.progress-result strong {
|
.progress-result strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -551,8 +516,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-copy small,
|
|
||||||
.todo-meta small,
|
|
||||||
.progress-identity small {
|
.progress-identity small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--workbench-muted);
|
color: var(--workbench-muted);
|
||||||
@@ -562,14 +525,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-meta {
|
|
||||||
min-width: 96px;
|
|
||||||
display: grid;
|
|
||||||
justify-items: end;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-status,
|
|
||||||
.progress-status {
|
.progress-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -582,33 +537,16 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-status--warning,
|
|
||||||
.progress-status--warning {
|
.progress-status--warning {
|
||||||
background: var(--warning-soft);
|
background: var(--warning-soft);
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-status--success,
|
|
||||||
.progress-status--success {
|
.progress-status--success {
|
||||||
background: var(--workbench-primary-soft);
|
background: var(--workbench-primary-soft);
|
||||||
color: var(--workbench-primary-active);
|
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 {
|
.progress-status--muted {
|
||||||
background: var(--info-soft);
|
background: var(--info-soft);
|
||||||
color: var(--workbench-muted);
|
color: var(--workbench-muted);
|
||||||
@@ -708,7 +646,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.capability-card:hover,
|
.capability-card:hover,
|
||||||
.todo-row:hover,
|
|
||||||
.progress-row:hover,
|
.progress-row:hover,
|
||||||
.quick-prompts button:hover,
|
.quick-prompts button:hover,
|
||||||
.composer-icon-button:hover {
|
.composer-icon-button:hover {
|
||||||
|
|||||||
@@ -403,6 +403,11 @@
|
|||||||
color: var(--theme-primary-active);
|
color: var(--theme-primary-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-badge {
|
.notification-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
@@ -423,6 +428,179 @@
|
|||||||
box-shadow: 0 5px 10px rgba(239, 68, 68, .22);
|
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 {
|
.company-switcher {
|
||||||
max-width: min(220px, 28vw);
|
max-width: min(220px, 28vw);
|
||||||
height: 38px;
|
height: 38px;
|
||||||
@@ -593,6 +771,61 @@
|
|||||||
.title-group {
|
.title-group {
|
||||||
padding-right: 56px;
|
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) {
|
@media (max-width: 640px) {
|
||||||
@@ -676,3 +909,22 @@
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,19 +142,16 @@
|
|||||||
|
|
||||||
.trend-count-panel,
|
.trend-count-panel,
|
||||||
.donut-panel,
|
.donut-panel,
|
||||||
.rank-panel,
|
|
||||||
.employee-rank-panel,
|
|
||||||
.top-claim-panel,
|
|
||||||
.budget-metrics-panel,
|
|
||||||
.bottleneck-panel,
|
|
||||||
.budget-panel,
|
|
||||||
.model-panel,
|
.model-panel,
|
||||||
.feedback-panel {
|
.feedback-panel {
|
||||||
grid-column: span 3;
|
grid-column: span 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottleneck-panel,
|
.rank-panel,
|
||||||
|
.employee-rank-panel,
|
||||||
|
.top-claim-panel,
|
||||||
.budget-metrics-panel,
|
.budget-metrics-panel,
|
||||||
|
.bottleneck-panel,
|
||||||
.budget-panel {
|
.budget-panel {
|
||||||
grid-column: span 6;
|
grid-column: span 6;
|
||||||
}
|
}
|
||||||
@@ -188,6 +185,21 @@
|
|||||||
width: 110px;
|
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 {
|
.panel-note {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
@@ -581,6 +593,42 @@
|
|||||||
.top-claim-list {
|
.top-claim-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
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 {
|
.top-claim-row {
|
||||||
@@ -873,6 +921,17 @@
|
|||||||
grid-template-columns: 24px 64px minmax(0, 1fr);
|
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 {
|
.budget-metric-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -704,6 +704,7 @@
|
|||||||
.ai-preview-secondary:disabled,
|
.ai-preview-secondary:disabled,
|
||||||
.ai-preview-primary:disabled,
|
.ai-preview-primary:disabled,
|
||||||
.approve-action:disabled,
|
.approve-action:disabled,
|
||||||
|
.secondary-action:disabled,
|
||||||
.return-action:disabled,
|
.return-action:disabled,
|
||||||
.ai-send-btn:disabled {
|
.ai-send-btn:disabled {
|
||||||
opacity: .45;
|
opacity: .45;
|
||||||
|
|||||||
@@ -1645,6 +1645,13 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary-action {
|
||||||
|
min-width: 98px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.return-action {
|
.return-action {
|
||||||
min-width: 98px;
|
min-width: 98px;
|
||||||
border: 1px solid #fed7aa;
|
border: 1px solid #fed7aa;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:pages="pageNumbers"
|
|
||||||
:show-page-size="true"
|
:show-page-size="true"
|
||||||
:summary="paginationSummary"
|
:summary="paginationSummary"
|
||||||
:total="visibleSkills.length"
|
:total="visibleSkills.length"
|
||||||
@@ -326,14 +325,6 @@ const pagedSkills = computed(() => {
|
|||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
return props.visibleSkills.slice(start, start + 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(() =>
|
const paginationSummary = computed(() =>
|
||||||
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
|
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:pages="pageNumbers"
|
|
||||||
:show-page-size="true"
|
:show-page-size="true"
|
||||||
:summary="paginationSummary"
|
:summary="paginationSummary"
|
||||||
:total="visibleEmployees.length"
|
:total="visibleEmployees.length"
|
||||||
@@ -225,14 +224,6 @@ const pagedEmployees = computed(() => {
|
|||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
return props.visibleEmployees.slice(start, start + 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(() =>
|
const paginationSummary = computed(() =>
|
||||||
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -110,6 +110,26 @@
|
|||||||
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察。</p>
|
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察。</p>
|
||||||
</section>
|
</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">
|
<section v-else-if="productKind === 'risk_clue'" class="run-product-section">
|
||||||
<div class="run-product-section-head">
|
<div class="run-product-section-head">
|
||||||
<h4>待复核线索</h4>
|
<h4>待复核线索</h4>
|
||||||
@@ -230,6 +250,12 @@ const productSubtitle = computed(() => {
|
|||||||
if (productKind.value === 'risk_graph') {
|
if (productKind.value === 'risk_graph') {
|
||||||
return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。'
|
return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。'
|
||||||
}
|
}
|
||||||
|
if (productKind.value === 'finance_snapshot') {
|
||||||
|
return '展示本次财务经营快照沉淀的预算、费用和报销统计。'
|
||||||
|
}
|
||||||
|
if (productKind.value === 'reminder_scan') {
|
||||||
|
return '展示本次定时提醒扫描生成的待办和触达结果。'
|
||||||
|
}
|
||||||
if (productKind.value === 'employee_profile') {
|
if (productKind.value === 'employee_profile') {
|
||||||
return '展示本次画像巡检写入的员工画像快照摘要。'
|
return '展示本次画像巡检写入的员工画像快照摘要。'
|
||||||
}
|
}
|
||||||
@@ -245,6 +271,12 @@ const productBadge = computed(() => {
|
|||||||
if (productKind.value === 'risk_graph') {
|
if (productKind.value === 'risk_graph') {
|
||||||
return '风险观察'
|
return '风险观察'
|
||||||
}
|
}
|
||||||
|
if (productKind.value === 'finance_snapshot') {
|
||||||
|
return '财务快照'
|
||||||
|
}
|
||||||
|
if (productKind.value === 'reminder_scan') {
|
||||||
|
return '提醒事项'
|
||||||
|
}
|
||||||
if (productKind.value === 'employee_profile') {
|
if (productKind.value === 'employee_profile') {
|
||||||
return '画像快照'
|
return '画像快照'
|
||||||
}
|
}
|
||||||
@@ -281,6 +313,25 @@ const metrics = computed(() => {
|
|||||||
buildMetric('图谱关系', payload.graph_edge_count)
|
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') {
|
if (productKind.value === 'employee_profile') {
|
||||||
return [
|
return [
|
||||||
buildMetric('目标员工', payload.target_employee_count),
|
buildMetric('目标员工', payload.target_employee_count),
|
||||||
@@ -376,6 +427,23 @@ function formatWindowDays(value) {
|
|||||||
return days.length ? days.map((item) => `${item}天`).join(' / ') : '-'
|
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) {
|
function observationGraphCount(item) {
|
||||||
return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length
|
return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
|
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:pages="pageNumbers"
|
|
||||||
:show-page-size="false"
|
:show-page-size="false"
|
||||||
:summary="paginationSummary"
|
:summary="paginationSummary"
|
||||||
:total="filteredRuns.length"
|
:total="filteredRuns.length"
|
||||||
@@ -303,6 +302,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
formatWorkRecordDateTime,
|
formatWorkRecordDateTime,
|
||||||
formatWorkRecordSummary,
|
formatWorkRecordSummary,
|
||||||
|
compactDigitalEmployeeWorkRecords,
|
||||||
resolveWorkRecordModuleLabel,
|
resolveWorkRecordModuleLabel,
|
||||||
resolveWorkRecordSourceLabel,
|
resolveWorkRecordSourceLabel,
|
||||||
resolveWorkRecordStatusLabel,
|
resolveWorkRecordStatusLabel,
|
||||||
@@ -456,14 +456,6 @@ const visibleRuns = computed(() => {
|
|||||||
return filteredRuns.value.slice(start, start + pageSize)
|
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(() =>
|
const paginationSummary = computed(() =>
|
||||||
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||||
)
|
)
|
||||||
@@ -523,7 +515,7 @@ async function loadWorkRecords(showToast = false) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
|
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
|
||||||
runs.value = Array.isArray(payload) ? payload : []
|
runs.value = Array.isArray(payload) ? compactDigitalEmployeeWorkRecords(payload) : []
|
||||||
emit('summary-change', {
|
emit('summary-change', {
|
||||||
total: workRecordSummary.value.total,
|
total: workRecordSummary.value.total,
|
||||||
succeeded: workRecordSummary.value.succeeded,
|
succeeded: workRecordSummary.value.succeeded,
|
||||||
|
|||||||
@@ -191,41 +191,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="workbench-content-grid">
|
<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">
|
<article class="panel workbench-card progress-panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>费用进度</h2>
|
<h2>费用进度</h2>
|
||||||
@@ -238,7 +203,7 @@
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="progress-row"
|
class="progress-row"
|
||||||
@click="openPromptAssistant(`查询 ${item.id} 的费用进度`)"
|
@click="openWorkbenchTarget(item)"
|
||||||
>
|
>
|
||||||
<span class="progress-identity">
|
<span class="progress-identity">
|
||||||
<strong>{{ item.id }}</strong>
|
<strong>{{ item.id }}</strong>
|
||||||
@@ -247,17 +212,17 @@
|
|||||||
|
|
||||||
<span class="progress-steps" aria-hidden="true">
|
<span class="progress-steps" aria-hidden="true">
|
||||||
<span
|
<span
|
||||||
v-for="(step, index) in progressSteps"
|
v-for="step in item.steps"
|
||||||
:key="step"
|
:key="step.label"
|
||||||
class="progress-step"
|
class="progress-step"
|
||||||
:class="{
|
:class="{
|
||||||
'is-done': index < item.activeStep,
|
'is-done': step.done,
|
||||||
'is-current': index === item.activeStep,
|
'is-current': step.current,
|
||||||
'is-future': index > item.activeStep
|
'is-future': !step.done && !step.current
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<i :class="index <= item.activeStep ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
|
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
|
||||||
<small>{{ step }}</small>
|
<small>{{ step.label }}</small>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -357,7 +322,6 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import PanelHead from '../shared/PanelHead.vue'
|
import PanelHead from '../shared/PanelHead.vue'
|
||||||
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||||||
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
|
||||||
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
|
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
import { useToast } from '../../composables/useToast.js'
|
||||||
@@ -365,11 +329,8 @@ import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposer
|
|||||||
import {
|
import {
|
||||||
buildExpenseStatItems,
|
buildExpenseStatItems,
|
||||||
filterAssistantCapabilitiesForUser,
|
filterAssistantCapabilitiesForUser,
|
||||||
progressItems,
|
|
||||||
progressSteps,
|
|
||||||
quickPromptItems,
|
quickPromptItems,
|
||||||
resolveWorkbenchCapabilityGridClass,
|
resolveWorkbenchCapabilityGridClass,
|
||||||
todoItems,
|
|
||||||
} from '../../data/personalWorkbench.js'
|
} from '../../data/personalWorkbench.js'
|
||||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
||||||
@@ -394,7 +355,7 @@ const props = defineProps({
|
|||||||
workbenchSummary: { type: Object, default: () => ({}) }
|
workbenchSummary: { type: Object, default: () => ({}) }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['open-assistant'])
|
const emit = defineEmits(['open-assistant', 'open-document'])
|
||||||
const { currentUser } = useSystemState()
|
const { currentUser } = useSystemState()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const assistantDraft = ref('')
|
const assistantDraft = ref('')
|
||||||
@@ -494,9 +455,12 @@ const currentUserProfileKey = computed(() => {
|
|||||||
user.employee_no
|
user.employee_no
|
||||||
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
|
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
|
||||||
})
|
})
|
||||||
const visibleTodoItems = computed(() => todoItems.slice(0, 5))
|
const visibleProgressItems = computed(() => {
|
||||||
const visibleProgressItems = computed(() => progressItems.slice(0, 5))
|
const rows = Array.isArray(props.workbenchSummary.progressItems)
|
||||||
const todoAlertCount = computed(() => visibleTodoItems.value.length)
|
? props.workbenchSummary.progressItems
|
||||||
|
: []
|
||||||
|
return rows.slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
function buildSelectedFileKey(file) {
|
function buildSelectedFileKey(file) {
|
||||||
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
||||||
@@ -625,6 +589,20 @@ function openPromptAssistant(prompt) {
|
|||||||
emitAssistant(payload)
|
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) {
|
function openCapabilityAssistant(item) {
|
||||||
if (pendingAction.value) {
|
if (pendingAction.value) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<template v-else>{{ idx + 1 }}</template>
|
<template v-else>{{ idx + 1 }}</template>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="rank-copy">
|
||||||
<span class="rank-name">{{ item.name || item.shortName }}</span>
|
<span class="rank-name">{{ item.name || item.shortName }}</span>
|
||||||
|
<small v-if="item.meta" class="rank-meta">{{ item.meta }}</small>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="chartElement" class="chart-area" role="img" :aria-label="ariaLabel"></div>
|
<div ref="chartElement" class="chart-area" role="img" :aria-label="ariaLabel"></div>
|
||||||
@@ -90,7 +93,11 @@ const chartOptions = computed(() => ({
|
|||||||
fontWeight: 700
|
fontWeight: 700
|
||||||
},
|
},
|
||||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
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: {
|
xAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
@@ -180,7 +187,8 @@ const formatValue = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rank-labels {
|
.rank-labels {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 min(34%, 150px);
|
||||||
|
min-width: 112px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
@@ -214,10 +222,24 @@ const formatValue = (value) => {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rank-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.rank-name {
|
.rank-name {
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 13px;
|
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 {
|
.chart-area {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<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">
|
<article class="panel dashboard-card risk-trend-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
<div class="risk-window-controls">
|
<div class="risk-window-controls">
|
||||||
|
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
|
||||||
<span class="risk-window-label">近 {{ dashboard.windowDays }} 天</span>
|
<span class="risk-window-label">近 {{ dashboard.windowDays }} 天</span>
|
||||||
<EnterpriseSelect
|
<EnterpriseSelect
|
||||||
class="risk-window-select"
|
class="risk-window-select"
|
||||||
@@ -174,11 +179,29 @@ const props = defineProps({
|
|||||||
signalRanking: { type: Array, default: () => [] },
|
signalRanking: { type: Array, default: () => [] },
|
||||||
dailyRows: { type: Array, default: () => [] },
|
dailyRows: { type: Array, default: () => [] },
|
||||||
windowOptions: { 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 emit = defineEmits(['update:windowDays'])
|
||||||
|
|
||||||
const router = useRouter()
|
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 errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
|
||||||
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
|
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
|
||||||
const dimensionGroups = computed(() => [
|
const dimensionGroups = computed(() => [
|
||||||
@@ -315,12 +338,39 @@ function openClaim(item) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.risk-observation-dashboard {
|
.risk-observation-dashboard {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
min-width: 0;
|
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 {
|
.dashboard-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -359,6 +409,13 @@ function openClaim(item) {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.risk-refresh-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.risk-window-controls {
|
.risk-window-controls {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="topbar" :class="{ 'chat-mode': isChat }">
|
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
|
||||||
<div class="title-group">
|
<div class="title-group">
|
||||||
<div class="eyebrow">{{ eyebrowLabel }}</div>
|
<div class="eyebrow">{{ eyebrowLabel }}</div>
|
||||||
<h1>{{ currentView.title }}</h1>
|
<h1>{{ currentView.title }}</h1>
|
||||||
@@ -123,11 +123,72 @@
|
|||||||
|
|
||||||
<template v-else-if="isWorkbench">
|
<template v-else-if="isWorkbench">
|
||||||
<div class="topbar-toolset" aria-label="工作台快捷工具">
|
<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>
|
<i class="mdi mdi-bell-outline"></i>
|
||||||
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
||||||
</button>
|
</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="帮助">
|
<button class="topbar-icon-btn" type="button" aria-label="帮助">
|
||||||
<i class="mdi mdi-help-circle-outline"></i>
|
<i class="mdi mdi-help-circle-outline"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -243,6 +304,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
|
workbenchSummary: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
companyName: {
|
companyName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
@@ -276,7 +341,8 @@ const emit = defineEmits([
|
|||||||
'update:overviewDashboard',
|
'update:overviewDashboard',
|
||||||
'batchApprove',
|
'batchApprove',
|
||||||
'openChat',
|
'openChat',
|
||||||
'newApplication'
|
'newApplication',
|
||||||
|
'openDocument'
|
||||||
])
|
])
|
||||||
const isChat = computed(() => props.activeView === 'chat')
|
const isChat = computed(() => props.activeView === 'chat')
|
||||||
const isOverview = computed(() => props.activeView === 'overview')
|
const isOverview = computed(() => props.activeView === 'overview')
|
||||||
@@ -294,10 +360,34 @@ const eyebrowLabel = computed(() => (
|
|||||||
))
|
))
|
||||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
||||||
const topbarNotificationCount = computed(() => {
|
const topbarNotificationCount = computed(() => {
|
||||||
const summary = props.documentSummary ?? {}
|
const summary = props.workbenchSummary ?? {}
|
||||||
const count = Number(summary.toProcess ?? summary.toSubmit ?? 8)
|
const count = Number(summary.unreadNotificationCount ?? 0)
|
||||||
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 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 requestKpis = computed(() => {
|
||||||
const summary = props.requestSummary ?? {}
|
const summary = props.requestSummary ?? {}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@
|
|||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:pages="pages"
|
|
||||||
:show-page-size="showPageSize"
|
:show-page-size="showPageSize"
|
||||||
:summary="summary"
|
:summary="summary"
|
||||||
:total="total"
|
:total="total"
|
||||||
@@ -139,10 +138,6 @@ const props = defineProps({
|
|||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
panel: { type: Boolean, default: true },
|
panel: { type: Boolean, default: true },
|
||||||
pages: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
retryLabel: { type: String, default: '重新加载' },
|
retryLabel: { type: String, default: '重新加载' },
|
||||||
searchable: { type: Boolean, default: false },
|
searchable: { type: Boolean, default: false },
|
||||||
searchPlaceholder: { type: String, default: '搜索' },
|
searchPlaceholder: { type: String, default: '搜索' },
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
pages: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
showPageSize: { type: Boolean, default: true },
|
showPageSize: { type: Boolean, default: true },
|
||||||
summary: { type: String, default: '' },
|
summary: { type: String, default: '' },
|
||||||
total: { type: Number, default: 0 },
|
total: { type: Number, default: 0 },
|
||||||
@@ -106,7 +102,7 @@ const summaryText = computed(() => {
|
|||||||
return props.summary
|
return props.summary
|
||||||
}
|
}
|
||||||
|
|
||||||
return `共 ${props.total} 条,当前第 ${props.currentPage} 页`
|
return `共 ${props.total} 条,当前第 ${props.currentPage} / ${props.totalPages} 页`
|
||||||
})
|
})
|
||||||
|
|
||||||
function setPage(page) {
|
function setPage(page) {
|
||||||
@@ -142,3 +138,140 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ export function useAppShell() {
|
|||||||
conversation: null,
|
conversation: null,
|
||||||
scope: null,
|
scope: null,
|
||||||
sessionType: '',
|
sessionType: '',
|
||||||
budgetContext: null
|
budgetContext: null,
|
||||||
|
initialPromptAutoSubmit: true,
|
||||||
|
initialApplicationPreview: null
|
||||||
})
|
})
|
||||||
const smartEntrySessionId = ref(0)
|
const smartEntrySessionId = ref(0)
|
||||||
const smartEntryRevealToken = ref(0)
|
const smartEntryRevealToken = ref(0)
|
||||||
const smartEntryInvalidatedDraftClaimId = ref('')
|
const smartEntryInvalidatedDraftClaimId = ref('')
|
||||||
const selectedRequestSnapshot = ref(null)
|
const selectedRequestSnapshot = ref(null)
|
||||||
|
const documentCenterRefreshToken = ref(0)
|
||||||
|
|
||||||
const { activeView, currentView, setView } = useNavigation()
|
const { activeView, currentView, setView } = useNavigation()
|
||||||
const {
|
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(
|
watch(
|
||||||
requestsNeeded,
|
() => [activeView.value, route.name],
|
||||||
(isNeeded) => {
|
([view]) => {
|
||||||
if (isNeeded) {
|
if (view === 'documents') {
|
||||||
|
void reloadDocumentCenterRequests()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (view === 'workbench') {
|
||||||
void ensureRequestsLoaded()
|
void ensureRequestsLoaded()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -168,7 +178,14 @@ export function useAppShell() {
|
|||||||
|
|
||||||
function handleNavigate(view) {
|
function handleNavigate(view) {
|
||||||
smartEntryOpen.value = false
|
smartEntryOpen.value = false
|
||||||
|
const shouldRefreshCurrentDocumentCenter =
|
||||||
|
view === 'documents'
|
||||||
|
&& activeView.value === 'documents'
|
||||||
|
&& route.name === 'app-documents'
|
||||||
setView(view)
|
setView(view)
|
||||||
|
if (shouldRefreshCurrentDocumentCenter) {
|
||||||
|
void reloadDocumentCenterRequests()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFinancialAssistantCreate(source) {
|
function openFinancialAssistantCreate(source) {
|
||||||
@@ -185,7 +202,9 @@ export function useAppShell() {
|
|||||||
conversation: null,
|
conversation: null,
|
||||||
scope: null,
|
scope: null,
|
||||||
sessionType: '',
|
sessionType: '',
|
||||||
budgetContext: null
|
budgetContext: null,
|
||||||
|
initialPromptAutoSubmit: true,
|
||||||
|
initialApplicationPreview: null
|
||||||
}
|
}
|
||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
}
|
}
|
||||||
@@ -320,6 +339,7 @@ export function useAppShell() {
|
|||||||
|| String(payload?.prompt || '').trim()
|
|| String(payload?.prompt || '').trim()
|
||||||
|| (Array.isArray(payload?.files) && payload.files.length)
|
|| (Array.isArray(payload?.files) && payload.files.length)
|
||||||
|| payload?.conversation
|
|| payload?.conversation
|
||||||
|
|| payload?.applicationPreview
|
||||||
)
|
)
|
||||||
if (smartEntryOpen.value && !shouldReplaceOpenEntry) {
|
if (smartEntryOpen.value && !shouldReplaceOpenEntry) {
|
||||||
smartEntryRevealToken.value += 1
|
smartEntryRevealToken.value += 1
|
||||||
@@ -342,6 +362,10 @@ export function useAppShell() {
|
|||||||
sessionType,
|
sessionType,
|
||||||
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
|
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
|
||||||
? payload.budgetContext
|
? payload.budgetContext
|
||||||
|
: null,
|
||||||
|
initialPromptAutoSubmit: payload.initialPromptAutoSubmit !== false,
|
||||||
|
initialApplicationPreview: payload.applicationPreview && typeof payload.applicationPreview === 'object'
|
||||||
|
? payload.applicationPreview
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
@@ -410,6 +434,7 @@ export function useAppShell() {
|
|||||||
currentView,
|
currentView,
|
||||||
customRange,
|
customRange,
|
||||||
detailMode,
|
detailMode,
|
||||||
|
documentCenterRefreshToken,
|
||||||
filteredRequests,
|
filteredRequests,
|
||||||
filters,
|
filters,
|
||||||
handleApprove,
|
handleApprove,
|
||||||
@@ -429,6 +454,7 @@ export function useAppShell() {
|
|||||||
requestsError,
|
requestsError,
|
||||||
requestsLoading,
|
requestsLoading,
|
||||||
reloadRequests,
|
reloadRequests,
|
||||||
|
reloadDocumentCenterRequests,
|
||||||
requests,
|
requests,
|
||||||
search,
|
search,
|
||||||
selectedRequest,
|
selectedRequest,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchDigitalEmployeeDashboard,
|
fetchDigitalEmployeeDashboard,
|
||||||
@@ -92,6 +92,9 @@ export function useOverviewView(options = {}) {
|
|||||||
const riskDashboardPayload = ref(null)
|
const riskDashboardPayload = ref(null)
|
||||||
const riskDashboardLoading = ref(false)
|
const riskDashboardLoading = ref(false)
|
||||||
const riskDashboardError = ref(null)
|
const riskDashboardError = ref(null)
|
||||||
|
const riskDashboardLastUpdatedAt = ref('')
|
||||||
|
let riskDashboardRefreshTimer = 0
|
||||||
|
let riskDashboardRequestSeq = 0
|
||||||
const digitalEmployeeDashboardPayload = ref(null)
|
const digitalEmployeeDashboardPayload = ref(null)
|
||||||
const digitalEmployeeDashboardLoading = ref(false)
|
const digitalEmployeeDashboardLoading = ref(false)
|
||||||
const digitalEmployeeDashboardError = ref(null)
|
const digitalEmployeeDashboardError = ref(null)
|
||||||
@@ -178,21 +181,52 @@ export function useOverviewView(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadRiskDashboard = async () => {
|
const loadRiskDashboard = async () => {
|
||||||
|
const requestSeq = ++riskDashboardRequestSeq
|
||||||
riskDashboardLoading.value = true
|
riskDashboardLoading.value = true
|
||||||
riskDashboardError.value = null
|
riskDashboardError.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
riskDashboardPayload.value = await fetchRiskObservationDashboard({
|
const payload = await fetchRiskObservationDashboard({
|
||||||
windowDays: activeRiskWindowDays.value,
|
windowDays: activeRiskWindowDays.value,
|
||||||
limit: 500
|
limit: 500
|
||||||
})
|
})
|
||||||
|
if (requestSeq !== riskDashboardRequestSeq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
riskDashboardPayload.value = payload
|
||||||
|
riskDashboardLastUpdatedAt.value = new Date().toISOString()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestSeq !== riskDashboardRequestSeq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
riskDashboardPayload.value = null
|
riskDashboardPayload.value = null
|
||||||
riskDashboardError.value = error
|
riskDashboardError.value = error
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestSeq === riskDashboardRequestSeq) {
|
||||||
riskDashboardLoading.value = false
|
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 () => {
|
const loadDigitalEmployeeDashboard = async () => {
|
||||||
digitalEmployeeDashboardLoading.value = true
|
digitalEmployeeDashboardLoading.value = true
|
||||||
@@ -222,6 +256,11 @@ export function useOverviewView(options = {}) {
|
|||||||
void loadSystemDashboard()
|
void loadSystemDashboard()
|
||||||
void loadRiskDashboard()
|
void loadRiskDashboard()
|
||||||
void loadDigitalEmployeeDashboard()
|
void loadDigitalEmployeeDashboard()
|
||||||
|
startRiskDashboardRealtimeRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopRiskDashboardRealtimeRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -323,6 +362,9 @@ export function useOverviewView(options = {}) {
|
|||||||
const financeDepartmentRanking = computed(() => (
|
const financeDepartmentRanking = computed(() => (
|
||||||
financeDashboardPayload.value?.departmentRanking || []
|
financeDashboardPayload.value?.departmentRanking || []
|
||||||
))
|
))
|
||||||
|
const financeDepartmentEmployeeMix = computed(() => (
|
||||||
|
financeDashboardPayload.value?.departmentEmployeeMix || emptyFinanceDonut
|
||||||
|
))
|
||||||
const financeEmployeeRanking = computed(() => (
|
const financeEmployeeRanking = computed(() => (
|
||||||
financeDashboardPayload.value?.employeeRanking || []
|
financeDashboardPayload.value?.employeeRanking || []
|
||||||
))
|
))
|
||||||
@@ -501,7 +543,11 @@ export function useOverviewView(options = {}) {
|
|||||||
const activeTrend = computed(() => financeTrend.value)
|
const activeTrend = computed(() => financeTrend.value)
|
||||||
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
|
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 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 spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
|
||||||
|
const departmentEmployeeCenterValue = computed(() => formatCurrency(Math.round(departmentEmployeeTotal.value)))
|
||||||
|
|
||||||
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
|
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -513,6 +559,14 @@ export function useOverviewView(options = {}) {
|
|||||||
display: `${item.value} 单`
|
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(() =>
|
const systemToolTotal = computed(() =>
|
||||||
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
|
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
|
||||||
)
|
)
|
||||||
@@ -542,6 +596,7 @@ export function useOverviewView(options = {}) {
|
|||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
shortName: item.name,
|
shortName: item.name,
|
||||||
amountLabel: formatCurrency(item.amount),
|
amountLabel: formatCurrency(item.amount),
|
||||||
|
meta: `${Number(item.employeeCount || 0)} 人 / ${Number(item.count || 0)} 单`,
|
||||||
width: `${Math.max((item.amount / max) * 100, 18)}%`,
|
width: `${Math.max((item.amount / max) * 100, 18)}%`,
|
||||||
color: item.color
|
color: item.color
|
||||||
}))
|
}))
|
||||||
@@ -561,6 +616,7 @@ export function useOverviewView(options = {}) {
|
|||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
shortName: item.name,
|
shortName: item.name,
|
||||||
amountLabel: formatCurrency(item.amount),
|
amountLabel: formatCurrency(item.amount),
|
||||||
|
meta: `${item.department || '未归属部门'} / ${Number(item.count || 0)} 单`,
|
||||||
width: `${Math.max((item.amount / max) * 100, 18)}%`,
|
width: `${Math.max((item.amount / max) * 100, 18)}%`,
|
||||||
color: item.color
|
color: item.color
|
||||||
}))
|
}))
|
||||||
@@ -738,6 +794,8 @@ export function useOverviewView(options = {}) {
|
|||||||
bottlenecks,
|
bottlenecks,
|
||||||
budgetMetrics,
|
budgetMetrics,
|
||||||
budgetSummary,
|
budgetSummary,
|
||||||
|
departmentEmployeeCenterValue,
|
||||||
|
departmentEmployeeLegend,
|
||||||
departmentRangeOptions,
|
departmentRangeOptions,
|
||||||
digitalEmployeeCategoryRows,
|
digitalEmployeeCategoryRows,
|
||||||
digitalEmployeeDashboard,
|
digitalEmployeeDashboard,
|
||||||
@@ -760,6 +818,7 @@ export function useOverviewView(options = {}) {
|
|||||||
rankedEmployees,
|
rankedEmployees,
|
||||||
riskDashboard,
|
riskDashboard,
|
||||||
riskDashboardError,
|
riskDashboardError,
|
||||||
|
riskDashboardLastUpdatedAt,
|
||||||
riskDashboardLoading,
|
riskDashboardLoading,
|
||||||
riskDailyTrendRows,
|
riskDailyTrendRows,
|
||||||
riskLegend,
|
riskLegend,
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const exceptionMix = [
|
|||||||
{ name: '已入账', value: 9, color: 'var(--chart-blue)' }
|
{ name: '已入账', value: 9, color: 'var(--chart-blue)' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const departmentRangeOptions = ['本周', '本月', '本季度']
|
export const departmentRangeOptions = ['本月', '本季度', '本年', '全部']
|
||||||
|
|
||||||
export const bottlenecks = [
|
export const bottlenecks = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const FINANCE_DASHBOARD_FALLBACK = {
|
|||||||
spendByCategory: null,
|
spendByCategory: null,
|
||||||
exceptionMix: null,
|
exceptionMix: null,
|
||||||
departmentRanking: null,
|
departmentRanking: null,
|
||||||
|
departmentEmployeeMix: null,
|
||||||
employeeRanking: null,
|
employeeRanking: null,
|
||||||
topClaims: null,
|
topClaims: null,
|
||||||
bottlenecks: null,
|
bottlenecks: null,
|
||||||
@@ -69,6 +70,7 @@ function normalizeFinanceDashboardPayload(payload = {}) {
|
|||||||
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
|
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
|
||||||
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
|
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
|
||||||
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
|
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
|
||||||
|
departmentEmployeeMix: payload.department_employee_mix || payload.departmentEmployeeMix || null,
|
||||||
employeeRanking: payload.employee_ranking || payload.employeeRanking || null,
|
employeeRanking: payload.employee_ranking || payload.employeeRanking || null,
|
||||||
topClaims: payload.top_claims || payload.topClaims || null,
|
topClaims: payload.top_claims || payload.topClaims || null,
|
||||||
bottlenecks: payload.bottlenecks || null,
|
bottlenecks: payload.bottlenecks || null,
|
||||||
@@ -129,7 +131,7 @@ export async function fetchFinanceDashboard(options = {}) {
|
|||||||
if (options.endDate) search.set('end_date', String(options.endDate))
|
if (options.endDate) search.set('end_date', String(options.endDate))
|
||||||
|
|
||||||
const payload = await apiRequest(`/analytics/finance-dashboard?${search.toString()}`, {
|
const payload = await apiRequest(`/analytics/finance-dashboard?${search.toString()}`, {
|
||||||
timeoutMs: Number(options.timeoutMs || 3500),
|
timeoutMs: Number(options.timeoutMs || 10000),
|
||||||
timeoutMessage: '财务看板真实数据加载超时,已保留本地展示数据。'
|
timeoutMessage: '财务看板真实数据加载超时,已保留本地展示数据。'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ function resolveDaysFromDateRange(rangeText) {
|
|||||||
return diffDays >= 0 ? `${diffDays + 1}天` : ''
|
return diffDays >= 0 ? `${diffDays + 1}天` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||||
|
return resolveDaysFromDateRange(rangeText)
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePreviewToday(options = {}) {
|
function resolvePreviewToday(options = {}) {
|
||||||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||||||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||||||
|
|||||||
119
web/src/utils/travelApplicationPlanning.js
Normal file
119
web/src/utils/travelApplicationPlanning.js
Normal 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')
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,10 @@ function parseNumber(value) {
|
|||||||
return Number.isFinite(nextValue) ? nextValue : 0
|
return Number.isFinite(nextValue) ? nextValue : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
function toDate(value) {
|
function toDate(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
@@ -60,6 +64,174 @@ function formatCurrency(value) {
|
|||||||
}).format(parseNumber(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) {
|
export function buildWorkbenchSummary(requests, currentUser) {
|
||||||
const ownedRequests = Array.isArray(requests)
|
const ownedRequests = Array.isArray(requests)
|
||||||
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
|
? 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 completedCount = ownedRequests.filter((item) => item.approvalKey === 'completed').length
|
||||||
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
|
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
|
||||||
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
|
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
|
||||||
|
const todoItems = buildTodoItems(ownedRequests)
|
||||||
|
const progressItems = buildProgressItems(ownedRequests)
|
||||||
|
const notifications = buildNotifications(todoItems, progressItems)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
monthlyCount,
|
monthlyCount,
|
||||||
@@ -91,6 +266,10 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
|||||||
pendingPaymentCount,
|
pendingPaymentCount,
|
||||||
completedCount,
|
completedCount,
|
||||||
returnCount,
|
returnCount,
|
||||||
highRiskCount
|
highRiskCount,
|
||||||
|
todoItems,
|
||||||
|
progressItems,
|
||||||
|
notifications,
|
||||||
|
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
:knowledge-summary="knowledgeSummary"
|
:knowledge-summary="knowledgeSummary"
|
||||||
:request-summary="requestSummary"
|
:request-summary="requestSummary"
|
||||||
:document-summary="documentSummary"
|
:document-summary="documentSummary"
|
||||||
|
:workbench-summary="workbenchSummary"
|
||||||
:digital-employee-summary="digitalEmployeeSummary"
|
:digital-employee-summary="digitalEmployeeSummary"
|
||||||
:company-name="ENTERPRISE_DISPLAY_NAME"
|
:company-name="ENTERPRISE_DISPLAY_NAME"
|
||||||
:detail-mode="resolvedDetailMode"
|
:detail-mode="resolvedDetailMode"
|
||||||
@@ -84,6 +85,7 @@
|
|||||||
@update:overview-dashboard="overviewDashboard = $event"
|
@update:overview-dashboard="overviewDashboard = $event"
|
||||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||||
@new-application="openExpenseApplicationCreate"
|
@new-application="openExpenseApplicationCreate"
|
||||||
|
@open-document="openWorkbenchDocument"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -124,6 +126,7 @@
|
|||||||
:assistant-modal-open="smartEntryOpen"
|
:assistant-modal-open="smartEntryOpen"
|
||||||
:workbench-summary="workbenchSummary"
|
:workbench-summary="workbenchSummary"
|
||||||
@open-assistant="openSmartEntry"
|
@open-assistant="openSmartEntry"
|
||||||
|
@open-document="openWorkbenchDocument"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TravelRequestDetailView
|
<TravelRequestDetailView
|
||||||
@@ -142,6 +145,7 @@
|
|||||||
:has-data="requests.length > 0"
|
:has-data="requests.length > 0"
|
||||||
:loading="requestsLoading"
|
:loading="requestsLoading"
|
||||||
:error="requestsError"
|
:error="requestsError"
|
||||||
|
:refresh-token="documentCenterRefreshToken"
|
||||||
@open-document="openRequestDetail"
|
@open-document="openRequestDetail"
|
||||||
@create-request="openTravelCreate"
|
@create-request="openTravelCreate"
|
||||||
@create-application="openExpenseApplicationCreate"
|
@create-application="openExpenseApplicationCreate"
|
||||||
@@ -188,6 +192,8 @@
|
|||||||
:initial-conversation="smartEntryContext.conversation"
|
:initial-conversation="smartEntryContext.conversation"
|
||||||
:initial-session-type="smartEntryContext.sessionType"
|
:initial-session-type="smartEntryContext.sessionType"
|
||||||
:initial-budget-context="smartEntryContext.budgetContext"
|
:initial-budget-context="smartEntryContext.budgetContext"
|
||||||
|
:initial-prompt-auto-submit="smartEntryContext.initialPromptAutoSubmit"
|
||||||
|
:initial-application-preview="smartEntryContext.initialApplicationPreview"
|
||||||
:entry-source="smartEntryContext.source"
|
:entry-source="smartEntryContext.source"
|
||||||
:request-context="smartEntryContext.request"
|
:request-context="smartEntryContext.request"
|
||||||
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
||||||
@@ -274,6 +280,7 @@ const {
|
|||||||
customRange,
|
customRange,
|
||||||
detailAlerts,
|
detailAlerts,
|
||||||
detailMode,
|
detailMode,
|
||||||
|
documentCenterRefreshToken,
|
||||||
filteredRequests,
|
filteredRequests,
|
||||||
filters,
|
filters,
|
||||||
handleApprove,
|
handleApprove,
|
||||||
@@ -292,6 +299,7 @@ const {
|
|||||||
workbenchSummary,
|
workbenchSummary,
|
||||||
requestsError,
|
requestsError,
|
||||||
requestsLoading,
|
requestsLoading,
|
||||||
|
reloadDocumentCenterRequests,
|
||||||
reloadRequests,
|
reloadRequests,
|
||||||
requests,
|
requests,
|
||||||
search,
|
search,
|
||||||
@@ -351,6 +359,20 @@ const resolvedDetailKpis = computed(() => (
|
|||||||
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
|
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() {
|
function handleLogout() {
|
||||||
logout('manual')
|
logout('manual')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,35 +196,16 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer v-if="showTable" class="list-foot">
|
<EnterprisePagination
|
||||||
<span class="page-summary">{{ pageSummary }}</span>
|
v-if="showTable"
|
||||||
<div class="pager" aria-label="分页">
|
:current-page="budgetPage"
|
||||||
<button class="page-nav" type="button" :disabled="budgetPage === 1" aria-label="上一页" @click="goToBudgetPage(budgetPage - 1)">
|
:page-size="budgetPageSize"
|
||||||
<i class="mdi mdi-chevron-left"></i>
|
:page-size-options="budgetPageSizeOptions"
|
||||||
</button>
|
:summary="pageSummary"
|
||||||
<button
|
:total-pages="totalBudgetPages"
|
||||||
v-for="page in budgetPageNumbers"
|
@page-size-change="changeBudgetPageSize"
|
||||||
:key="page"
|
@update:current-page="goToBudgetPage"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
</footer>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<EnterpriseDetailPage
|
<EnterpriseDetailPage
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ import { isPlatformAdminUser } from '../utils/accessControl.js'
|
|||||||
import {
|
import {
|
||||||
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
|
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
|
||||||
formatDigitalEmployeeCron,
|
formatDigitalEmployeeCron,
|
||||||
isDigitalEmployeeAsset
|
isDigitalEmployeeAsset,
|
||||||
|
shouldDisplayDigitalEmployeeAsset
|
||||||
} from './scripts/auditViewDigitalEmployeeModel.js'
|
} from './scripts/auditViewDigitalEmployeeModel.js'
|
||||||
import {
|
import {
|
||||||
buildDigitalEmployeeScheduleConfig,
|
buildDigitalEmployeeScheduleConfig,
|
||||||
@@ -336,7 +337,9 @@ async function loadEmployees() {
|
|||||||
try {
|
try {
|
||||||
const payload = await fetchAgentAssets({ assetType: 'task' })
|
const payload = await fetchAgentAssets({ assetType: 'task' })
|
||||||
const items = Array.isArray(payload)
|
const items = Array.isArray(payload)
|
||||||
? payload.filter(isDigitalEmployeeAsset).map(buildEmployeeListItem)
|
? payload
|
||||||
|
.filter((asset) => isDigitalEmployeeAsset(asset) && shouldDisplayDigitalEmployeeAsset(asset))
|
||||||
|
.map(buildEmployeeListItem)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
employees.value = sortEmployees(items)
|
employees.value = sortEmployees(items)
|
||||||
|
|||||||
@@ -215,36 +215,23 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer v-if="showTable" class="list-foot">
|
<EnterprisePagination
|
||||||
<span class="page-summary">共 {{ filteredRows.length }} 条,目前第 {{ currentPage }} 页</span>
|
v-if="showTable"
|
||||||
<div class="pager" aria-label="分页">
|
:current-page="currentPage"
|
||||||
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
:page-size="pageSize"
|
||||||
<i class="mdi mdi-chevron-left"></i>
|
:page-size-options="pageSizeOptions"
|
||||||
</button>
|
:summary="pageSummary"
|
||||||
<button
|
:total-pages="totalPages"
|
||||||
v-for="page in totalPages"
|
@page-size-change="changePageSize"
|
||||||
:key="page"
|
@update:current-page="currentPage = $event"
|
||||||
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>
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
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 TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||||
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
||||||
@@ -318,7 +305,8 @@ const props = defineProps({
|
|||||||
filteredRequests: { type: Array, required: true },
|
filteredRequests: { type: Array, required: true },
|
||||||
hasData: { type: Boolean, default: false },
|
hasData: { type: Boolean, default: false },
|
||||||
loading: { 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([
|
const emit = defineEmits([
|
||||||
'open-document',
|
'open-document',
|
||||||
@@ -463,6 +451,7 @@ const filteredRows = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
|
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 visibleRows = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
return filteredRows.value.slice(start, start + pageSize.value)
|
return filteredRows.value.slice(start, start + pageSize.value)
|
||||||
@@ -832,6 +821,15 @@ watch(documentSummary, (summary) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadSupportingRows()
|
void loadSupportingRows()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.refreshToken,
|
||||||
|
(token, previousToken) => {
|
||||||
|
if (token && token !== previousToken) {
|
||||||
|
void loadSupportingRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
|
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
|
||||||
|
|||||||
@@ -657,47 +657,16 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer v-if="!loading && !errorMessage && totalCount" class="list-foot">
|
<EnterprisePagination
|
||||||
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
v-if="!loading && !errorMessage && totalCount"
|
||||||
<div class="pager" aria-label="分页">
|
:current-page="currentPage"
|
||||||
<button
|
:page-size="pageSize"
|
||||||
class="page-nav"
|
:page-size-options="pageSizeOptions"
|
||||||
type="button"
|
:summary="pageSummary"
|
||||||
:disabled="currentPage === 1"
|
:total-pages="totalPages"
|
||||||
aria-label="上一页"
|
@page-size-change="changePageSize"
|
||||||
@click="currentPage--"
|
@update:current-page="currentPage = $event"
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</article>
|
</article>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,8 @@
|
|||||||
:empty="!systemLogLoading && !visibleSystemLogEntries.length"
|
:empty="!systemLogLoading && !visibleSystemLogEntries.length"
|
||||||
:total="totalCount"
|
:total="totalCount"
|
||||||
:total-pages="totalPages"
|
:total-pages="totalPages"
|
||||||
:pages="visiblePageItems"
|
|
||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} 页`"
|
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} / ${totalPages} 页`"
|
||||||
:show-pagination="!systemLogLoading && filteredSystemLogEntries.length > 0"
|
:show-pagination="!systemLogLoading && filteredSystemLogEntries.length > 0"
|
||||||
loading-title="系统日志同步中"
|
loading-title="系统日志同步中"
|
||||||
loading-message="正在加载系统运行日志记录"
|
loading-message="正在加载系统运行日志记录"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<div class="content-grid bottom-grid">
|
<div class="content-grid bottom-grid">
|
||||||
<article class="panel dashboard-card rank-panel">
|
<article class="panel dashboard-card rank-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>部门报销排行(费用金额)<i class="mdi mdi-information-outline"></i></h3>
|
<h3>部门报销排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
<EnterpriseSelect
|
<EnterpriseSelect
|
||||||
v-model="activeDepartmentRange"
|
v-model="activeDepartmentRange"
|
||||||
class="card-select"
|
class="card-select"
|
||||||
@@ -84,7 +84,14 @@
|
|||||||
|
|
||||||
<article class="panel dashboard-card employee-rank-panel">
|
<article class="panel dashboard-card employee-rank-panel">
|
||||||
<div class="card-head">
|
<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>
|
</div>
|
||||||
|
|
||||||
<BarChart :items="rankedEmployees" />
|
<BarChart :items="rankedEmployees" />
|
||||||
@@ -92,7 +99,17 @@
|
|||||||
|
|
||||||
<article class="panel dashboard-card top-claim-panel">
|
<article class="panel dashboard-card top-claim-panel">
|
||||||
<div class="card-head">
|
<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>
|
||||||
|
|
||||||
<div class="top-claim-list">
|
<div class="top-claim-list">
|
||||||
@@ -111,6 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel dashboard-card budget-metrics-panel">
|
<article class="panel dashboard-card budget-metrics-panel">
|
||||||
@@ -158,6 +176,7 @@
|
|||||||
:dashboard="riskDashboard"
|
:dashboard="riskDashboard"
|
||||||
:loading="riskDashboardLoading"
|
:loading="riskDashboardLoading"
|
||||||
:error="riskDashboardError"
|
:error="riskDashboardError"
|
||||||
|
:last-updated-at="riskDashboardLastUpdatedAt"
|
||||||
:level-legend="riskLevelLegend"
|
:level-legend="riskLevelLegend"
|
||||||
:source-legend="riskSourceLegend"
|
:source-legend="riskSourceLegend"
|
||||||
:signal-ranking="riskSignalRanking"
|
:signal-ranking="riskSignalRanking"
|
||||||
@@ -358,6 +377,8 @@ const {
|
|||||||
activeTrendRange,
|
activeTrendRange,
|
||||||
budgetMetrics,
|
budgetMetrics,
|
||||||
budgetSummary,
|
budgetSummary,
|
||||||
|
departmentEmployeeCenterValue,
|
||||||
|
departmentEmployeeLegend,
|
||||||
departmentRangeOptions,
|
departmentRangeOptions,
|
||||||
digitalEmployeeCategoryRows,
|
digitalEmployeeCategoryRows,
|
||||||
digitalEmployeeDashboard,
|
digitalEmployeeDashboard,
|
||||||
@@ -371,6 +392,7 @@ const {
|
|||||||
rankedEmployees,
|
rankedEmployees,
|
||||||
riskDashboard,
|
riskDashboard,
|
||||||
riskDashboardError,
|
riskDashboardError,
|
||||||
|
riskDashboardLastUpdatedAt,
|
||||||
riskDashboardLoading,
|
riskDashboardLoading,
|
||||||
riskDailyTrendRows,
|
riskDailyTrendRows,
|
||||||
riskKpiMetrics,
|
riskKpiMetrics,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
:assistant-modal-open="assistantModalOpen"
|
:assistant-modal-open="assistantModalOpen"
|
||||||
:workbench-summary="workbenchSummary"
|
:workbench-summary="workbenchSummary"
|
||||||
@open-assistant="emit('open-assistant', $event)"
|
@open-assistant="emit('open-assistant', $event)"
|
||||||
|
@open-document="emit('open-document', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -15,5 +16,5 @@ defineProps({
|
|||||||
workbenchSummary: { type: Object, default: () => ({}) }
|
workbenchSummary: { type: Object, default: () => ({}) }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['open-assistant'])
|
const emit = defineEmits(['open-assistant', 'open-document'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -146,35 +146,15 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="list-foot">
|
<EnterprisePagination
|
||||||
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
:current-page="currentPage"
|
||||||
<div class="pager" aria-label="分页">
|
:page-size="pageSize"
|
||||||
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
:page-size-options="pageSizeOptions"
|
||||||
<i class="mdi mdi-chevron-left"></i>
|
:summary="pageSummary"
|
||||||
</button>
|
:total-pages="totalPages"
|
||||||
<button
|
@page-size-change="changePageSize"
|
||||||
v-for="page in totalPages"
|
@update:current-page="currentPage = $event"
|
||||||
: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>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -112,28 +112,16 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer v-if="showTable" class="list-foot">
|
<EnterprisePagination
|
||||||
<span class="page-summary">共 {{ filteredRows.length }} 条,目前第 {{ currentPage }} 页</span>
|
v-if="showTable"
|
||||||
<div class="pager" aria-label="分页">
|
:current-page="currentPage"
|
||||||
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
:page-size="pageSize"
|
||||||
<i class="mdi mdi-chevron-left"></i>
|
:page-size-options="pageSizeOptions"
|
||||||
</button>
|
:summary="pageSummary"
|
||||||
<button
|
:total-pages="totalPages"
|
||||||
v-for="page in totalPages"
|
@page-size-change="changePageSize"
|
||||||
:key="page"
|
@update:current-page="currentPage = $event"
|
||||||
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>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<EnterpriseDetailPage
|
<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 { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
|
||||||
import { ElDialog } from 'element-plus/es/components/dialog/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 EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
|
||||||
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
||||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||||
@@ -477,6 +465,7 @@ const filteredRows = computed(() => {
|
|||||||
].filter(Boolean).join('').toLowerCase().includes(normalized))
|
].filter(Boolean).join('').toLowerCase().includes(normalized))
|
||||||
})
|
})
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
|
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 visibleRows = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
return filteredRows.value.slice(start, start + pageSize.value)
|
return filteredRows.value.slice(start, start + pageSize.value)
|
||||||
@@ -599,6 +588,11 @@ function switchStatus(status) {
|
|||||||
activeStatus.value = status
|
activeStatus.value = status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changePageSize(size) {
|
||||||
|
pageSize.value = Number(size) || pageSize.value
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
async function reloadReceipts() {
|
async function reloadReceipts() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|||||||
@@ -476,6 +476,16 @@
|
|||||||
<i class="mdi mdi-trash-can-outline"></i>
|
<i class="mdi mdi-trash-can-outline"></i>
|
||||||
{{ deleteBusy ? '删除中' : deleteActionLabel }}
|
{{ deleteBusy ? '删除中' : deleteActionLabel }}
|
||||||
</button>
|
</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">
|
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
|
||||||
<i :class="submitActionIcon"></i>
|
<i :class="submitActionIcon"></i>
|
||||||
{{ submitActionLabel }}
|
{{ submitActionLabel }}
|
||||||
@@ -773,3 +783,4 @@
|
|||||||
<script src="./scripts/TravelRequestDetailView.js"></script>
|
<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.css"></style>
|
||||||
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
|||||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||||
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
|
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
|
||||||
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
|
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
|
||||||
|
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
|
||||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||||
@@ -98,6 +99,7 @@ export default {
|
|||||||
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
|
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
|
||||||
components: {
|
components: {
|
||||||
BudgetTrendChart,
|
BudgetTrendChart,
|
||||||
|
EnterprisePagination,
|
||||||
EnterpriseSelect,
|
EnterpriseSelect,
|
||||||
EnterpriseDetailCard,
|
EnterpriseDetailCard,
|
||||||
EnterpriseDetailPage,
|
EnterpriseDetailPage,
|
||||||
@@ -169,9 +171,6 @@ export default {
|
|||||||
const currentBudgetPage = computed(() =>
|
const currentBudgetPage = computed(() =>
|
||||||
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
|
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
|
||||||
)
|
)
|
||||||
const budgetPageNumbers = computed(() =>
|
|
||||||
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
|
|
||||||
)
|
|
||||||
const visibleBudgetRows = computed(() => {
|
const visibleBudgetRows = computed(() => {
|
||||||
const pageSize = Number(budgetPageSize.value || 8)
|
const pageSize = Number(budgetPageSize.value || 8)
|
||||||
const start = (currentBudgetPage.value - 1) * pageSize
|
const start = (currentBudgetPage.value - 1) * pageSize
|
||||||
@@ -227,7 +226,7 @@ export default {
|
|||||||
artLabel: '预算列表为空',
|
artLabel: '预算列表为空',
|
||||||
tips: ['可以调整年度、季度、状态或关键词后重试。']
|
tips: ['可以调整年度、季度、状态或关键词后重试。']
|
||||||
}))
|
}))
|
||||||
const pageSummary = computed(() => `共 ${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} 页`)
|
const pageSummary = computed(() => `共 ${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} / ${totalBudgetPages.value} 页`)
|
||||||
|
|
||||||
function buildBudgetAssistantContext(row, mode = 'edit') {
|
function buildBudgetAssistantContext(row, mode = 'edit') {
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
@@ -425,7 +424,6 @@ export default {
|
|||||||
budgetKeyword,
|
budgetKeyword,
|
||||||
budgetLoading,
|
budgetLoading,
|
||||||
budgetPage: currentBudgetPage,
|
budgetPage: currentBudgetPage,
|
||||||
budgetPageNumbers,
|
|
||||||
budgetPageSize,
|
budgetPageSize,
|
||||||
budgetPageSizeOptions,
|
budgetPageSizeOptions,
|
||||||
budgetScopeTabs,
|
budgetScopeTabs,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||||
|
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
|
||||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||||
@@ -452,6 +453,7 @@ export default {
|
|||||||
name: 'EmployeeManagementView',
|
name: 'EmployeeManagementView',
|
||||||
components: {
|
components: {
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
EnterprisePagination,
|
||||||
EnterpriseSelect,
|
EnterpriseSelect,
|
||||||
TableLoadingState,
|
TableLoadingState,
|
||||||
TableEmptyState
|
TableEmptyState
|
||||||
@@ -672,6 +674,7 @@ export default {
|
|||||||
|
|
||||||
const totalCount = computed(() => filteredEmployees.value.length)
|
const totalCount = computed(() => filteredEmployees.value.length)
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
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 visibleEmployees = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
@@ -1469,6 +1472,7 @@ export default {
|
|||||||
hasEmployeeFilters,
|
hasEmployeeFilters,
|
||||||
totalCount,
|
totalCount,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
pageSummary,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
handleEmployeeEmptyAction,
|
handleEmployeeEmptyAction,
|
||||||
openEmployeeDetail,
|
openEmployeeDetail,
|
||||||
|
|||||||
@@ -152,12 +152,6 @@ export default {
|
|||||||
filteredSystemLogEntries.value.filter((entry) => entry.level === 'INFO').length
|
filteredSystemLogEntries.value.filter((entry) => entry.level === 'INFO').length
|
||||||
)
|
)
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
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 visibleSystemLogEntries = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
return filteredSystemLogEntries.value.slice(start, start + pageSize.value)
|
return filteredSystemLogEntries.value.slice(start, start + pageSize.value)
|
||||||
@@ -300,7 +294,6 @@ export default {
|
|||||||
systemSearchKeyword,
|
systemSearchKeyword,
|
||||||
totalCount,
|
totalCount,
|
||||||
totalPages,
|
totalPages,
|
||||||
visiblePageItems,
|
|
||||||
visibleSystemLogEntries
|
visibleSystemLogEntries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.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 TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
import { useToast } from '../../composables/useToast.js'
|
||||||
@@ -87,7 +87,7 @@ export default {
|
|||||||
name: 'PoliciesView',
|
name: 'PoliciesView',
|
||||||
components: {
|
components: {
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
EnterpriseSelect,
|
EnterprisePagination,
|
||||||
TableLoadingState
|
TableLoadingState
|
||||||
},
|
},
|
||||||
emits: ['summary-change'],
|
emits: ['summary-change'],
|
||||||
@@ -184,6 +184,7 @@ export default {
|
|||||||
|
|
||||||
const totalCount = computed(() => filteredDocuments.value.length)
|
const totalCount = computed(() => filteredDocuments.value.length)
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
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 visibleDocuments = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
return filteredDocuments.value.slice(start, start + pageSize.value)
|
return filteredDocuments.value.slice(start, start + pageSize.value)
|
||||||
@@ -636,6 +637,7 @@ export default {
|
|||||||
loading,
|
loading,
|
||||||
pageSize,
|
pageSize,
|
||||||
pageSizeOptions,
|
pageSizeOptions,
|
||||||
|
pageSummary,
|
||||||
pageSizes,
|
pageSizes,
|
||||||
onlyOfficeError,
|
onlyOfficeError,
|
||||||
onlyOfficeHostId,
|
onlyOfficeHostId,
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ import {
|
|||||||
buildLocalApplicationPreviewMessage,
|
buildLocalApplicationPreviewMessage,
|
||||||
normalizeApplicationPreview
|
normalizeApplicationPreview
|
||||||
} from '../../utils/expenseApplicationPreview.js'
|
} from '../../utils/expenseApplicationPreview.js'
|
||||||
|
import {
|
||||||
|
TRAVEL_PLANNING_ACTION_GENERATE,
|
||||||
|
TRAVEL_PLANNING_ACTION_SKIP,
|
||||||
|
buildTravelPlanningNudgeMessage,
|
||||||
|
buildTravelPlanningRecommendation,
|
||||||
|
buildTravelPlanningSuggestedActions
|
||||||
|
} from '../../utils/travelApplicationPlanning.js'
|
||||||
import {
|
import {
|
||||||
calculateTravelReimbursement,
|
calculateTravelReimbursement,
|
||||||
createExpenseClaimItem,
|
createExpenseClaimItem,
|
||||||
@@ -524,6 +531,14 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
|
initialPromptAutoSubmit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
initialApplicationPreview: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
initialFiles: {
|
initialFiles: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
@@ -629,7 +644,9 @@ export default {
|
|||||||
handleApplicationPreviewEditorKeydown
|
handleApplicationPreviewEditorKeydown
|
||||||
} = useApplicationPreviewEditor({
|
} = useApplicationPreviewEditor({
|
||||||
persistSessionState,
|
persistSessionState,
|
||||||
toast
|
toast,
|
||||||
|
calculateTravelReimbursement,
|
||||||
|
currentUser
|
||||||
})
|
})
|
||||||
|
|
||||||
function applyLinkedApplicationPreviewDateSelection(selection) {
|
function applyLinkedApplicationPreviewDateSelection(selection) {
|
||||||
@@ -1372,6 +1389,14 @@ export default {
|
|||||||
currentInsight.value =
|
currentInsight.value =
|
||||||
currentInsight.value
|
currentInsight.value
|
||||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.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) {
|
if (props.initialPrompt?.trim() || props.initialFiles.length) {
|
||||||
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
|
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
|
||||||
composerDraft.value = props.initialPrompt.trim()
|
composerDraft.value = props.initialPrompt.trim()
|
||||||
@@ -1380,8 +1405,13 @@ export default {
|
|||||||
if (initialMerge.overflowCount > 0) {
|
if (initialMerge.overflowCount > 0) {
|
||||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||||
}
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
adjustComposerTextareaHeight()
|
||||||
|
})
|
||||||
|
if (props.initialPromptAutoSubmit !== false) {
|
||||||
submitComposer()
|
submitComposer()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -1576,6 +1606,32 @@ export default {
|
|||||||
if (await handleGuidedSuggestedAction(message, action)) return
|
if (await handleGuidedSuggestedAction(message, action)) return
|
||||||
if (await handleSceneSelectionApplicationGate(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) {
|
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
||||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
const targetSessionType = String(actionPayload.session_type || '').trim()
|
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() {
|
async function confirmApplicationSubmit() {
|
||||||
const message = applicationSubmitConfirmDialog.value.message
|
const message = applicationSubmitConfirmDialog.value.message
|
||||||
if (!message || submitting.value || reviewActionBusy.value) {
|
if (!message || submitting.value || reviewActionBusy.value) {
|
||||||
@@ -2044,6 +2111,7 @@ export default {
|
|||||||
const applicationSubmitText = applicationPreview
|
const applicationSubmitText = applicationPreview
|
||||||
? buildApplicationPreviewSubmitText(applicationPreview)
|
? buildApplicationPreviewSubmitText(applicationPreview)
|
||||||
: '确认提交'
|
: '确认提交'
|
||||||
|
const applicationEditClaimId = resolveApplicationEditClaimId()
|
||||||
applicationSubmitConfirmDialog.value = {
|
applicationSubmitConfirmDialog.value = {
|
||||||
open: false,
|
open: false,
|
||||||
message: null
|
message: null
|
||||||
@@ -2059,7 +2127,16 @@ export default {
|
|||||||
feedbackOperationType: 'submit_application',
|
feedbackOperationType: 'submit_application',
|
||||||
extraContext: {
|
extraContext: {
|
||||||
application_preview: applicationPreview,
|
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 || {}
|
const draftPayload = payload?.result?.draft_payload || {}
|
||||||
@@ -2074,6 +2151,23 @@ export default {
|
|||||||
documentType: 'application'
|
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 {
|
} finally {
|
||||||
reviewActionBusy.value = false
|
reviewActionBusy.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,11 +460,17 @@ export default {
|
|||||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||||||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
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 canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||||
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
||||||
const canDeleteRequest = computed(() => {
|
const canDeleteRequest = computed(() => {
|
||||||
if (isApplicationDocument.value) {
|
if (isApplicationDocument.value) {
|
||||||
return isPlatformAdminUser(currentUser.value)
|
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
|
||||||
}
|
}
|
||||||
if (isArchivedRequest.value) {
|
if (isArchivedRequest.value) {
|
||||||
return canDeleteArchivedExpenseClaims(currentUser.value)
|
return canDeleteArchivedExpenseClaims(currentUser.value)
|
||||||
@@ -1007,7 +1013,7 @@ export default {
|
|||||||
if (analysis) {
|
if (analysis) {
|
||||||
return {
|
return {
|
||||||
label: analysis.label || '已上传',
|
label: analysis.label || '已上传',
|
||||||
tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
|
tone: normalizeRiskTone(analysis.severity || 'low'),
|
||||||
headline: analysis.headline || 'AI提示',
|
headline: analysis.headline || 'AI提示',
|
||||||
summary: analysis.summary || '',
|
summary: analysis.summary || '',
|
||||||
points: Array.isArray(analysis.points) ? analysis.points : [],
|
points: Array.isArray(analysis.points) ? analysis.points : [],
|
||||||
@@ -1858,6 +1864,8 @@ export default {
|
|||||||
toast(
|
toast(
|
||||||
isArchivedRequest.value
|
isArchivedRequest.value
|
||||||
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
||||||
|
: isApplicationDocument.value
|
||||||
|
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。'
|
||||||
: '当前单据已进入流程,只有高级财务人员可以删除。'
|
: '当前单据已进入流程,只有高级财务人员可以删除。'
|
||||||
)
|
)
|
||||||
return
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
closeAttachmentPreview()
|
closeAttachmentPreview()
|
||||||
})
|
})
|
||||||
@@ -2032,7 +2110,7 @@ export default {
|
|||||||
applicationDetailFactItems, relatedApplicationFactItems,
|
applicationDetailFactItems, relatedApplicationFactItems,
|
||||||
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
||||||
canNavigateAttachmentPreview,
|
canNavigateAttachmentPreview,
|
||||||
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
||||||
closeRiskOverrideDialog,
|
closeRiskOverrideDialog,
|
||||||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||||||
@@ -2046,6 +2124,7 @@ export default {
|
|||||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||||
|
handleModifyApplication,
|
||||||
handlePayRequest,
|
handlePayRequest,
|
||||||
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||||
isMajorExpenseRisk,
|
isMajorExpenseRisk,
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
|
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
|
||||||
export const DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS = ['积累', '升级', '整理', '评估']
|
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 = {
|
const TASK_TYPE_LABELS = {
|
||||||
|
finance_dashboard_snapshot: '财务经营快照沉淀',
|
||||||
|
digital_employee_reminder_scan: '定时提醒与待办扫描',
|
||||||
daily_risk_scan: '每日风险巡检',
|
daily_risk_scan: '每日风险巡检',
|
||||||
global_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_ar_summary: '周度应收账龄汇总',
|
||||||
weekly_expense_report: '周度费用洞察',
|
weekly_expense_report: '周度费用洞察',
|
||||||
rule_review_digest: '规则待审摘要',
|
rule_review_digest: '规则待审摘要',
|
||||||
@@ -15,9 +33,15 @@ const TASK_TYPE_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TASK_TYPE_SKILL_CATEGORIES = {
|
const TASK_TYPE_SKILL_CATEGORIES = {
|
||||||
|
finance_dashboard_snapshot: '整理',
|
||||||
|
digital_employee_reminder_scan: '升级',
|
||||||
daily_risk_scan: '评估',
|
daily_risk_scan: '评估',
|
||||||
global_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_ar_summary: '整理',
|
||||||
weekly_expense_report: '整理',
|
weekly_expense_report: '整理',
|
||||||
rule_review_digest: '升级',
|
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) {
|
export function formatDigitalEmployeeCron(value) {
|
||||||
const raw = normalizeDigitalEmployeeText(value)
|
const raw = normalizeDigitalEmployeeText(value)
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
|
|||||||
@@ -18,9 +18,31 @@ const KNOWLEDGE_JOB_TYPES = new Set([
|
|||||||
'finance_policy_knowledge_organize'
|
'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 = {
|
const TASK_TYPE_LABELS = {
|
||||||
|
finance_dashboard_snapshot: '财务经营快照沉淀',
|
||||||
|
digital_employee_reminder_scan: '定时提醒与待办扫描',
|
||||||
global_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: '差旅时空一致性评估',
|
||||||
risk_clue_collect: '风险线索归集',
|
risk_clue_collect: '风险线索归集',
|
||||||
finance_policy_knowledge_organize: '知识制度整理',
|
finance_policy_knowledge_organize: '知识制度整理',
|
||||||
knowledge_index_sync: '知识制度整理',
|
knowledge_index_sync: '知识制度整理',
|
||||||
@@ -29,10 +51,16 @@ const TASK_TYPE_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TASK_CODE_TO_TYPE = {
|
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.global_risk_scan': 'global_risk_scan',
|
||||||
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
|
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
|
||||||
'task.hermes.risk_rule_discovery': 'risk_clue_collect',
|
'task.hermes.department_expense_baseline_accumulate': 'department_expense_baseline_accumulate',
|
||||||
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
|
'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) {
|
function toObject(value) {
|
||||||
@@ -52,6 +80,12 @@ function resolveTaskTypeFromToolName(value) {
|
|||||||
if (name.includes('financial_risk_graph')) {
|
if (name.includes('financial_risk_graph')) {
|
||||||
return 'global_risk_scan'
|
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')) {
|
if (name.includes('employee_behavior_profile')) {
|
||||||
return 'employee_behavior_profile_scan'
|
return 'employee_behavior_profile_scan'
|
||||||
}
|
}
|
||||||
@@ -128,6 +162,43 @@ export function resolveWorkRecordTaskType(run) {
|
|||||||
return ''
|
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) {
|
export function resolveWorkRecordTaskLabel(run) {
|
||||||
const taskType = resolveWorkRecordTaskType(run)
|
const taskType = resolveWorkRecordTaskType(run)
|
||||||
return TASK_TYPE_LABELS[taskType] || ''
|
return TASK_TYPE_LABELS[taskType] || ''
|
||||||
@@ -135,6 +206,12 @@ export function resolveWorkRecordTaskLabel(run) {
|
|||||||
|
|
||||||
export function resolveWorkRecordProductKind(run) {
|
export function resolveWorkRecordProductKind(run) {
|
||||||
const taskType = resolveWorkRecordTaskType(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') {
|
if (taskType === 'global_risk_scan') {
|
||||||
return 'risk_graph'
|
return 'risk_graph'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ export function sanitizeRequest(request) {
|
|||||||
|
|
||||||
const normalized = {
|
const normalized = {
|
||||||
claimId: String(request.claimId || request.claim_id || '').trim(),
|
claimId: String(request.claimId || request.claim_id || '').trim(),
|
||||||
|
claimNo: String(request.claimNo || request.claim_no || request.documentNo || '').trim(),
|
||||||
id: String(request.id || '').trim(),
|
id: String(request.id || '').trim(),
|
||||||
typeLabel: String(request.typeLabel || request.category || '').trim(),
|
typeLabel: String(request.typeLabel || request.category || '').trim(),
|
||||||
reason: String(request.reason || request.title || '').trim(),
|
reason: String(request.reason || request.title || '').trim(),
|
||||||
@@ -468,7 +469,8 @@ export function sanitizeRequest(request) {
|
|||||||
amount: String(request.amount || '').trim(),
|
amount: String(request.amount || '').trim(),
|
||||||
node: String(request.node || '').trim(),
|
node: String(request.node || '').trim(),
|
||||||
approval: String(request.approval || '').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
|
return Object.values(normalized).some(Boolean) ? normalized : null
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ function resolveRequestBusinessStage(request = {}) {
|
|||||||
|
|
||||||
function normalizeTone(value) {
|
function normalizeTone(value) {
|
||||||
const tone = normalizeText(value).toLowerCase()
|
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 === 'high') return 'high'
|
||||||
if (tone === 'medium') return 'medium'
|
if (tone === 'medium') return 'medium'
|
||||||
if (tone === 'low') return 'low'
|
if (tone === 'low') return 'low'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user