From 15006a05a7f3082560dc7240bc7c9fb77eb776ef Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 3 Jun 2026 09:25:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B0=E5=AD=97=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E6=8A=A5=E5=91=8A=E4=BD=93=E7=B3=BB=E4=B8=8E?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E6=8F=90=E9=86=92=E5=8F=8A=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖 --- .../CONCEPT.md | 27 + .../travel-application-planning-nudge/TODO.md | 8 + .../数字员工财务报告体系/CONCEPT.md | 328 +++++++++++ .../development/数字员工财务报告体系/TODO.md | 80 +++ .../数字员工财务经验沉淀与定时提醒/CONCEPT.md | 227 ++++++++ .../数字员工财务经验沉淀与定时提醒/TODO.md | 39 ++ .../财务看板排行口径与部门人员占比/CONCEPT.md | 153 +++++ .../财务看板排行口径与部门人员占比/TODO.md | 35 ++ .../scripts/audit_expense_claim_statuses.py | 2 +- server/scripts/generate_finance_report.py | 52 ++ ...mock_half_year_expense_demo_attachments.py | 8 +- ...me_half_year_expense_demo_claim_numbers.py | 347 +++++++++++ ...air_half_year_expense_demo_distribution.py | 25 +- server/scripts/seed_half_year_expense_demo.py | 2 + server/src/app/api/v1/endpoints/analytics.py | 12 +- .../app/api/v1/endpoints/reimbursements.py | 2 +- server/src/app/main.py | 12 + server/src/app/schemas/finance_dashboard.py | 1 + .../services/agent_foundation_constants.py | 13 +- ...agent_foundation_digital_employee_tasks.py | 112 +++- .../demo_company_simulation_catalog.py | 31 +- .../demo_company_simulation_filters.py | 71 ++- .../services/demo_company_simulation_seed.py | 110 ++-- .../services/digital_employee_dashboard.py | 57 +- .../digital_employee_finance_report_task.py | 163 ++++++ .../digital_employee_reminder_scheduler.py | 102 ++++ .../digital_employee_reminder_task.py | 547 ++++++++++++++++++ .../services/employee_profile_scan_task.py | 123 ++++ .../services/employee_profile_scheduler.py | 88 +++ .../app/services/expense_claim_read_model.py | 2 +- server/src/app/services/expense_claims.py | 4 - server/src/app/services/finance_dashboard.py | 132 ++--- .../services/finance_dashboard_constants.py | 65 +++ .../services/finance_dashboard_scheduler.py | 85 +++ .../services/finance_dashboard_snapshot.py | 268 +++++++++ .../app/services/finance_report_context.py | 319 ++++++++++ .../src/app/services/finance_report_mailer.py | 205 +++++++ .../app/services/finance_report_renderer.py | 397 +++++++++++++ .../app/services/finance_report_scheduler.py | 143 +++++ .../app/services/orchestrator_execution.py | 58 ++ server/src/app/services/risk_observations.py | 3 +- .../app/services/user_agent_application.py | 117 +++- .../SKILL.md | 22 + .../SKILL.md | 22 + .../finance-report-orchestrator/SKILL.md | 46 ++ .../test_demo_company_simulation_seed.py | 14 + ...test_digital_employee_dashboard_service.py | 89 +++ .../test_digital_employee_reminder_task.py | 176 ++++++ .../test_digital_employee_skill_catalog.py | 18 +- server/tests/test_expense_claim_service.py | 53 +- .../tests/test_finance_dashboard_service.py | 152 ++++- server/tests/test_finance_report_task.py | 99 ++++ .../test_hermes_employee_profile_baselines.py | 20 + server/tests/test_user_agent_service.py | 86 +++ .../components/personal-workbench-glass.css | 3 - .../personal-workbench-responsive.css | 42 +- .../styles/components/personal-workbench.css | 69 +-- web/src/assets/styles/components/top-bar.css | 252 ++++++++ web/src/assets/styles/views/overview-view.css | 73 ++- .../travel-request-detail-responsive.css | 69 +++ .../travel-request-detail-view-part2.css | 1 + .../views/travel-request-detail-view.css | 7 + web/src/components/audit/AuditAssetList.vue | 9 - .../audit/DigitalEmployeeListPanel.vue | 9 - .../audit/DigitalEmployeeRunProducts.vue | 68 +++ .../audit/DigitalEmployeeWorkRecords.vue | 12 +- .../components/business/PersonalWorkbench.vue | 80 +-- web/src/components/charts/BarChart.vue | 30 +- .../dashboard/RiskObservationDashboard.vue | 61 +- web/src/components/layout/TopBar.vue | 110 +++- .../components/shared/EnterpriseListPage.vue | 5 - .../shared/EnterprisePagination.vue | 143 ++++- web/src/composables/useAppShell.js | 48 +- web/src/composables/useOverviewView.js | 65 ++- web/src/data/metrics.js | 2 +- web/src/services/analytics.js | 4 +- web/src/utils/expenseApplicationPreview.js | 4 + web/src/utils/travelApplicationPlanning.js | 119 ++++ web/src/utils/workbenchSummary.js | 181 +++++- web/src/views/AppShellRouteView.vue | 22 + web/src/views/BudgetCenterView.vue | 39 +- web/src/views/DigitalEmployeesView.vue | 7 +- web/src/views/DocumentsCenterView.vue | 48 +- web/src/views/EmployeeManagementView.vue | 51 +- web/src/views/LogsView.vue | 3 +- web/src/views/OverviewView.vue | 54 +- web/src/views/PersonalWorkbenchView.vue | 3 +- web/src/views/PoliciesView.vue | 38 +- web/src/views/ReceiptFolderView.vue | 40 +- web/src/views/TravelRequestDetailView.vue | 11 + web/src/views/scripts/BudgetCenterView.js | 8 +- .../views/scripts/EmployeeManagementView.js | 4 + web/src/views/scripts/LogsView.js | 7 - web/src/views/scripts/PoliciesView.js | 12 +- .../scripts/TravelReimbursementCreateView.js | 100 +++- .../views/scripts/TravelRequestDetailView.js | 87 ++- .../scripts/auditViewDigitalEmployeeModel.js | 32 +- .../digitalEmployeeWorkRecordsModel.js | 81 ++- .../travelReimbursementConversationModel.js | 4 +- .../scripts/travelRequestDetailInsights.js | 2 +- .../scripts/useApplicationPreviewEditor.js | 77 ++- .../useTravelReimbursementSessionState.js | 1 + ...p-shell-financial-assistant-entry.test.mjs | 41 ++ ...tal-employee-work-record-products.test.mjs | 51 ++ .../documents-center-status-filter.test.mjs | 11 +- .../expense-application-fast-preview.test.mjs | 95 +++ ...e-application-submit-rich-confirm.test.mjs | 6 + web/tests/finance-dashboard-ranking.test.mjs | 53 ++ .../personal-workbench-assistant.test.mjs | 6 +- web/tests/risk-observation-dashboard.test.mjs | 13 + ...travel-request-detail-risk-advice.test.mjs | 51 ++ ...vel-request-detail-submit-confirm.test.mjs | 3 +- web/tests/workbench-summary.test.mjs | 80 +++ web/vite.config.js | 27 +- 114 files changed, 7356 insertions(+), 650 deletions(-) create mode 100644 document/development/travel-application-planning-nudge/CONCEPT.md create mode 100644 document/development/travel-application-planning-nudge/TODO.md create mode 100644 document/development/数字员工财务报告体系/CONCEPT.md create mode 100644 document/development/数字员工财务报告体系/TODO.md create mode 100644 document/development/数字员工财务经验沉淀与定时提醒/CONCEPT.md create mode 100644 document/development/数字员工财务经验沉淀与定时提醒/TODO.md create mode 100644 document/development/财务看板排行口径与部门人员占比/CONCEPT.md create mode 100644 document/development/财务看板排行口径与部门人员占比/TODO.md create mode 100644 server/scripts/generate_finance_report.py create mode 100644 server/scripts/rename_half_year_expense_demo_claim_numbers.py create mode 100644 server/src/app/services/digital_employee_finance_report_task.py create mode 100644 server/src/app/services/digital_employee_reminder_scheduler.py create mode 100644 server/src/app/services/digital_employee_reminder_task.py create mode 100644 server/src/app/services/employee_profile_scan_task.py create mode 100644 server/src/app/services/employee_profile_scheduler.py create mode 100644 server/src/app/services/finance_dashboard_constants.py create mode 100644 server/src/app/services/finance_dashboard_scheduler.py create mode 100644 server/src/app/services/finance_dashboard_snapshot.py create mode 100644 server/src/app/services/finance_report_context.py create mode 100644 server/src/app/services/finance_report_mailer.py create mode 100644 server/src/app/services/finance_report_renderer.py create mode 100644 server/src/app/services/finance_report_scheduler.py create mode 100644 server/src/app/skills/domain/digital-employee-reminder-scanner/SKILL.md create mode 100644 server/src/app/skills/domain/finance-dashboard-snapshot-analyst/SKILL.md create mode 100644 server/src/app/skills/domain/finance-report-orchestrator/SKILL.md create mode 100644 server/tests/test_digital_employee_reminder_task.py create mode 100644 server/tests/test_finance_report_task.py create mode 100644 web/src/assets/styles/views/travel-request-detail-responsive.css create mode 100644 web/src/utils/travelApplicationPlanning.js create mode 100644 web/tests/finance-dashboard-ranking.test.mjs create mode 100644 web/tests/workbench-summary.test.mjs diff --git a/document/development/travel-application-planning-nudge/CONCEPT.md b/document/development/travel-application-planning-nudge/CONCEPT.md new file mode 100644 index 0000000..9020da9 --- /dev/null +++ b/document/development/travel-application-planning-nudge/CONCEPT.md @@ -0,0 +1,27 @@ +# 差旅申请后行程规划推荐 + +## 背景 + +用户完成差旅申请后,当前流程直接结束,交互偏机械。差旅申请本身已经包含地点、行程时间、出行方式、天数等信息,系统可以在申请提交成功后继续以对话形式询问是否需要行程规划。 + +## 目标 + +- 仅在差旅费用申请提交成功后追加一条对话式推荐。 +- 推荐内容应基于本次申请的已知字段,不要求用户重新输入地点和时间。 +- 用户同意后,在当前申请助手对话中生成规划建议。 +- 规划建议只提供交通时间窗口、酒店区域、待确认事项,不创建订单、不保存草稿、不调用真实订票接口。 + +## 非目标 + +- 不接入真实火车、机票、酒店预订。 +- 不改变申请单提交和审批状态。 +- 不强制用户继续规划。 + +## 交互 + +1. 用户确认提交差旅申请。 +2. 系统返回申请提交成功结果。 +3. 系统追加一条轻量对话:询问是否需要行程规划。 +4. 用户点击“生成行程规划”后,系统在对话中给出推荐。 +5. 用户点击“暂不需要”后,系统简短确认,不再继续追问。 + diff --git a/document/development/travel-application-planning-nudge/TODO.md b/document/development/travel-application-planning-nudge/TODO.md new file mode 100644 index 0000000..5b28300 --- /dev/null +++ b/document/development/travel-application-planning-nudge/TODO.md @@ -0,0 +1,8 @@ +# 差旅申请后行程规划推荐 TODO + +- [x] 新增差旅规划推荐工具,按申请预览字段生成提示、动作和规划正文。 +- [x] 申请提交成功后追加规划推荐对话。 +- [x] 支持“生成行程规划”和“暂不需要”两个对话动作。 +- [x] 增加前端静态测试覆盖,防止回退成死板结束流程。 +- [x] 运行定向测试和前端构建验证。 + diff --git a/document/development/数字员工财务报告体系/CONCEPT.md b/document/development/数字员工财务报告体系/CONCEPT.md new file mode 100644 index 0000000..563a78a --- /dev/null +++ b/document/development/数字员工财务报告体系/CONCEPT.md @@ -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.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 图表。 diff --git a/document/development/数字员工财务报告体系/TODO.md b/document/development/数字员工财务报告体系/TODO.md new file mode 100644 index 0000000..ff09e32 --- /dev/null +++ b/document/development/数字员工财务报告体系/TODO.md @@ -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///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`。 diff --git a/document/development/数字员工财务经验沉淀与定时提醒/CONCEPT.md b/document/development/数字员工财务经验沉淀与定时提醒/CONCEPT.md new file mode 100644 index 0000000..a956b62 --- /dev/null +++ b/document/development/数字员工财务经验沉淀与定时提醒/CONCEPT.md @@ -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`。 +- 审批责任人目前主要通过员工直属领导推断,复杂动态审批流需要后续对接审批路由结果。 +- 如果后续需要“已读/已处理/重复提醒抑制”,必须新增提醒表或消息表,并进行数据库迁移确认。 diff --git a/document/development/数字员工财务经验沉淀与定时提醒/TODO.md b/document/development/数字员工财务经验沉淀与定时提醒/TODO.md new file mode 100644 index 0000000..2f4ddfa --- /dev/null +++ b/document/development/数字员工财务经验沉淀与定时提醒/TODO.md @@ -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 `。[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: 行为沉淀技能] diff --git a/document/development/财务看板排行口径与部门人员占比/CONCEPT.md b/document/development/财务看板排行口径与部门人员占比/CONCEPT.md new file mode 100644 index 0000000..4f1361e --- /dev/null +++ b/document/development/财务看板排行口径与部门人员占比/CONCEPT.md @@ -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 聚合优化。 diff --git a/document/development/财务看板排行口径与部门人员占比/TODO.md b/document/development/财务看板排行口径与部门人员占比/TODO.md new file mode 100644 index 0000000..0e33a6d --- /dev/null +++ b/document/development/财务看板排行口径与部门人员占比/TODO.md @@ -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: 风险与开放问题] 阻塞:工作区已有大量未提交改动,且本次相关后端文件依赖未跟踪的财务看板快照/常量文件,直接提交会混入既有改动,单独提交又可能缺依赖。 diff --git a/server/scripts/audit_expense_claim_statuses.py b/server/scripts/audit_expense_claim_statuses.py index c98bdaa..99ce787 100644 --- a/server/scripts/audit_expense_claim_statuses.py +++ b/server/scripts/audit_expense_claim_statuses.py @@ -111,7 +111,7 @@ def _doc_type(claim: ExpenseClaim) -> str: expense_type = str(claim.expense_type or "").strip().lower() if claim_no.startswith(("AP-", "APP-")) or expense_type.endswith("_application"): return "application" - if claim_no.startswith("SIM-EXP-2026"): + if str(claim.project_code or "").strip().upper() == "SIM-DEMO": return "sim_reimbursement" return "reimbursement" diff --git a/server/scripts/generate_finance_report.py b/server/scripts/generate_finance_report.py new file mode 100644 index 0000000..0154241 --- /dev/null +++ b/server/scripts/generate_finance_report.py @@ -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() diff --git a/server/scripts/mock_half_year_expense_demo_attachments.py b/server/scripts/mock_half_year_expense_demo_attachments.py index 64ff13c..1369f96 100644 --- a/server/scripts/mock_half_year_expense_demo_attachments.py +++ b/server/scripts/mock_half_year_expense_demo_attachments.py @@ -20,7 +20,7 @@ if str(SRC_DIR) not in sys.path: from app.db.session import get_session_factory # noqa: E402 from app.models.financial_record import ExpenseClaim, ExpenseClaimItem # noqa: E402 -from app.services.demo_company_simulation_catalog import SIM_CLAIM_PREFIX # noqa: E402 +from app.services.demo_company_simulation_catalog import SIM_PROJECT_CODE # noqa: E402 from app.services.expense_claim_attachment_storage import ( # noqa: E402 ExpenseClaimAttachmentStorage, ) @@ -135,8 +135,8 @@ def _sim_claims(db) -> list[ExpenseClaim]: db.scalars( select(ExpenseClaim) .options(selectinload(ExpenseClaim.items)) - .where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")) - .order_by(ExpenseClaim.claim_no.asc()) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + .order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc()) ).all() ) @@ -184,7 +184,7 @@ def _write_mock_attachment( violated=violated, ) file_path.write_text(ocr_text, encoding="utf-8") - item.invoice_id = storage.to_storage_key(file_path) + item.invoice_id = filename storage.write_meta( file_path, _meta_payload( diff --git a/server/scripts/rename_half_year_expense_demo_claim_numbers.py b/server/scripts/rename_half_year_expense_demo_claim_numbers.py new file mode 100644 index 0000000..4b47768 --- /dev/null +++ b/server/scripts/rename_half_year_expense_demo_claim_numbers.py @@ -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() diff --git a/server/scripts/repair_half_year_expense_demo_distribution.py b/server/scripts/repair_half_year_expense_demo_distribution.py index af3baa5..0c88793 100644 --- a/server/scripts/repair_half_year_expense_demo_distribution.py +++ b/server/scripts/repair_half_year_expense_demo_distribution.py @@ -29,7 +29,6 @@ from app.services.demo_company_simulation_catalog import ( # noqa: E402 BUDGETED_STATUSES, PENDING_STATUSES, SIM_BUDGET_PREFIX, - SIM_CLAIM_PREFIX, SIM_EMPLOYEE_PREFIX, SIM_PROJECT_CODE, SIM_RESERVATION_PREFIX, @@ -60,6 +59,8 @@ RECENT_DATES = ( datetime(2026, 6, 1, 15, 0, tzinfo=UTC), datetime(2026, 6, 2, 6, 0, tzinfo=UTC), ) +PERIOD_START = date(2026, 1, 1) +PERIOD_END = date(2026, 6, 2) @dataclass(frozen=True, slots=True) @@ -139,6 +140,7 @@ def repair_distribution(db, *, apply: bool) -> RepairSummary: if apply: _normalize_sim_claim_workflow(sim_claims) + _clamp_sim_claim_dates(sim_claims) _redistribute_employees(sim_employees, departments, employee_plan) db.flush() employees_by_dept = _employees_by_department(db) @@ -235,8 +237,8 @@ def _sim_claims(db) -> list[ExpenseClaim]: db.scalars( select(ExpenseClaim) .options(selectinload(ExpenseClaim.items)) - .where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")) - .order_by(ExpenseClaim.claim_no.asc()) + .where(ExpenseClaim.project_code == SIM_PROJECT_CODE) + .order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc()) ).all() ) @@ -254,6 +256,23 @@ def _normalize_sim_claim_workflow(claims: list[ExpenseClaim]) -> None: claim.approval_stage = normalized.approval_stage +def _clamp_sim_claim_dates(claims: list[ExpenseClaim]) -> None: + for index, claim in enumerate(claims): + occurred_at = claim.occurred_at or claim.submitted_at + if occurred_at is None: + continue + if PERIOD_START <= occurred_at.date() <= PERIOD_END: + continue + anchor = RECENT_DATES[index % len(RECENT_DATES)] + claim.occurred_at = anchor - _hours(2) + if claim.submitted_at is not None or claim.status != "draft": + claim.submitted_at = anchor + claim.created_at = claim.occurred_at + claim.updated_at = anchor + _hours(1) + for item in claim.items or []: + item.item_date = claim.occurred_at.date() + + def _counts_by_weight(total: int) -> dict[str, int]: raw = [(code, total * weight) for code, weight in DEPARTMENT_PLAN] counts = {code: int(value) for code, value in raw} diff --git a/server/scripts/seed_half_year_expense_demo.py b/server/scripts/seed_half_year_expense_demo.py index 3002116..c9465fd 100644 --- a/server/scripts/seed_half_year_expense_demo.py +++ b/server/scripts/seed_half_year_expense_demo.py @@ -32,6 +32,7 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--target-employees", type=int, default=100) parser.add_argument("--start-date", type=date.fromisoformat, default=date(2026, 1, 1)) + parser.add_argument("--end-date", type=date.fromisoformat, default=date(2026, 6, 2)) parser.add_argument("--months", type=int, default=6) parser.add_argument("--seed", type=int, default=20260602) parser.add_argument("--apply", action="store_true", help="Write data. Default is dry-run only.") @@ -49,6 +50,7 @@ def main() -> None: config = SimulationConfig( target_employees=args.target_employees, start_date=args.start_date, + end_date=args.end_date, months=args.months, seed=args.seed, ) diff --git a/server/src/app/api/v1/endpoints/analytics.py b/server/src/app/api/v1/endpoints/analytics.py index 39db137..74cf90b 100644 --- a/server/src/app/api/v1/endpoints/analytics.py +++ b/server/src/app/api/v1/endpoints/analytics.py @@ -11,7 +11,7 @@ from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead from app.schemas.finance_dashboard import FinanceDashboardRead from app.schemas.system_dashboard import SystemDashboardRead from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService -from app.services.finance_dashboard import FinanceDashboardService +from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService from app.services.system_dashboard import SystemDashboardService router = APIRouter(prefix="/analytics") @@ -65,10 +65,14 @@ def get_finance_dashboard( range_key: Annotated[str, Query(max_length=30, description="顶部时间范围。")] = "近10日", start_date: Annotated[date | None, Query(description="自定义开始日期。")] = None, end_date: Annotated[date | None, Query(description="自定义结束日期。")] = None, - trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = "近12天", - department_range: Annotated[str, Query(max_length=30, description="部门排行时间范围。")] = "本月", + trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = ( + "近12天" + ), + department_range: Annotated[str, Query(max_length=30, description="排行分析时间范围。")] = ( + "本月" + ), ) -> FinanceDashboardRead: - return FinanceDashboardService(db).build_dashboard( + return FinanceDashboardSnapshotService(db).build_dashboard( range_key=range_key, start_date=start_date, end_date=end_date, diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 239a260..f238e34 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -703,7 +703,7 @@ def pay_expense_claim( "/claims/{claim_id}", response_model=ExpenseClaimActionResponse, summary="删除报销单", - description="申请单仅系统管理员可删除;报销单申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。", + description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, diff --git a/server/src/app/main.py b/server/src/app/main.py index d151d1c..d26cca7 100644 --- a/server/src/app/main.py +++ b/server/src/app/main.py @@ -13,7 +13,11 @@ from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS from app.middleware.logging import AccessLogMiddleware from app.schemas.common import RootStatusRead from app.services.agent_foundation import prepare_agent_foundation +from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler from app.services.employee import prepare_employee_directory +from app.services.employee_profile_scheduler import employee_profile_scheduler +from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler +from app.services.finance_report_scheduler import finance_report_scheduler from app.services.hermes_sync import sync_repository_hermes_skills from app.services.knowledge import prepare_knowledge_library from app.services.knowledge_index_tasks import knowledge_index_task_manager @@ -31,6 +35,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: prepare_knowledge_library() sync_repository_hermes_skills() knowledge_index_scheduler.start() + finance_dashboard_scheduler.start() + employee_profile_scheduler.start() + digital_employee_reminder_scheduler.start() + finance_report_scheduler.start() logger.info( "Server ready - host=%s port=%s prefix=%s", settings.app_host, @@ -38,6 +46,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.api_v1_prefix, ) yield + finance_report_scheduler.shutdown() + digital_employee_reminder_scheduler.shutdown() + employee_profile_scheduler.shutdown() + finance_dashboard_scheduler.shutdown() knowledge_index_scheduler.shutdown() knowledge_index_task_manager.shutdown() shutdown_knowledge_rag_runtime() diff --git a/server/src/app/schemas/finance_dashboard.py b/server/src/app/schemas/finance_dashboard.py index 35134db..b505ca9 100644 --- a/server/src/app/schemas/finance_dashboard.py +++ b/server/src/app/schemas/finance_dashboard.py @@ -17,6 +17,7 @@ class FinanceDashboardRead(BaseModel): spend_by_category: list[dict[str, Any]] = Field(default_factory=list) exception_mix: list[dict[str, Any]] = Field(default_factory=list) department_ranking: list[dict[str, Any]] = Field(default_factory=list) + department_employee_mix: list[dict[str, Any]] = Field(default_factory=list) employee_ranking: list[dict[str, Any]] = Field(default_factory=list) top_claims: list[dict[str, Any]] = Field(default_factory=list) bottlenecks: list[dict[str, Any]] = Field(default_factory=list) diff --git a/server/src/app/services/agent_foundation_constants.py b/server/src/app/services/agent_foundation_constants.py index aa96247..c308da9 100644 --- a/server/src/app/services/agent_foundation_constants.py +++ b/server/src/app/services/agent_foundation_constants.py @@ -90,6 +90,12 @@ DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估") DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize" +DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE = "task.hermes.finance_dashboard_snapshot" + +DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE = "task.hermes.digital_employee_reminder_scan" + +DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE = "task.hermes.finance_report_orchestration" + DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE = "task.hermes.global_risk_scan" DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile_scan" @@ -102,7 +108,9 @@ DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE = "task.hermes.expense_policy_alignm DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE = "task.hermes.risk_rule_template_organize" -DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = "task.hermes.department_expense_baseline_accumulate" +DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = ( + "task.hermes.department_expense_baseline_accumulate" +) DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE = "task.hermes.supplier_risk_profile_accumulate" @@ -132,6 +140,9 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = ( DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = { DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理", + DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE: "整理", + DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE: "升级", + DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE: "整理", DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE: "评估", DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累", DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级", diff --git a/server/src/app/services/agent_foundation_digital_employee_tasks.py b/server/src/app/services/agent_foundation_digital_employee_tasks.py index 8f5c260..50c861a 100644 --- a/server/src/app/services/agent_foundation_digital_employee_tasks.py +++ b/server/src/app/services/agent_foundation_digital_employee_tasks.py @@ -16,11 +16,14 @@ from app.services.agent_foundation_constants import ( DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE, DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE, DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE, + DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE, + DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE, DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE, DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE, DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE, DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE, DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, + DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE, DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE, DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE, @@ -30,7 +33,6 @@ from app.services.agent_foundation_constants import ( DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE, ) - DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = ( "规则由人定义,风险由人确认,主流程由外层智能体执行," "数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。" @@ -40,6 +42,65 @@ DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = ( class AgentFoundationDigitalEmployeeTaskMixin: def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]: return ( + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE, + name="财务经营快照沉淀", + description="按固定周期统计报销金额、费用结构、预算占用、高额单据和个人费用排行,刷新财务看板缓存。", + scenario_json=["schedule", "finance_dashboard", "expense", "budget"], + owner="财务运营组", + cron="0 2 * * *", + skill_category="整理", + skill_name="finance-dashboard-snapshot-analyst", + output_format="finance_dashboard_snapshot", + input_sources=[ + "expense_claims", + "expense_items", + "budget_snapshots", + "employee_profiles", + ], + execution_strategy="scheduled_dashboard_cache", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE, + name="定时提醒与待办扫描", + description="按计划扫描待审批单据、预算编制周期、差旅申请到期和逾期报销,生成可触达的提醒事项。", + scenario_json=["schedule", "reminder", "approval", "budget", "travel"], + owner="财务运营组", + cron="0 2 * * *", + skill_category="升级", + skill_name="digital-employee-reminder-scanner", + output_format="digital_employee_reminder_report", + input_sources=[ + "expense_claims", + "approval_tasks", + "budgets", + "travel_applications", + ], + execution_strategy="scheduled_reminder_scan", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE, + name="财务报告编排与邮件投递", + description=( + "按周、季、年整合费用、预算、风险、画像和提醒结果," + "生成图文 PDF 报告并按邮箱设置投递给财务管理人员。" + ), + scenario_json=["schedule", "finance_report", "pdf", "email", "management"], + owner="财务运营组", + cron="30 8 * * 1", + skill_category="整理", + skill_name="finance-report-orchestrator", + output_format="finance_report_pdf_delivery", + input_sources=[ + "finance_dashboard_snapshots", + "budget_snapshots", + "risk_observations", + "employee_profiles", + "digital_employee_reminders", + "system_mail_settings", + ], + execution_strategy="scheduled_pdf_email_report", + ), self._digital_employee_task_spec( code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE, name="制度条款结构化抽取", @@ -134,7 +195,10 @@ class AgentFoundationDigitalEmployeeTaskMixin: { "code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, "name": "财务风险图谱巡检", - "description": "按计划扫描报销单、票据、审批链、员工画像和规则命中结果,生成风险观察与可复核证据链。", + "description": ( + "按计划扫描报销单、票据、审批链、员工画像和规则命中结果," + "生成风险观察与可复核证据链。" + ), "scenario_json": ["schedule", "expense", "risk_graph", "risk_observation"], "owner": "风控与审计部", "reviewer": "顾承宇", @@ -167,7 +231,10 @@ class AgentFoundationDigitalEmployeeTaskMixin: { "code": DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, "name": "员工行为画像巡检", - "description": "按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,为风险图谱提供画像基线。", + "description": ( + "按计划更新员工费用行为、材料完整性、审批效率和智能协作画像," + "为风险图谱提供画像基线。" + ), "scenario_json": ["schedule", "employee_profile", "baseline", "risk_graph"], "owner": "风控与审计部", "reviewer": "顾承宇", @@ -219,7 +286,12 @@ class AgentFoundationDigitalEmployeeTaskMixin: skill_category="评估", skill_name="travel-spatiotemporal-consistency-evaluator", output_format="spatiotemporal_consistency_report", - input_sources=["expense_claims", "expense_items", "invoice_locations", "travel_routes"], + input_sources=[ + "expense_claims", + "expense_items", + "invoice_locations", + "travel_routes", + ], execution_strategy="reuse_financial_risk_graph_scan", ), self._digital_employee_task_spec( @@ -232,7 +304,12 @@ class AgentFoundationDigitalEmployeeTaskMixin: skill_category="评估", skill_name="budget-overrun-precontrol-evaluator", output_format="budget_precontrol_warning_report", - input_sources=["expense_claims", "budget_snapshots", "policy_refs", "profile_baselines"], + input_sources=[ + "expense_claims", + "budget_snapshots", + "policy_refs", + "profile_baselines", + ], execution_strategy="definition_ready", ), self._digital_employee_task_spec( @@ -245,13 +322,21 @@ class AgentFoundationDigitalEmployeeTaskMixin: skill_category="评估", skill_name="supplier-abnormal-relation-evaluator", output_format="supplier_abnormal_relation_report", - input_sources=["risk_graph", "expense_claims", "invoice_entities", "entity_registry"], + input_sources=[ + "risk_graph", + "expense_claims", + "invoice_entities", + "entity_registry", + ], execution_strategy="reuse_financial_risk_graph_scan", ), { "code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE, "name": "风险线索归集", - "description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则。", + "description": ( + "按计划复盘申请、报销、规则命中和人工反馈," + "归集带事实依据的潜在线索,提交人工复核,不生成规则。" + ), "scenario_json": ["schedule", "application", "reimbursement", "risk_clue"], "owner": "风控与审计部", "reviewer": "顾承宇", @@ -291,7 +376,11 @@ class AgentFoundationDigitalEmployeeTaskMixin: skill_category="升级", skill_name="risk-algorithm-replay-evaluator", output_format="algorithm_replay_evaluation_report", - input_sources=["algorithm_replay_sets", "risk_observations", "risk_observation_feedback"], + input_sources=[ + "algorithm_replay_sets", + "risk_observations", + "risk_observation_feedback", + ], execution_strategy="definition_ready", ), self._digital_employee_task_spec( @@ -304,7 +393,12 @@ class AgentFoundationDigitalEmployeeTaskMixin: skill_category="升级", skill_name="policy-reference-gap-hinter", output_format="policy_reference_gap_hint_report", - input_sources=["policy_refs", "rule_hits", "expense_claims", "risk_feedback_samples"], + input_sources=[ + "policy_refs", + "rule_hits", + "expense_claims", + "risk_feedback_samples", + ], execution_strategy="definition_ready", ), ) diff --git a/server/src/app/services/demo_company_simulation_catalog.py b/server/src/app/services/demo_company_simulation_catalog.py index b81f061..2040017 100644 --- a/server/src/app/services/demo_company_simulation_catalog.py +++ b/server/src/app/services/demo_company_simulation_catalog.py @@ -5,8 +5,15 @@ from datetime import date, datetime from decimal import Decimal from typing import Any +from app.services.document_numbering import ( + DOCUMENT_NUMBER_TOKEN_ALPHABET, + DOCUMENT_NUMBER_TOKEN_LENGTH, + build_document_number, +) + SIM_EMPLOYEE_PREFIX = "SIM2026" -SIM_CLAIM_PREFIX = "SIM-EXP-2026" +# 历史模拟数据已用这个命名空间生成 UUID;这里只用于保持幂等,不再作为业务单号。 +SIM_CLAIM_ID_NAMESPACE = "SIM-EXP-2026" SIM_BUDGET_PREFIX = "SIM-BUD-2026" SIM_TRANSACTION_PREFIX = "SIM-BTX-2026" SIM_RESERVATION_PREFIX = "SIM-BRS-2026" @@ -31,6 +38,7 @@ BUDGETED_STATUSES = SUCCESS_STATUSES | PENDING_STATUSES class SimulationConfig: target_employees: int = 100 start_date: date = date(2026, 1, 1) + end_date: date = date(2026, 6, 2) months: int = 6 seed: int = 20260602 @@ -269,6 +277,25 @@ def updated_at_for_claim_plan(plan: ClaimPlan) -> datetime: from datetime import timedelta base = plan.submitted_at or plan.occurred_at + claim_offset = sum(ord(char) for char in str(plan.claim_no or "")[-2:]) % 24 if plan.status in SUCCESS_STATUSES | {"rejected", "returned"}: - return base + timedelta(hours=2 + int(plan.claim_no[-2:]) % 24) + return base + timedelta(hours=2 + claim_offset) return base + timedelta(hours=1) + + +def build_simulation_reimbursement_no(occurred_at: datetime, sequence: int) -> str: + return build_document_number( + "reimbursement", + timestamp=occurred_at, + token=simulation_document_token(sequence), + ) + + +def simulation_document_token(sequence: int) -> str: + value = max(0, int(sequence)) + base = len(DOCUMENT_NUMBER_TOKEN_ALPHABET) + chars: list[str] = [] + for _ in range(DOCUMENT_NUMBER_TOKEN_LENGTH): + chars.append(DOCUMENT_NUMBER_TOKEN_ALPHABET[value % base]) + value //= base + return "".join(reversed(chars)) diff --git a/server/src/app/services/demo_company_simulation_filters.py b/server/src/app/services/demo_company_simulation_filters.py index b40aab7..ed8bcd7 100644 --- a/server/src/app/services/demo_company_simulation_filters.py +++ b/server/src/app/services/demo_company_simulation_filters.py @@ -22,7 +22,7 @@ APPLICATION_EXPENSE_TYPES = { } APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-") RECENT_VISIBLE_CLAIM_START = 501 -RECENT_VISIBLE_CLAIM_END = 950 +RECENT_VISIBLE_CLAIM_END = 817 def is_admin_identity(*values: Any) -> bool: @@ -70,10 +70,79 @@ def recent_visible_claim_day( *, employee_index: int, claim_index: int, + period_end: date, ) -> date | None: if not months or not (RECENT_VISIBLE_CLAIM_START <= claim_index <= RECENT_VISIBLE_CLAIM_END): return None month = months[-1] _, max_day = calendar.monthrange(month.year, month.month) + if month.year == period_end.year and month.month == period_end.month: + max_day = min(max_day, period_end.day) day = min(2, max_day) return month.replace(day=1 + ((employee_index + claim_index) % day)) + + +def simulation_claim_day( + rng: Any, + months: list[date], + *, + employee_index: int, + local_index: int, + claim_index: int, + period_end: date, +) -> date: + visible_day = recent_visible_claim_day( + months, + employee_index=employee_index, + claim_index=claim_index, + period_end=period_end, + ) + if visible_day is not None: + return visible_day + month = months[(employee_index + local_index * 2) % len(months)] + _, max_day = calendar.monthrange(month.year, month.month) + if month.year == period_end.year and month.month == period_end.month: + max_day = min(max_day, period_end.day) + day = 1 + ((employee_index * 7 + local_index * 11 + rng.randint(0, 5)) % max_day) + return month.replace(day=day) + + +def simulation_claim_count(employee: Any, index: int) -> int: + base = 7 + (index % 5) + department_code = str(getattr(getattr(employee, "department", None), "unit_code", "") or "") + grade = str(getattr(employee, "grade", "") or "") + if department_code in {"MARKET-DEPT", "TECH-DEPT"}: + base += 3 + elif department_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}: + base += 2 + if grade in {"P7", "P8"}: + base += 2 + return max(6, min(base, 16)) + + +def next_simulation_number(prefix: str, used_numbers: set[str], cursor: int) -> tuple[str, int]: + while True: + number = f"{prefix}-{cursor:04d}" + cursor += 1 + if number not in used_numbers: + used_numbers.add(number) + return number, cursor + + +def simulation_month_starts(config: Any) -> list[date]: + current = config.start_date.replace(day=1) + months: list[date] = [] + for _ in range(max(1, config.months)): + if current > config.end_date: + break + months.append(current) + year = current.year + (1 if current.month == 12 else 0) + month = 1 if current.month == 12 else current.month + 1 + current = date(year, month, 1) + return months or [config.start_date.replace(day=1)] + + +def simulation_period_end(config: Any) -> date: + last_month = simulation_month_starts(config)[-1] + _, max_day = calendar.monthrange(last_month.year, last_month.month) + return min(last_month.replace(day=max_day), config.end_date) diff --git a/server/src/app/services/demo_company_simulation_seed.py b/server/src/app/services/demo_company_simulation_seed.py index d5550aa..56c47a7 100644 --- a/server/src/app/services/demo_company_simulation_seed.py +++ b/server/src/app/services/demo_company_simulation_seed.py @@ -1,13 +1,12 @@ from __future__ import annotations -import calendar import random import uuid from datetime import UTC, date, datetime, timedelta from decimal import Decimal from typing import Any -from sqlalchemy import func, or_, select +from sqlalchemy import or_, select from sqlalchemy.orm import Session, selectinload from app.core.security import hash_password @@ -28,7 +27,7 @@ from app.services.demo_company_simulation_catalog import ( MONTH_FACTORS, PENDING_STATUSES, SIM_BUDGET_PREFIX, - SIM_CLAIM_PREFIX, + SIM_CLAIM_ID_NAMESPACE, SIM_EMPLOYEE_PREFIX, SIM_PROJECT_CODE, SIM_RESERVATION_PREFIX, @@ -45,6 +44,7 @@ from app.services.demo_company_simulation_catalog import ( SimulationConfig, SimulationSummary, build_employee_name, + build_simulation_reimbursement_no, claim_location, claim_reason, department_from_row, @@ -57,7 +57,11 @@ from app.services.demo_company_simulation_catalog import ( ) from app.services.demo_company_simulation_filters import ( is_admin_employee_like, - recent_visible_claim_day, + next_simulation_number, + simulation_claim_count, + simulation_claim_day, + simulation_month_starts, + simulation_period_end, ) @@ -117,7 +121,7 @@ class HalfYearExpenseSimulationSeeder: budget_reservations_to_create=reservation_count, risk_observations_to_create=risk_count, period_start=self.config.start_date.isoformat(), - period_end=self._period_end().isoformat(), + period_end=simulation_period_end(self.config).isoformat(), ) def _department_refs(self, *, apply: bool) -> list[DepartmentRef]: @@ -275,16 +279,19 @@ class HalfYearExpenseSimulationSeeder: def _build_claim_plans(self, employees: list[EmployeeRef]) -> list[ClaimPlan]: plans: list[ClaimPlan] = [] - months = self._month_starts() + months = simulation_month_starts(self.config) + period_end = simulation_period_end(self.config) claim_index = 1 for employee_index, employee in enumerate(employees): - count = self._claim_count_for_employee(employee, employee_index) + count = simulation_claim_count(employee, employee_index) for local_index in range(count): - occurred_day = self._claim_day( + occurred_day = simulation_claim_day( + self.rng, months, - employee_index, - local_index, - claim_index, + employee_index=employee_index, + local_index=local_index, + claim_index=claim_index, + period_end=period_end, ) expense_type = self._expense_type_for_employee(employee) amount = self._claim_amount(employee, expense_type, occurred_day) @@ -301,10 +308,10 @@ class HalfYearExpenseSimulationSeeder: id=str( uuid.uuid5( uuid.NAMESPACE_DNS, - f"x-financial:{SIM_CLAIM_PREFIX}:{claim_index}", + f"x-financial:{SIM_CLAIM_ID_NAMESPACE}:{claim_index}", ) ), - claim_no=f"{SIM_CLAIM_PREFIX}-{claim_index:04d}", + claim_no=self._simulation_claim_no(occurred_at, claim_index), employee=employee, expense_type=expense_type, reason=claim_reason( @@ -372,12 +379,25 @@ class HalfYearExpenseSimulationSeeder: ) -> tuple[dict[tuple[int, str, str, str, str], str], int]: allocation_map: dict[tuple[int, str, str, str, str], str] = {} created_count = 0 - for index, plan in enumerate(plans, start=1): + used_budget_nos = set( + self.db.scalars( + select(BudgetAllocation.budget_no).where( + BudgetAllocation.budget_no.like(f"{SIM_BUDGET_PREFIX}%") + ) + ).all() + ) + budget_no_cursor = 1 + for plan in plans: existing = self._find_sim_allocation(plan) if existing is not None: allocation_map[plan.key] = existing.id continue created_count += 1 + budget_no, budget_no_cursor = next_simulation_number( + SIM_BUDGET_PREFIX, + used_budget_nos, + budget_no_cursor, + ) allocation_id = str( uuid.uuid5( uuid.NAMESPACE_DNS, @@ -390,7 +410,7 @@ class HalfYearExpenseSimulationSeeder: self.db.add( BudgetAllocation( id=allocation_id, - budget_no=f"{SIM_BUDGET_PREFIX}-{index:04d}", + budget_no=budget_no, fiscal_year=plan.key[0], period_type="quarter", period_key=plan.period_key, @@ -415,15 +435,19 @@ class HalfYearExpenseSimulationSeeder: return allocation_map, created_count def _ensure_claims(self, plans: list[ClaimPlan], *, apply: bool) -> tuple[int, int]: - existing_claim_nos = set( - self.db.scalars( - select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")) + existing_rows = list( + self.db.execute( + select(ExpenseClaim.id, ExpenseClaim.claim_no).where( + ExpenseClaim.project_code == SIM_PROJECT_CODE + ) ).all() ) + existing_claim_ids = {str(row.id) for row in existing_rows} + existing_claim_nos = set(self.db.scalars(select(ExpenseClaim.claim_no)).all()) claim_count = 0 item_count = 0 for plan in plans: - if plan.claim_no in existing_claim_nos: + if plan.id in existing_claim_ids or plan.claim_no in existing_claim_nos: continue claim_count += 1 item_count += len(plan.items) @@ -645,40 +669,6 @@ class HalfYearExpenseSimulationSeeder: plan.budget_subject_code, ) - def _month_starts(self) -> list[date]: - current = self.config.start_date.replace(day=1) - months: list[date] = [] - for _ in range(max(1, self.config.months)): - months.append(current) - year = current.year + (1 if current.month == 12 else 0) - month = 1 if current.month == 12 else current.month + 1 - current = date(year, month, 1) - return months - - def _period_end(self) -> date: - months = self._month_starts() - last_month = months[-1] - return last_month.replace(day=calendar.monthrange(last_month.year, last_month.month)[1]) - - def _claim_day( - self, - months: list[date], - employee_index: int, - local_index: int, - claim_index: int, - ) -> date: - visible_day = recent_visible_claim_day( - months, - employee_index=employee_index, - claim_index=claim_index, - ) - if visible_day is not None: - return visible_day - month = months[(employee_index + local_index * 2) % len(months)] - _, max_day = calendar.monthrange(month.year, month.month) - day = 1 + ((employee_index * 7 + local_index * 11 + self.rng.randint(0, 5)) % max_day) - return month.replace(day=day) - def _weighted_department(self, departments: list[DepartmentRef], index: int) -> DepartmentRef: weighted: list[DepartmentRef] = [] by_code = {item.unit_code: item for item in departments} @@ -696,16 +686,6 @@ class HalfYearExpenseSimulationSeeder: subjects = list(weights) return self.rng.choices(subjects, weights=[weights[item] for item in subjects], k=1)[0] - def _claim_count_for_employee(self, employee: EmployeeRef, index: int) -> int: - base = 7 + (index % 5) - if employee.department.unit_code in {"MARKET-DEPT", "TECH-DEPT"}: - base += 3 - elif employee.department.unit_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}: - base += 2 - if employee.grade in {"P7", "P8"}: - base += 2 - return max(6, min(base, 16)) - def _claim_amount( self, employee: EmployeeRef, @@ -726,6 +706,10 @@ class HalfYearExpenseSimulationSeeder: Decimal("0.01") ) + @staticmethod + def _simulation_claim_no(occurred_at: datetime, claim_index: int) -> str: + return build_simulation_reimbursement_no(occurred_at, claim_index) + def _status_for_claim(self, employee_index: int, local_index: int) -> tuple[str, str | None]: selector = (employee_index * 11 + local_index * 17 + self.config.seed) % 100 if selector < 42: diff --git a/server/src/app/services/digital_employee_dashboard.py b/server/src/app/services/digital_employee_dashboard.py index fa8fbad..567022e 100644 --- a/server/src/app/services/digital_employee_dashboard.py +++ b/server/src/app/services/digital_employee_dashboard.py @@ -28,7 +28,9 @@ TASK_CODE_TO_TYPE = { "task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate", "task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate", "task.hermes.multi_evidence_consistency_evaluate": "multi_evidence_consistency_evaluate", - "task.hermes.travel_spatiotemporal_consistency_evaluate": "travel_spatiotemporal_consistency_evaluate", + "task.hermes.travel_spatiotemporal_consistency_evaluate": ( + "travel_spatiotemporal_consistency_evaluate" + ), "task.hermes.budget_overrun_precontrol_evaluate": "budget_overrun_precontrol_evaluate", "task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate", "task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate", @@ -136,6 +138,16 @@ TASK_SPECS: dict[str, dict[str, str]] = { "category": "升级", "color": "var(--chart-amber)", }, + "finance_dashboard_snapshot": { + "label": "财务看板指标快照", + "category": "积累", + "color": "var(--chart-blue)", + }, + "digital_employee_reminder_scan": { + "label": "定时提醒扫描", + "category": "整理", + "color": "var(--success)", + }, } CATEGORY_SPECS = { @@ -203,6 +215,8 @@ class DigitalEmployeeDashboardService: + metrics["risk_clues"] + metrics["profile_snapshots"] + metrics["knowledge_documents"] + + metrics["finance_snapshots"] + + metrics["reminders"] ) return { @@ -216,6 +230,8 @@ class DigitalEmployeeDashboardService: "riskClues": metrics["risk_clues"], "profileSnapshots": metrics["profile_snapshots"], "knowledgeDocuments": metrics["knowledge_documents"], + "financeDashboardSnapshots": metrics["finance_snapshots"], + "reminders": metrics["reminders"], "successRate": self._percent(success_runs, total_runs), "failureRate": self._percent(failed_runs, total_runs), } @@ -232,6 +248,8 @@ class DigitalEmployeeDashboardService: "riskClues": 0, "profileSnapshots": 0, "knowledgeDocuments": 0, + "financeDashboardSnapshots": 0, + "reminders": 0, "businessOutputs": 0, } for label in labels @@ -254,11 +272,15 @@ class DigitalEmployeeDashboardService: row["riskClues"] += metrics["risk_clues"] row["profileSnapshots"] += metrics["profile_snapshots"] row["knowledgeDocuments"] += metrics["knowledge_documents"] + row["financeDashboardSnapshots"] += metrics["finance_snapshots"] + row["reminders"] += metrics["reminders"] row["businessOutputs"] += ( metrics["risk_observations"] + metrics["risk_clues"] + metrics["profile_snapshots"] + metrics["knowledge_documents"] + + metrics["finance_snapshots"] + + metrics["reminders"] ) return [rows[label] for label in labels] @@ -374,6 +396,12 @@ class DigitalEmployeeDashboardService: summary, ("snapshot_count", "profile_snapshot_count", "profile_snapshots"), ) + if self._resolve_task_type(run) == "finance_dashboard_snapshot": + metrics["profile_snapshots"] = 0 + metrics["finance_snapshots"] = self._first_int( + summary, + ("finance_snapshot_count", "dashboard_snapshot_count"), + ) metrics["knowledge_documents"] = max( self._first_int( summary, @@ -383,9 +411,21 @@ class DigitalEmployeeDashboardService: self._list_length(route_json, ("document_ids", "requested_document_ids")), ) metrics["scanned_claims"] = self._first_int(summary, ("scanned_claim_count", "claim_count")) - metrics["target_employees"] = self._first_int(summary, ("target_employee_count", "employee_count")) + metrics["target_employees"] = self._first_int( + summary, + ("target_employee_count", "employee_count"), + ) metrics["rule_hits"] = self._first_int(summary, ("rule_hit_count", "rule_hits")) metrics["facts"] = self._first_int(summary, ("fact_count", "facts")) + metrics["reminders"] = self._first_int( + summary, + ( + "reminder_count", + "reminders", + "approval_pending_count", + "budget_reminder_count", + ), + ) return metrics @staticmethod @@ -394,11 +434,13 @@ class DigitalEmployeeDashboardService: "risk_observations": 0, "risk_clues": 0, "profile_snapshots": 0, + "finance_snapshots": 0, "knowledge_documents": 0, "scanned_claims": 0, "target_employees": 0, "rule_hits": 0, "facts": 0, + "reminders": 0, } def _extract_run_summary(self, run: AgentRun) -> dict[str, Any]: @@ -413,7 +455,9 @@ class DigitalEmployeeDashboardService: def _matched_tool_call(self, run: AgentRun, task_type: str) -> AgentToolCall | None: digital_tools = [ - tool for tool in run.tool_calls if str(tool.tool_name or "").startswith("digital_employee.") + tool + for tool in run.tool_calls + if str(tool.tool_name or "").startswith("digital_employee.") ] for tool in run.tool_calls: candidates = [ @@ -441,7 +485,10 @@ class DigitalEmployeeDashboardService: route_json = run.route_json or {} if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value: return True - return any(str(tool.tool_name or "").startswith("digital_employee.") for tool in run.tool_calls) + return any( + str(tool.tool_name or "").startswith("digital_employee.") + for tool in run.tool_calls + ) def _resolve_task_type(self, run: AgentRun) -> str: route_json = run.route_json or {} @@ -491,6 +538,8 @@ class DigitalEmployeeDashboardService: return "global_risk_scan" if "employee_behavior_profile" in name: return "employee_behavior_profile_scan" + if "reminder" in name: + return "digital_employee_reminder_scan" if "finance_policy_knowledge" in name: return "finance_policy_knowledge_organize" if "risk_clue" in name: diff --git a/server/src/app/services/digital_employee_finance_report_task.py b/server/src/app/services/digital_employee_finance_report_task.py new file mode 100644 index 0000000..6f60b6e --- /dev/null +++ b/server/src/app/services/digital_employee_finance_report_task.py @@ -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'}。" + ) diff --git a/server/src/app/services/digital_employee_reminder_scheduler.py b/server/src/app/services/digital_employee_reminder_scheduler.py new file mode 100644 index 0000000..f224fb3 --- /dev/null +++ b/server/src/app/services/digital_employee_reminder_scheduler.py @@ -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() diff --git a/server/src/app/services/digital_employee_reminder_task.py b/server/src/app/services/digital_employee_reminder_task.py new file mode 100644 index 0000000..592b729 --- /dev/null +++ b/server/src/app/services/digital_employee_reminder_task.py @@ -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), + } diff --git a/server/src/app/services/employee_profile_scan_task.py b/server/src/app/services/employee_profile_scan_task.py new file mode 100644 index 0000000..66ae7ba --- /dev/null +++ b/server/src/app/services/employee_profile_scan_task.py @@ -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 {}, + } diff --git a/server/src/app/services/employee_profile_scheduler.py b/server/src/app/services/employee_profile_scheduler.py new file mode 100644 index 0000000..32e1e05 --- /dev/null +++ b/server/src/app/services/employee_profile_scheduler.py @@ -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() diff --git a/server/src/app/services/expense_claim_read_model.py b/server/src/app/services/expense_claim_read_model.py index 5bed930..fe3bdaf 100644 --- a/server/src/app/services/expense_claim_read_model.py +++ b/server/src/app/services/expense_claim_read_model.py @@ -245,7 +245,7 @@ class ExpenseClaimReadModelMixin: def _ensure_draft_claim(self, claim: ExpenseClaim) -> None: if not self._is_editable_claim_status(claim.status): - raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。") + raise ValueError("只有草稿、待补充或退回待提交状态的单据才允许执行该操作。") @staticmethod def _ensure_draft_pending_claim(claim: ExpenseClaim) -> None: diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index b570ec1..3490802 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -562,9 +562,6 @@ class ExpenseClaimService( if claim is None: return None - if self._is_expense_application_claim(claim) and not current_user.is_admin: - raise ValueError("申请单只有系统管理员可以删除。") - if self._access_policy.is_archived_claim(claim) and not current_user.is_admin: raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。") @@ -756,6 +753,5 @@ class ExpenseClaimService( - diff --git a/server/src/app/services/finance_dashboard.py b/server/src/app/services/finance_dashboard.py index 5310978..7ec8ed7 100644 --- a/server/src/app/services/finance_dashboard.py +++ b/server/src/app/services/finance_dashboard.py @@ -9,75 +9,23 @@ from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session -from app.db.base import Base from app.models.budget import BudgetAllocation from app.models.financial_record import ExpenseClaim from app.schemas.finance_dashboard import FinanceDashboardRead from app.services.budget_support import BudgetSupportMixin from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS - -SLA_TARGET_HOURS = Decimal("8.0") -PENDING_STATUSES = { - "submitted", - "review", - "pending_review", - "manager_review", - "budget_review", - "finance_review", - "approving", -} -SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"} -EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"} -EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}] -EXPENSE_TYPE_ALIASES = { - "travel_application": "travel", - "business_travel": "travel", - "trip": "travel", - "traffic": "travel", - "transportation": "travel", - "hotel": "travel", - "accommodation": "travel", - "business_meal": "meal", - "communication_fee": "communication", -} -CHART_COLORS = [ - "var(--theme-primary)", - "var(--chart-blue)", - "var(--chart-amber)", - "var(--chart-purple)", - "var(--success)", - "var(--danger)", -] -STAGE_LABELS = { - "manager": "直属经理", - "manager_review": "直属经理", - "budget": "预算复核", - "budget_review": "预算复核", - "finance": "财务审核", - "finance_review": "财务审核", - "payment": "付款确认", - "pending_payment": "付款确认", -} -RISK_SIGNAL_LABELS = { - "duplicate_invoice": "重复发票", - "split_billing": "拆分报销", - "frequent_small_claims": "高频小额", - "location_mismatch": "地点不一致", - "amount_outlier": "金额异常", - "preapproval_absent": "缺少事前申请", - "missing_material": "材料不完整", - "budget_pressure": "预算压力偏高", - "budget_overrun": "预算超支", - "budget_warning": "预算预警", - "over_budget": "预算超支", - "invoice_abnormal": "发票异常", - "invoice_missing": "缺少发票", - "missing_invoice": "缺少发票", - "policy_violation": "政策不符", - "abnormal_frequency": "频次异常", - "manual_review": "人工复核", -} +from app.services.finance_dashboard_constants import ( + CHART_COLORS, + EMPTY_DONUT, + EXCLUDED_SPEND_STATUSES, + EXPENSE_TYPE_ALIASES, + PENDING_STATUSES, + RISK_SIGNAL_LABELS, + SLA_TARGET_HOURS, + STAGE_LABELS, + SUCCESS_STATUSES, +) class FinanceDashboardService(BudgetSupportMixin): @@ -93,7 +41,6 @@ class FinanceDashboardService(BudgetSupportMixin): trend_range: str = "近12天", department_range: str = "本月", ) -> FinanceDashboardRead: - self._ensure_storage_ready() now = datetime.now(UTC) start, end, resolved_key = self._resolve_scope( range_key=range_key, @@ -103,7 +50,7 @@ class FinanceDashboardService(BudgetSupportMixin): ) previous_start = start - (end - start) trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now) - department_start, department_end = self._resolve_department_scope(department_range, now) + ranking_start, ranking_end = self._resolve_ranking_scope(department_range, now) claims = [ claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim) @@ -111,7 +58,7 @@ class FinanceDashboardService(BudgetSupportMixin): scope_claims = self._claims_between(claims, start, end) previous_claims = self._claims_between(claims, previous_start, start) trend_claims = self._claims_between(claims, trend_start, trend_end) - department_claims = self._claims_between(claims, department_start, department_end) + ranking_claims = self._claims_between(claims, ranking_start, ranking_end) totals = self._totals(scope_claims) previous_totals = self._totals(previous_claims) @@ -127,17 +74,15 @@ class FinanceDashboardService(BudgetSupportMixin): trend=self._trend(trend_labels, trend_claims, now), spend_by_category=self._spend_by_category(scope_claims), exception_mix=self._payment_status_mix(scope_claims), - department_ranking=self._department_ranking(department_claims), - employee_ranking=self._employee_ranking(department_claims), - top_claims=self._top_claims(department_claims), + department_ranking=self._department_ranking(ranking_claims), + department_employee_mix=self._department_employee_mix(ranking_claims), + employee_ranking=self._employee_ranking(ranking_claims), + top_claims=self._top_claims(ranking_claims), bottlenecks=self._bottlenecks(scope_claims), budget_summary=self._budget_summary(now.year), budget_metrics=self._budget_metrics(now.year), ) - def _ensure_storage_ready(self) -> None: - Base.metadata.create_all(bind=self.db.get_bind()) - def _fetch_claims(self) -> list[ExpenseClaim]: stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc()) return list(self.db.scalars(stmt).all()) @@ -189,18 +134,20 @@ class FinanceDashboardService(BudgetSupportMixin): labels = [self._date_label(start_day + timedelta(days=index)) for index in range(days)] return self._day_start(start_day), self._day_after(end_day), labels - def _resolve_department_scope( + def _resolve_ranking_scope( self, department_range: str, now: datetime, ) -> tuple[datetime, datetime]: today = now.date() key = str(department_range or "").strip() - if key == "本周": - start_day = today - timedelta(days=today.weekday()) - elif key == "本季度": + if key == "全部": + return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today) + if key == "本季度": quarter_month = ((today.month - 1) // 3) * 3 + 1 start_day = today.replace(month=quarter_month, day=1) + elif key == "本年": + start_day = today.replace(month=1, day=1) else: start_day = today.replace(day=1) return self._day_start(start_day), self._day_after(today) @@ -347,6 +294,7 @@ class FinanceDashboardService(BudgetSupportMixin): buckets: dict[str, Decimal] = defaultdict(Decimal) counts: dict[str, int] = defaultdict(int) pending_amounts: dict[str, Decimal] = defaultdict(Decimal) + employees: dict[str, set[str]] = defaultdict(set) for claim in claims: status = self._status(claim) if status in EXCLUDED_SPEND_STATUSES: @@ -357,6 +305,9 @@ class FinanceDashboardService(BudgetSupportMixin): amount = self._claim_amount(claim) buckets[department_name] += amount counts[department_name] += 1 + employee_name = str(claim.employee_name or "").strip() + if not self._is_missing_finance_dimension(employee_name): + employees[department_name].add(employee_name) if status in PENDING_STATUSES: pending_amounts[department_name] += amount @@ -366,6 +317,7 @@ class FinanceDashboardService(BudgetSupportMixin): "amount": self._decimal_number(amount), "value": self._decimal_number(amount), "count": counts[name], + "employeeCount": len(employees[name]), "pendingAmount": self._decimal_number(pending_amounts[name]), "color": CHART_COLORS[index % len(CHART_COLORS)], } @@ -375,6 +327,34 @@ class FinanceDashboardService(BudgetSupportMixin): ] return rows + def _department_employee_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]: + buckets: dict[tuple[str, str], Decimal] = defaultdict(Decimal) + for claim in claims: + if self._status(claim) in EXCLUDED_SPEND_STATUSES: + continue + department_name = str(claim.department_name or "").strip() + employee_name = str(claim.employee_name or "").strip() + if self._is_missing_finance_dimension(department_name): + continue + if self._is_missing_finance_dimension(employee_name): + continue + buckets[(department_name, employee_name)] += self._claim_amount(claim) + + rows = [ + { + "name": f"{department_name} · {employee_name}", + "department": department_name, + "employee": employee_name, + "value": self._decimal_number(amount), + "amount": self._decimal_number(amount), + "color": CHART_COLORS[index % len(CHART_COLORS)], + } + for index, ((department_name, employee_name), amount) in enumerate( + sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6] + ) + ] + return rows or EMPTY_DONUT + def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]: buckets: dict[str, Decimal] = defaultdict(Decimal) counts: dict[str, int] = defaultdict(int) diff --git a/server/src/app/services/finance_dashboard_constants.py b/server/src/app/services/finance_dashboard_constants.py new file mode 100644 index 0000000..9aa98c8 --- /dev/null +++ b/server/src/app/services/finance_dashboard_constants.py @@ -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": "人工复核", +} diff --git a/server/src/app/services/finance_dashboard_scheduler.py b/server/src/app/services/finance_dashboard_scheduler.py new file mode 100644 index 0000000..1571029 --- /dev/null +++ b/server/src/app/services/finance_dashboard_scheduler.py @@ -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() diff --git a/server/src/app/services/finance_dashboard_snapshot.py b/server/src/app/services/finance_dashboard_snapshot.py new file mode 100644 index 0000000..0dc8d30 --- /dev/null +++ b/server/src/app/services/finance_dashboard_snapshot.py @@ -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) diff --git a/server/src/app/services/finance_report_context.py b/server/src/app/services/finance_report_context.py new file mode 100644 index 0000000..35930f2 --- /dev/null +++ b/server/src/app/services/finance_report_context.py @@ -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}" diff --git a/server/src/app/services/finance_report_mailer.py b/server/src/app/services/finance_report_mailer.py new file mode 100644 index 0000000..7fc64e8 --- /dev/null +++ b/server/src/app/services/finance_report_mailer.py @@ -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) diff --git a/server/src/app/services/finance_report_renderer.py b/server/src/app/services/finance_report_renderer.py new file mode 100644 index 0000000..61c4c74 --- /dev/null +++ b/server/src/app/services/finance_report_renderer.py @@ -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""" + + + + {_e(period.get("title"))} + + + +
+
+

{_e(period.get("title"))}

+
+ 周期:{_e(period.get("label"))} 生成时间:{_e(context.get("generated_at"))} +
+
+

管理摘要

+ {''.join(f'
{_e(item)}
' for item in insights)} +

关键指标

+
+ {_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}%')} +
+

每日报销趋势

+ {_trend_html(trend)} +

部门费用排行

+ {_ranking_html(departments, "amount")} +

高额单据

+ {_top_claims_html(top_claims)} +

行动清单

+ {_actions_html(actions)} +
+ +""" + + 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'
{_e(label)}
' + f'
{_e(value)}
' + ) + + +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( + '
' + f'
{_e(label)}
' + f'
' + f'
{_e(_money(value))}
' + "
" + ) + return "".join(rows) or '
暂无数据
' + + +def _top_claims_html(rows: list[dict[str, Any]]) -> str: + body = "".join( + "" + f"{_e(item.get('claimNo'))}" + f"{_e(item.get('employeeName'))}" + f"{_e(item.get('departmentName'))}" + f"{_e(item.get('amountLabel') or _money(item.get('amount')))}" + "" + for item in rows[:6] + ) + return ( + "" + f"{body}
单号员工部门金额
" + ) + + +def _actions_html(rows: list[dict[str, Any]]) -> str: + if not rows: + return '
暂无需要升级的行动项。
' + return "".join( + ( + f'
{_e(item.get("title"))}' + f'|{_e(item.get("owner"))}
{_e(item.get("suggestion"))}
' + ) + 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() diff --git a/server/src/app/services/finance_report_scheduler.py b/server/src/app/services/finance_report_scheduler.py new file mode 100644 index 0000000..30c5e5a --- /dev/null +++ b/server/src/app/services/finance_report_scheduler.py @@ -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() diff --git a/server/src/app/services/orchestrator_execution.py b/server/src/app/services/orchestrator_execution.py index 7a84c99..f30a920 100644 --- a/server/src/app/services/orchestrator_execution.py +++ b/server/src/app/services/orchestrator_execution.py @@ -12,6 +12,9 @@ from app.schemas.agent_asset import AgentAssetListItem, AgentAssetRead from app.schemas.ontology import OntologyParseResult from app.schemas.orchestrator import OrchestratorRequest from app.schemas.user_agent import UserAgentRequest, UserAgentResponse +from app.services.digital_employee_finance_report_task import ( + DigitalEmployeeFinanceReportTaskService, +) from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService from app.services.hermes_risk_scanner import HermesRiskScannerService @@ -388,6 +391,11 @@ class OrchestratorExecutionEngine: ) if task_type == "risk_clue_collect": return self._execute_risk_clue_collect(run_id=run_id, context_json=context_json) + if task_type == "finance_report_orchestration": + return self._execute_finance_report_orchestration( + run_id=run_id, + context_json=context_json, + ) return None def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome: @@ -542,6 +550,56 @@ class OrchestratorExecutionEngine: failed_tool_count=1 if degraded else 0, ) + def _execute_finance_report_orchestration( + self, + *, + run_id: str, + context_json: dict[str, Any], + ) -> ExecutionOutcome: + report_type = str(context_json.get("report_type") or "weekly").strip().lower() + if report_type not in {"weekly", "quarterly", "annual"}: + report_type = "weekly" + summary, degraded = self._invoke_tool( + run_id=run_id, + tool_type=AgentToolType.DATABASE.value, + tool_name="digital_employee.finance_report.orchestrate", + request_json={ + "task_type": "finance_report_orchestration", + "report_type": report_type, + }, + context_json=context_json, + executor=lambda: DigitalEmployeeFinanceReportTaskService(self.db).generate_report( + report_type=report_type, # type: ignore[arg-type] + send_email=bool(context_json.get("send_email", True)), + dry_run_email=bool(context_json.get("dry_run_email", False)), + source=AgentRunSource.SCHEDULE.value, + run_id=run_id, + record_tool_call=False, + ), + fallback_factory=lambda exc: { + "message": f"财务报告生成失败,已保留失败记录:{exc}", + "degraded": True, + }, + ) + message = ( + str(summary.get("message") or "").strip() + or "财务报告编排完成:" + f"{summary.get('title', '财务经营报告')}," + f"邮件状态 {(summary.get('delivery') or {}).get('status', 'skipped')}。" + ) + return ExecutionOutcome( + status=AgentRunStatus.SUCCEEDED.value, + result={ + "message": message, + "report_type": "finance_report_orchestration", + "summary": summary, + "degraded": degraded, + }, + degraded=degraded, + tool_count=1, + failed_tool_count=1 if degraded else 0, + ) + @staticmethod def _resolve_task_type(task_asset: AgentAssetRead | None) -> str: if task_asset is None: diff --git a/server/src/app/services/risk_observations.py b/server/src/app/services/risk_observations.py index 3cb2635..f547568 100644 --- a/server/src/app/services/risk_observations.py +++ b/server/src/app/services/risk_observations.py @@ -5,7 +5,7 @@ from decimal import Decimal from typing import Any from sqlalchemy import func, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.algorithem.risk_graph import RiskHistoryStats, RiskObservationDraft from app.db.base import Base @@ -326,6 +326,7 @@ class RiskObservationService: since = datetime.now(UTC) - timedelta(days=window_days) stmt = ( select(RiskObservation) + .options(joinedload(RiskObservation.claim)) .where(RiskObservation.created_at >= since) .order_by(RiskObservation.created_at.desc()) .limit(limit) diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index c2af6d8..79053b2 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -178,12 +178,17 @@ class UserAgentApplicationMixin: step = self._resolve_expense_application_step(payload, facts) application_claim = None if step == "submitted": - application_claim = self._find_duplicate_expense_application_record(payload, facts) - if application_claim is not None: - step = "duplicate" - facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip() + 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._create_expense_application_record(payload, facts) + application_claim = self._find_duplicate_expense_application_record(payload, facts) + if application_claim is not None: + step = "duplicate" + facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip() + else: + application_claim = self._create_expense_application_record(payload, facts) facts["application_no"] = application_claim.claim_no facts["application_claim_id"] = application_claim.id facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim) @@ -229,9 +234,14 @@ class UserAgentApplicationMixin: if step == "submitted": application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts) manager_name = str(facts.get("manager_name") or "").strip() or "直属领导" + submitted_title = ( + "申请单据已修改并重新提交,已进入审批流程。" + if str(facts.get("application_edit_mode") or "").strip().lower() == "true" + else "申请单据已生成,并已进入审批流程。" + ) return "\n\n".join( [ - "申请单据已生成,并已进入审批流程。", + submitted_title, f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。", f"申请单号:{application_no}", "下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。", @@ -930,6 +940,101 @@ class UserAgentApplicationMixin: return "会务费用申请" return "差旅费用申请" + @staticmethod + def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str: + if not isinstance(context_json, dict): + return "" + is_edit_mode = bool(context_json.get("application_edit_mode") or context_json.get("applicationEditMode")) + claim_id = str( + context_json.get("application_edit_claim_id") + or context_json.get("applicationEditClaimId") + or "" + ).strip() + return claim_id if is_edit_mode and claim_id else "" + + @staticmethod + def _is_expense_application_claim_like(claim: ExpenseClaim) -> bool: + expense_type = str(claim.expense_type or "").strip().lower() + claim_no = str(claim.claim_no or "").strip().upper() + flags = claim.risk_flags_json + if isinstance(flags, dict): + flags = [flags] + if not isinstance(flags, list): + flags = [] + has_application_detail = any( + isinstance(flag, dict) + and ( + str(flag.get("business_stage") or "").strip() == "expense_application" + or isinstance(flag.get("application_detail"), dict) + ) + for flag in flags + ) + return ( + expense_type in {"application", "expense_application"} + or expense_type.endswith("_application") + or claim_no.startswith("AP-") + or claim_no.startswith("APP-") + or has_application_detail + ) + + def _find_editable_expense_application_record( + self, + payload: UserAgentRequest, + ) -> ExpenseClaim | None: + claim_id = self._resolve_application_edit_claim_id(payload.context_json or {}) + if not claim_id: + return None + + claim = self.db.get(ExpenseClaim, claim_id) + if claim is None: + raise ValueError("未找到要修改的申请单。") + if not self._is_expense_application_claim_like(claim): + raise ValueError("只能修改申请单。") + + current_user = self._build_application_current_user(payload) + access_policy = ExpenseClaimAccessPolicy(self.db) + if not (current_user.is_admin or access_policy.is_claim_owned_by_current_user(claim, current_user)): + raise ValueError("只能修改本人被退回的申请单。") + + status = str(claim.status or "").strip().lower() + if status not in {"returned", "draft", "supplement"}: + raise ValueError("当前申请单状态不支持修改。") + return claim + + def _update_expense_application_record( + self, + payload: UserAgentRequest, + facts: dict[str, str], + claim: ExpenseClaim, + ) -> ExpenseClaim: + current_user = self._build_application_current_user(payload) + flags = claim.risk_flags_json + if isinstance(flags, dict): + flags = [flags] + if not isinstance(flags, list): + flags = [] + preserved_flags = [ + flag + for flag in flags + if not ( + isinstance(flag, dict) + and str(flag.get("source") or "").strip() == "application_detail" + ) + ] + claim.expense_type = self._resolve_application_expense_type_code(facts) + claim.reason = str(facts.get("reason") or "费用申请").strip() or "费用申请" + claim.location = str(facts.get("location") or "待补充").strip() or "待补充" + claim.amount = self._parse_application_amount_to_decimal(facts.get("amount", "")) + claim.occurred_at = self._parse_application_occurred_at(facts.get("time", "")) + claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)] + + from app.services.expense_claims import ExpenseClaimService + + submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user) + if submitted is None: + raise ValueError("未找到可修改的申请单。") + return submitted + def _create_expense_application_record( self, payload: UserAgentRequest, diff --git a/server/src/app/skills/domain/digital-employee-reminder-scanner/SKILL.md b/server/src/app/skills/domain/digital-employee-reminder-scanner/SKILL.md new file mode 100644 index 0000000..1cc994c --- /dev/null +++ b/server/src/app/skills/domain/digital-employee-reminder-scanner/SKILL.md @@ -0,0 +1,22 @@ +--- +name: digital-employee-reminder-scanner +description: 按计划扫描审批、预算、差旅申请和逾期报销,生成数字员工提醒事项。 +--- + +# 定时提醒与待办扫描 + +## 功能说明 + +该技能面向后台数字员工运行,用于把财务流程中需要人工跟进的事项沉淀为提醒记录,支持后续通知中心、审批提醒和预算编制提醒。 + +## 输入范围 + +- 待审批报销单和审批节点。 +- 预算编制周期、预算管理员和预算状态。 +- 差旅申请有效期、关联报销状态和逾期情况。 + +## 输出要求 + +- 输出提醒人数、提醒事项数和提醒类型分布。 +- 标记对应业务对象,便于前端跳转到审批、预算、报销或风险详情。 +- 只生成提醒和待办,不代替负责人完成审批或预算编制。 diff --git a/server/src/app/skills/domain/finance-dashboard-snapshot-analyst/SKILL.md b/server/src/app/skills/domain/finance-dashboard-snapshot-analyst/SKILL.md new file mode 100644 index 0000000..70b4851 --- /dev/null +++ b/server/src/app/skills/domain/finance-dashboard-snapshot-analyst/SKILL.md @@ -0,0 +1,22 @@ +--- +name: finance-dashboard-snapshot-analyst +description: 按计划统计报销、预算、费用结构和高额单据,刷新财务看板缓存并沉淀经营指标。 +--- + +# 财务经营快照沉淀 + +## 功能说明 + +该技能面向后台数字员工运行,按固定周期读取报销单、费用明细、预算快照和员工画像,生成财务看板所需的经营指标缓存。 + +## 输入范围 + +- 报销单、费用明细和付款状态。 +- 部门、个人、费用类型和预算维度。 +- 员工画像与历史费用基线。 + +## 输出要求 + +- 输出当期报销金额、报销单数、预算使用率和费用结构。 +- 输出高额单据、高费用个人和重点部门的排行摘要。 +- 只沉淀事实和指标,不直接修改预算、规则或审批结论。 diff --git a/server/src/app/skills/domain/finance-report-orchestrator/SKILL.md b/server/src/app/skills/domain/finance-report-orchestrator/SKILL.md new file mode 100644 index 0000000..932eca0 --- /dev/null +++ b/server/src/app/skills/domain/finance-report-orchestrator/SKILL.md @@ -0,0 +1,46 @@ +--- +name: finance-report-orchestrator +description: 按周、季、年整合费用、预算、风险、画像和提醒结果,生成图文 PDF 财务报告,并按系统邮箱设置投递给财务管理人员。 +--- + +# 财务报告编排与邮件投递 + +## 定位 + +本技能是数字员工的后台报告编排能力,不负责审批、付款、预算调整或规则发布。 + +它只读取已沉淀的数据,形成管理层可阅读的财务报告。 + +## 输入 + +- 财务看板快照。 +- 报销单、费用明细和高额单据。 +- 预算池、预算流水和预算预占。 +- 风险观察、风险反馈和待复核线索。 +- 员工行为画像。 +- 定时提醒扫描结果。 +- 系统设置中的 SMTP 和默认收件人。 + +## 输出 + +- 周报、季报或年报 PDF。 +- HTML 报告副本。 +- 邮件投递状态。 +- 数字员工运行记录和工具调用记录。 +- 管理摘要、关键指标和行动清单。 + +## 工作原则 + +- 结论先行:先给金额、预算、风险和行动建议。 +- 图表辅助:趋势、排行、预算和风险尽量用图形表达。 +- 口径一致:金额、单数、预算和状态沿用财务看板口径。 +- 可追踪:PDF 路径、收件人、发送状态和失败原因必须写入运行记录。 +- 可降级:SMTP 未配置时只生成报告,不阻断任务。 + +## 边界 + +- 不自动修改单据状态。 +- 不自动调整预算。 +- 不生成或发布风险规则。 +- 不向未配置的真实邮箱发送邮件。 +- 发送失败只记录状态和建议,不重复轰炸收件人。 diff --git a/server/tests/test_demo_company_simulation_seed.py b/server/tests/test_demo_company_simulation_seed.py index bb0443d..b8588bf 100644 --- a/server/tests/test_demo_company_simulation_seed.py +++ b/server/tests/test_demo_company_simulation_seed.py @@ -182,7 +182,21 @@ def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume() .where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC)) .where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC)) ) + earliest_claim_day = db.scalar( + select(func.min(ExpenseClaim.occurred_at)).where( + ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%") + ) + ) + latest_claim_day = db.scalar( + select(func.max(ExpenseClaim.occurred_at)).where( + ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%") + ) + ) assert admin_claim_count == 0 assert visible_claim_count is not None assert 400 <= visible_claim_count <= 500 + assert earliest_claim_day is not None + assert latest_claim_day is not None + assert earliest_claim_day.date() >= date(2026, 1, 1) + assert latest_claim_day.date() <= date(2026, 6, 2) diff --git a/server/tests/test_digital_employee_dashboard_service.py b/server/tests/test_digital_employee_dashboard_service.py index bac6b4d..d0e0cc9 100644 --- a/server/tests/test_digital_employee_dashboard_service.py +++ b/server/tests/test_digital_employee_dashboard_service.py @@ -157,3 +157,92 @@ def test_digital_employee_dashboard_keeps_empty_payload_without_fake_data() -> N assert dashboard.totals["totalRuns"] == 0 assert dashboard.daily_work assert dashboard.task_distribution == [] + + +def test_digital_employee_dashboard_counts_finance_dashboard_snapshots() -> None: + now = datetime.now(UTC) + + with build_session() as db: + db.add( + AgentRun( + run_id="run-finance-snapshot-001", + agent="hermes", + source="schedule", + user_id="digital_employee", + status="succeeded", + route_json={"task_type": "finance_dashboard_snapshot"}, + result_summary="finance dashboard snapshot generated", + started_at=now - timedelta(minutes=3), + finished_at=now - timedelta(minutes=2), + tool_calls=[ + AgentToolCall( + run_id="run-finance-snapshot-001", + tool_type="database", + tool_name="digital_employee.finance_dashboard.snapshot", + request_json={"task_type": "finance_dashboard_snapshot"}, + response_json={ + "summary": { + "finance_snapshot_count": 1, + "reimbursement_count": 534, + } + }, + status="succeeded", + duration_ms=1200, + created_at=now - timedelta(minutes=3), + ) + ], + ) + ) + db.commit() + + dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7) + + assert dashboard.totals["financeDashboardSnapshots"] == 1 + assert dashboard.totals["businessOutputs"] == 1 + assert dashboard.daily_work[-1]["financeDashboardSnapshots"] == 1 + assert dashboard.task_distribution[0]["taskType"] == "finance_dashboard_snapshot" + + +def test_digital_employee_dashboard_counts_reminder_outputs() -> None: + now = datetime.now(UTC) + + with build_session() as db: + db.add( + AgentRun( + run_id="run-reminder-scan-001", + agent="hermes", + source="schedule", + user_id="digital_employee", + status="succeeded", + route_json={"task_type": "digital_employee_reminder_scan"}, + result_summary="reminder scan generated", + started_at=now - timedelta(minutes=3), + finished_at=now - timedelta(minutes=2), + tool_calls=[ + AgentToolCall( + run_id="run-reminder-scan-001", + tool_type="database", + tool_name="digital_employee.reminder.scan", + request_json={"task_type": "digital_employee_reminder_scan"}, + response_json={ + "summary": { + "recipient_count": 3, + "reminder_count": 8, + "approval_pending_count": 2, + } + }, + status="succeeded", + duration_ms=900, + created_at=now - timedelta(minutes=3), + ) + ], + ) + ) + db.commit() + + dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7) + + assert dashboard.totals["reminders"] == 8 + assert dashboard.totals["businessOutputs"] == 8 + assert dashboard.daily_work[-1]["reminders"] == 8 + assert dashboard.task_distribution[0]["taskType"] == "digital_employee_reminder_scan" diff --git a/server/tests/test_digital_employee_reminder_task.py b/server/tests/test_digital_employee_reminder_task.py new file mode 100644 index 0000000..86e614a --- /dev/null +++ b/server/tests/test_digital_employee_reminder_task.py @@ -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, + ) diff --git a/server/tests/test_digital_employee_skill_catalog.py b/server/tests/test_digital_employee_skill_catalog.py index cb07dc1..a6c8a9c 100644 --- a/server/tests/test_digital_employee_skill_catalog.py +++ b/server/tests/test_digital_employee_skill_catalog.py @@ -5,14 +5,17 @@ from typing import Any from app.core.agent_enums import AgentName from app.services.agent_foundation_constants import ( + DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE, + DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE, DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, + DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE, DIGITAL_EMPLOYEE_SKILL_CATEGORIES, DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP, ) from app.services.agent_foundation_digital_employee_tasks import ( - AgentFoundationDigitalEmployeeTaskMixin, DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY, + AgentFoundationDigitalEmployeeTaskMixin, ) @@ -56,11 +59,17 @@ def test_digital_employee_skill_catalog_has_complete_categories_and_packages() - categories = [str(spec["skill_category"]) for spec in specs] skill_names = [str(dict(spec["config"])["skill_name"]) for spec in specs] - assert len(specs) == 16 + assert len(specs) == 19 assert len(set(codes)) == len(codes) assert set(categories) == set(DIGITAL_EMPLOYEE_SKILL_CATEGORIES) assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE] == "积累" - assert len(set(codes + [DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE])) == 17 + assert ( + DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_FINANCE_DASHBOARD_SNAPSHOT_TASK_CODE] + == "整理" + ) + assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_REMINDER_SCAN_TASK_CODE] == "升级" + assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_FINANCE_REPORT_TASK_CODE] == "整理" + assert len(set(codes + [DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE])) == 20 for skill_name in ["finance-policy-knowledge-organizer", *skill_names]: skill_file = _skill_root() / skill_name / "SKILL.md" @@ -114,6 +123,9 @@ def test_digital_employee_skills_do_not_cross_rule_governance_boundary() -> None ) assert "risk-clue-collector" in skill_names + assert "finance-dashboard-snapshot-analyst" in skill_names + assert "digital-employee-reminder-scanner" in skill_names + assert "finance-report-orchestrator" in skill_names assert "rule-execution-case-organizer" in skill_names assert "policy-reference-gap-hinter" in skill_names assert "risk-rule-discovery" not in skill_names diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 007c763..cbf63da 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -3541,12 +3541,63 @@ def test_direct_manager_cannot_delete_application_claim() -> None: db.commit() claim_id = claim.id - with pytest.raises(ValueError, match="申请单只有系统管理员可以删除"): + with pytest.raises(ValueError, match="只有草稿、待补充或退回待提交状态的单据"): ExpenseClaimService(db).delete_claim(claim_id, current_user) assert db.get(ExpenseClaim, claim_id) is not None +def test_applicant_can_delete_returned_application_claim() -> None: + current_user = CurrentUserContext( + username="zhangsan-application-return-delete@example.com", + name="张三", + role_codes=[], + is_admin=False, + ) + + with build_session() as db: + employee = Employee( + employee_no="E-APP-DEL-RETURN", + name="张三", + email="zhangsan-application-return-delete@example.com", + ) + db.add(employee) + db.flush() + claim = ExpenseClaim( + claim_no="APP-DEL-RETURN-101", + employee_id=employee.id, + employee_name="张三", + department_name="市场部", + project_code=None, + expense_type="travel_application", + reason="差旅申请", + location="上海", + amount=Decimal("1200.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC), + status="returned", + approval_stage="待提交", + risk_flags_json=[ + { + "source": "manual_return", + "event_type": "expense_application_return", + "message": "请补充出差事由", + } + ], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user) + + assert deleted is not None + assert deleted.claim_no == "APP-DEL-RETURN-101" + assert db.get(ExpenseClaim, claim_id) is None + + def test_admin_can_delete_application_claim() -> None: current_user = CurrentUserContext( username="superadmin", diff --git a/server/tests/test_finance_dashboard_service.py b/server/tests/test_finance_dashboard_service.py index 5a03524..dfc9109 100644 --- a/server/tests/test_finance_dashboard_service.py +++ b/server/tests/test_finance_dashboard_service.py @@ -8,10 +8,12 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base +from app.models.agent_run import AgentRun from app.models.budget import BudgetAllocation, BudgetTransaction from app.models.financial_record import ExpenseClaim from app.models.risk_observation import RiskObservation from app.services.finance_dashboard import FinanceDashboardService +from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService def build_session() -> Session: @@ -165,12 +167,16 @@ def test_finance_dashboard_service_aggregates_claim_budget_and_payment_data() -> assert dashboard.totals["reimbursementCount"] == 2 assert dashboard.totals["reimbursementAmount"] == 2000.0 assert dashboard.totals["pendingPaymentAmount"] == 0.0 - assert dashboard.trend["applications"][-1] >= 1 + assert sum(dashboard.trend["applications"]) >= 1 assert "AP-DASH-ADMIN-001" not in str(dashboard.trend) assert dashboard.spend_by_category[0]["value"] == 1200.0 assert dashboard.department_ranking[0]["name"] == "财务部" assert dashboard.department_ranking[0]["amount"] == 1200.0 + assert dashboard.department_ranking[0]["employeeCount"] == 1 + assert dashboard.department_employee_mix[0]["name"] == "财务部 · 陈雨晴" + assert dashboard.department_employee_mix[0]["amount"] == 1200.0 assert dashboard.employee_ranking[0]["name"] == "陈雨晴" + assert dashboard.employee_ranking[0]["count"] == 1 assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001" assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims) assert dashboard.budget_summary["ratio"] == 40.0 @@ -226,7 +232,7 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N ExpenseClaim( claim_no="CLM-DASH-LABEL-003", employee_name="reimbursement-user", - department_name="甯傚満閮?, + department_name="Market", expense_type="travel", reason="real travel reimbursement", location="Shanghai", @@ -327,10 +333,150 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N assert dashboard.trend["claimCount"][-1] == 1 assert dashboard.trend["claimAmount"][-1] == 700.0 assert dashboard.trend["applications"] == dashboard.trend["claimCount"] - assert dashboard.department_ranking[0]["name"] == "市场部" + assert dashboard.department_ranking[0]["name"] == "Market" assert dashboard.department_ranking[0]["amount"] == 700.0 assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names) assert "风险金额" not in focus_names assert "材料待补" not in focus_names assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks) assert len(dashboard.budget_metrics) == 6 + + +def test_finance_dashboard_ranking_range_supports_year_and_all_scope() -> None: + now = datetime.now(UTC) + previous_year_time = now - timedelta(days=420) + + with build_session() as db: + db.add_all( + [ + ExpenseClaim( + claim_no="CLM-RANGE-CURRENT-001", + employee_name="王明", + department_name="销售部", + expense_type="travel", + reason="本年差旅", + location="北京", + amount=Decimal("1000.00"), + invoice_count=1, + occurred_at=now - timedelta(days=5), + submitted_at=now - timedelta(days=5), + status="paid", + approval_stage="payment", + risk_flags_json=[], + hermes_risk_flag=False, + created_at=now - timedelta(days=5), + updated_at=now - timedelta(days=4), + ), + ExpenseClaim( + claim_no="CLM-RANGE-CURRENT-002", + employee_name="赵琳", + department_name="销售部", + expense_type="meal", + reason="本年招待", + location="上海", + amount=Decimal("500.00"), + invoice_count=1, + occurred_at=now - timedelta(days=8), + submitted_at=now - timedelta(days=8), + status="paid", + approval_stage="payment", + risk_flags_json=[], + hermes_risk_flag=False, + created_at=now - timedelta(days=8), + updated_at=now - timedelta(days=7), + ), + ExpenseClaim( + claim_no="CLM-RANGE-OLD-001", + employee_name="钱远", + department_name="销售部", + expense_type="office", + reason="历史办公", + location="广州", + amount=Decimal("9000.00"), + invoice_count=1, + occurred_at=previous_year_time, + submitted_at=previous_year_time, + status="paid", + approval_stage="payment", + risk_flags_json=[], + hermes_risk_flag=False, + created_at=previous_year_time, + updated_at=previous_year_time, + ), + ] + ) + db.commit() + + year_dashboard = FinanceDashboardService(db).build_dashboard( + range_key="近10日", + trend_range="近7天", + department_range="本年", + ) + all_dashboard = FinanceDashboardService(db).build_dashboard( + range_key="近10日", + trend_range="近7天", + department_range="全部", + ) + + assert year_dashboard.department_ranking[0]["amount"] == 1500.0 + assert year_dashboard.department_ranking[0]["employeeCount"] == 2 + assert "CLM-RANGE-OLD-001" not in str(year_dashboard.top_claims) + assert {item["employee"] for item in year_dashboard.department_employee_mix} == { + "王明", + "赵琳", + } + assert all_dashboard.department_ranking[0]["amount"] == 10500.0 + assert all_dashboard.department_ranking[0]["employeeCount"] == 3 + assert all_dashboard.top_claims[0]["claimNo"] == "CLM-RANGE-OLD-001" + assert all_dashboard.department_employee_mix[0]["employee"] == "钱远" + + +def test_finance_dashboard_snapshot_service_persists_digital_employee_snapshot() -> None: + now = datetime.now(UTC) + + with build_session() as db: + db.add( + ExpenseClaim( + claim_no="CLM-SNAPSHOT-001", + employee_name="snapshot-user", + department_name="Finance", + expense_type="travel", + reason="snapshot test", + location="Shanghai", + amount=Decimal("880.00"), + invoice_count=1, + occurred_at=now - timedelta(hours=1), + submitted_at=now - timedelta(minutes=50), + status="paid", + approval_stage="payment", + risk_flags_json=[], + hermes_risk_flag=False, + created_at=now - timedelta(hours=1), + updated_at=now - timedelta(minutes=40), + ) + ) + db.commit() + + service = FinanceDashboardSnapshotService(db) + first = service.build_dashboard( + range_key="近30日", + trend_range="近12天", + department_range="本月", + ) + second = service.build_dashboard( + range_key="近30日", + trend_range="近12天", + department_range="本月", + ) + + runs = [ + run + for run in db.query(AgentRun).filter(AgentRun.agent == "hermes").all() + if (run.route_json or {}).get("task_type") == "finance_dashboard_snapshot" + ] + assert first.totals["reimbursementCount"] == 1 + assert second.generated_at == first.generated_at + assert len(runs) == 1 + assert runs[0].status == "succeeded" + assert runs[0].route_json["task_type"] == "finance_dashboard_snapshot" + assert runs[0].route_json["snapshot_payload"]["totals"]["reimbursementAmount"] == 880.0 diff --git a/server/tests/test_finance_report_task.py b/server/tests/test_finance_report_task.py new file mode 100644 index 0000000..8ccd3e8 --- /dev/null +++ b/server/tests/test_finance_report_task.py @@ -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() diff --git a/server/tests/test_hermes_employee_profile_baselines.py b/server/tests/test_hermes_employee_profile_baselines.py index 92cfa76..3e45eb6 100644 --- a/server/tests/test_hermes_employee_profile_baselines.py +++ b/server/tests/test_hermes_employee_profile_baselines.py @@ -12,6 +12,8 @@ from app.models.employee import Employee from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.organization import OrganizationUnit +from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService +from app.services.employee_profile_scan_task import EmployeeProfileScanTaskService from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService @@ -35,6 +37,24 @@ def test_hermes_employee_profile_scan_returns_profile_baseline_summary() -> None ) +def test_employee_profile_scan_task_records_digital_employee_run() -> None: + session_factory = _build_session_factory() + with session_factory() as db: + _seed_scan_data(db) + + result = EmployeeProfileScanTaskService(db).refresh_profiles() + + summary = result["summary"] + assert result["task_type"] == "employee_behavior_profile_scan" + assert summary["target_employee_count"] == 3 + assert summary["snapshot_count"] >= 12 + assert db.query(EmployeeBehaviorProfileSnapshot).count() >= 12 + + dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7) + assert dashboard.totals["profileSnapshots"] >= 12 + assert dashboard.task_distribution[0]["taskType"] == "employee_behavior_profile_scan" + + def _build_session_factory() -> sessionmaker[Session]: engine = create_engine( "sqlite+pysqlite:///:memory:", diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index cd74cdd..a2db0e1 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -693,6 +693,92 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None: assert second_response.draft_payload is None +def test_user_agent_application_edit_resubmits_returned_application_claim() -> None: + session_factory = build_session_factory() + with session_factory() as db: + claim = ExpenseClaim( + id="application-edit-1", + claim_no="AP-20260220-EDIT", + employee_name="pytest", + department_name="技术部", + expense_type="travel_application", + reason="旧事由", + location="上海", + amount=Decimal("1000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 2, 20, tzinfo=UTC), + status="returned", + approval_stage="待提交", + risk_flags_json=[ + { + "source": "manual_return", + "event_type": "expense_application_return", + "message": "请修改事由", + }, + { + "source": "application_detail", + "application_detail": { + "reason": "旧事由", + "time": "2026-02-20 至 2026-02-23", + }, + }, + ], + ) + db.add(claim) + db.commit() + + response = build_application_user_agent_response( + db, + "确认提交", + context_overrides={ + "manager_name": "向万红", + "application_edit_mode": True, + "application_edit_claim_id": claim.id, + "application_preview": { + "fields": { + "applicationType": "差旅费用申请", + "time": "2026-02-20 至 2026-02-23", + "location": "上海市", + "reason": "支撑国网仿生产环境建设", + "days": "4天", + "transportMode": "火车", + "amount": "4660元", + "grade": "P5", + "department": "技术部", + "position": "财务智能化产品经理", + "managerName": "向万红", + } + }, + }, + ) + + db.refresh(claim) + claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all() + assert len(claims) == 1 + assert "申请单据已修改并重新提交" in response.answer + assert response.draft_payload is not None + assert response.draft_payload.claim_id == claim.id + assert claim.status == "submitted" + assert claim.approval_stage == "直属领导审批" + assert claim.reason == "支撑国网仿生产环境建设" + assert claim.location == "上海市" + assert claim.amount == Decimal("4660.00") + assert claim.occurred_at.date().isoformat() == "2026-02-20" + + flags = list(claim.risk_flags_json or []) + assert any(flag.get("event_type") == "expense_application_return" for flag in flags) + assert any(flag.get("event_type") == "expense_application_submission" for flag in flags) + detail_flags = [ + flag.get("application_detail") + for flag in flags + if isinstance(flag, dict) and flag.get("source") == "application_detail" + ] + assert len(detail_flags) == 1 + assert detail_flags[0]["reason"] == "支撑国网仿生产环境建设" + assert detail_flags[0]["transport_mode"] == "火车" + + def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/web/src/assets/styles/components/personal-workbench-glass.css b/web/src/assets/styles/components/personal-workbench-glass.css index c92d596..0631ac4 100644 --- a/web/src/assets/styles/components/personal-workbench-glass.css +++ b/web/src/assets/styles/components/personal-workbench-glass.css @@ -123,7 +123,6 @@ z-index: 1; } -.todo-row, .progress-row { position: relative; border-top: 0; @@ -131,12 +130,10 @@ box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1); } -.todo-row:first-child, .progress-row:first-child { box-shadow: none; } -.todo-row:hover, .progress-row:hover { background: linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)), diff --git a/web/src/assets/styles/components/personal-workbench-responsive.css b/web/src/assets/styles/components/personal-workbench-responsive.css index c925d57..be12f44 100644 --- a/web/src/assets/styles/components/personal-workbench-responsive.css +++ b/web/src/assets/styles/components/personal-workbench-responsive.css @@ -63,7 +63,7 @@ } .assistant-copy { - width: min(1040px, 92%); + width: min(940px, 92%); } .assistant-copy h1 { @@ -71,11 +71,11 @@ } .capability-grid--privileged { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); } .capability-grid--standard { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .capability-card { @@ -87,7 +87,7 @@ } .workbench-content-grid { - grid-template-columns: minmax(300px, 0.92fr) minmax(480px, 1.34fr) minmax(270px, 0.76fr); + grid-template-columns: minmax(480px, 1.34fr) minmax(270px, 0.76fr); gap: 14px; } @@ -202,25 +202,15 @@ grid-template-columns: 1fr; } - .todo-row, .progress-row { grid-template-columns: 1fr; justify-items: start; } - .todo-row { - grid-template-columns: 48px minmax(0, 1fr); - } - - .todo-meta, .progress-result { justify-items: start; } - .todo-meta { - grid-column: 2; - } - .progress-steps { width: 100%; } @@ -332,30 +322,6 @@ font-size: 11px; } - /* 我的待办列表项更精致 */ - .todo-row { - padding: 5px 0; - gap: 6px; - } - - .todo-copy strong { - font-size: 12.5px; - } - - .todo-copy small { - font-size: 11px; - } - - .todo-status { - font-size: 11px; - min-height: 18px; - padding: 0 5px; - } - - .todo-meta small { - font-size: 10.5px; - } - /* 重点优化:费用进度行的网格区域(Grid Area)双行重构 */ .progress-row { display: grid; diff --git a/web/src/assets/styles/components/personal-workbench.css b/web/src/assets/styles/components/personal-workbench.css index 1d33f06..764f38f 100644 --- a/web/src/assets/styles/components/personal-workbench.css +++ b/web/src/assets/styles/components/personal-workbench.css @@ -97,7 +97,7 @@ .assistant-copy { position: relative; z-index: 3; - width: min(1120px, 94%); + width: min(980px, 94%); display: grid; gap: var(--hero-copy-gap); } @@ -130,6 +130,7 @@ z-index: 5; display: grid; gap: 6px; + max-width: 920px; min-height: var(--composer-min-height); padding: var(--composer-padding-block) 18px 10px; border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28); @@ -416,7 +417,7 @@ .workbench-content-grid { display: grid; - grid-template-columns: minmax(360px, 0.95fr) minmax(560px, 1.4fr) minmax(320px, 0.82fr); + grid-template-columns: minmax(560px, 1.45fr) minmax(320px, 0.82fr); gap: 14px; align-items: stretch; min-height: 0; @@ -434,7 +435,6 @@ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035); } -.todo-panel, .progress-panel, .side-panel { display: grid; @@ -493,7 +493,6 @@ color: var(--workbench-muted); } -.todo-list, .progress-list { display: grid; min-height: 0; @@ -501,45 +500,11 @@ grid-auto-rows: minmax(0, 1fr); } -.todo-row { - display: grid; - grid-template-columns: 34px minmax(0, 1fr) auto; - align-items: center; - gap: 9px; - width: 100%; - padding: 2px 0; - border-top: 1px solid var(--workbench-line-soft); - text-align: left; -} - -.todo-row:first-child, .progress-row:first-child { padding-top: 2px; border-top: 0; } -.todo-row :deep(.workbench-list-icon) { - width: 28px; - height: 28px; -} - -.todo-row :deep(.workbench-list-icon__panel) { - border-radius: 4px; -} - -.todo-row :deep(.workbench-list-icon__art), -.todo-row :deep(.workbench-heroicon) { - width: 16px; - height: 16px; -} - -.todo-copy { - min-width: 0; - display: grid; - gap: 2px; -} - -.todo-copy strong, .progress-identity strong, .progress-result strong { overflow: hidden; @@ -551,8 +516,6 @@ white-space: nowrap; } -.todo-copy small, -.todo-meta small, .progress-identity small { overflow: hidden; color: var(--workbench-muted); @@ -562,14 +525,6 @@ white-space: nowrap; } -.todo-meta { - min-width: 96px; - display: grid; - justify-items: end; - gap: 2px; -} - -.todo-status, .progress-status { display: inline-flex; align-items: center; @@ -582,33 +537,16 @@ white-space: nowrap; } -.todo-status--warning, .progress-status--warning { background: var(--warning-soft); color: var(--warning); } -.todo-status--success, .progress-status--success { background: var(--workbench-primary-soft); color: var(--workbench-primary-active); } -.todo-status--danger { - background: var(--danger-soft); - color: var(--danger); -} - -.todo-status--info { - background: var(--info-soft); - color: var(--info); -} - -.todo-status--orange { - background: var(--warning-soft); - color: var(--warning); -} - .progress-status--muted { background: var(--info-soft); color: var(--workbench-muted); @@ -708,7 +646,6 @@ } .capability-card:hover, -.todo-row:hover, .progress-row:hover, .quick-prompts button:hover, .composer-icon-button:hover { diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index 0267e80..073448b 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -403,6 +403,11 @@ color: var(--theme-primary-active); } +.notification-wrap { + position: relative; + display: inline-flex; +} + .notification-badge { position: absolute; top: 2px; @@ -423,6 +428,179 @@ box-shadow: 0 5px 10px rgba(239, 68, 68, .22); } +.notification-popover { + position: absolute; + top: calc(100% + 10px); + right: -8px; + z-index: 60; + width: min(360px, calc(100vw - 32px)); + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid #e5edf5; + border-radius: 4px; + background: rgba(255, 255, 255, 0.98); + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.92); +} + +.notification-popover::before { + content: ""; + position: absolute; + top: -6px; + right: 18px; + width: 10px; + height: 10px; + border-top: 1px solid #e5edf5; + border-left: 1px solid #e5edf5; + background: #fff; + transform: rotate(45deg); +} + +.notification-head, +.notification-tabs { + position: relative; + z-index: 1; + display: flex; + align-items: center; +} + +.notification-head { + justify-content: space-between; + gap: 10px; +} + +.notification-head strong { + color: #0f172a; + font-size: 14px; + font-weight: 850; +} + +.notification-head button { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 4px; + color: #64748b; +} + +.notification-head button:hover { + background: #f1f5f9; + color: #0f172a; +} + +.notification-tabs { + gap: 6px; + padding: 3px; + border: 1px solid #edf2f7; + border-radius: 4px; + background: #f8fafc; +} + +.notification-tabs button { + flex: 1 1 0; + height: 28px; + border-radius: 3px; + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.notification-tabs button.active { + background: #fff; + color: var(--theme-primary-active); + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); +} + +.notification-list { + position: relative; + z-index: 1; + display: grid; + max-height: 320px; + overflow: auto; +} + +.notification-row { + display: grid; + grid-template-columns: 8px minmax(0, 1fr) 16px; + align-items: center; + gap: 10px; + padding: 10px 4px; + border-top: 1px solid #edf2f7; + text-align: left; +} + +.notification-row:first-child { + border-top: 0; +} + +.notification-row:hover { + background: #f8fafc; +} + +.notification-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--theme-primary); +} + +.notification-dot.danger { background: #ef4444; } +.notification-dot.warning { background: #f59e0b; } +.notification-dot.success { background: var(--success); } +.notification-dot.info { background: #3b82f6; } + +.notification-copy { + min-width: 0; + display: grid; + gap: 2px; +} + +.notification-copy strong, +.notification-copy small, +.notification-copy em { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notification-copy strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.notification-copy small { + color: #475569; + font-size: 12px; +} + +.notification-copy em { + color: #94a3b8; + font-size: 11px; + font-style: normal; +} + +.notification-row > .mdi { + color: #94a3b8; + font-size: 16px; +} + +.notification-empty { + min-height: 112px; + display: grid; + place-items: center; + gap: 8px; + color: #94a3b8; + font-size: 13px; +} + +.notification-empty .mdi { + font-size: 24px; +} + .company-switcher { max-width: min(220px, 28vw); height: 38px; @@ -593,6 +771,61 @@ .title-group { padding-right: 56px; } + + .topbar.detail-mode { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + padding: 10px 14px; + } + + .topbar.detail-mode .title-group { + min-width: 0; + padding-right: 50px; + } + + .topbar.detail-mode .eyebrow { + display: none; + } + + .topbar.detail-mode h1 { + font-size: 22px; + line-height: 1.12; + } + + .topbar.detail-mode p { + max-width: 100%; + margin-top: 3px; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + font-size: 12px; + line-height: 1.35; + } + + .topbar.detail-mode .top-actions, + .topbar.detail-mode .detail-topbar-actions { + width: 100%; + min-width: 0; + justify-content: flex-end; + } + + .topbar.detail-mode .detail-alert-strip { + width: auto; + max-width: 100%; + min-width: 0; + justify-content: flex-end; + gap: 6px; + } + + .topbar.detail-mode .detail-alert-pill { + min-height: 26px; + max-width: 100%; + padding: 0 9px; + border-radius: 4px; + font-size: 11px; + } } @media (max-width: 640px) { @@ -676,3 +909,22 @@ grid-template-columns: 1fr; } } + +@media (max-width: 420px) { + .topbar.detail-mode { + gap: 6px; + padding: 8px 12px; + } + + .topbar.detail-mode h1 { + font-size: 20px; + } + + .topbar.detail-mode p { + font-size: 11.5px; + } + + .topbar.detail-mode .detail-alert-pill { + min-height: 24px; + } +} diff --git a/web/src/assets/styles/views/overview-view.css b/web/src/assets/styles/views/overview-view.css index edee97c..ba0e7c8 100644 --- a/web/src/assets/styles/views/overview-view.css +++ b/web/src/assets/styles/views/overview-view.css @@ -142,19 +142,16 @@ .trend-count-panel, .donut-panel, -.rank-panel, -.employee-rank-panel, -.top-claim-panel, -.budget-metrics-panel, -.bottleneck-panel, -.budget-panel, .model-panel, .feedback-panel { grid-column: span 3; } -.bottleneck-panel, +.rank-panel, +.employee-rank-panel, +.top-claim-panel, .budget-metrics-panel, +.bottleneck-panel, .budget-panel { grid-column: span 6; } @@ -188,6 +185,21 @@ width: 110px; } +.card-range-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), .18); + border-radius: 4px; + background: rgba(var(--theme-primary-rgb, 58, 124, 165), .07); + color: var(--theme-primary-active); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + .panel-note { margin-top: 8px; color: #64748b; @@ -581,6 +593,42 @@ .top-claim-list { display: grid; gap: 10px; + align-content: start; +} + +.top-claim-split { + flex: 1; + display: grid; + grid-template-columns: minmax(260px, .92fr) minmax(0, 1.08fr); + gap: 18px; + align-items: stretch; + min-height: 0; +} + +.department-employee-mix { + min-width: 0; + padding-right: 18px; + border-right: 1px solid #f1f5f9; +} + +.department-employee-mix :deep(.donut-chart) { + min-height: 100%; +} + +.department-employee-mix :deep(.donut-body) { + height: 150px; +} + +.department-employee-mix :deep(.donut-legend) { + grid-template-columns: 1fr; + gap: 7px; +} + +.department-employee-mix :deep(.legend-name) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .top-claim-row { @@ -873,6 +921,17 @@ grid-template-columns: 24px 64px minmax(0, 1fr); } + .top-claim-split { + grid-template-columns: 1fr; + } + + .department-employee-mix { + padding-right: 0; + padding-bottom: 14px; + border-right: 0; + border-bottom: 1px solid #f1f5f9; + } + .budget-metric-grid { grid-template-columns: 1fr; } diff --git a/web/src/assets/styles/views/travel-request-detail-responsive.css b/web/src/assets/styles/views/travel-request-detail-responsive.css new file mode 100644 index 0000000..fd00feb --- /dev/null +++ b/web/src/assets/styles/views/travel-request-detail-responsive.css @@ -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; + } +} diff --git a/web/src/assets/styles/views/travel-request-detail-view-part2.css b/web/src/assets/styles/views/travel-request-detail-view-part2.css index cd08e58..a691b1b 100644 --- a/web/src/assets/styles/views/travel-request-detail-view-part2.css +++ b/web/src/assets/styles/views/travel-request-detail-view-part2.css @@ -704,6 +704,7 @@ .ai-preview-secondary:disabled, .ai-preview-primary:disabled, .approve-action:disabled, +.secondary-action:disabled, .return-action:disabled, .ai-send-btn:disabled { opacity: .45; diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index aa29943..2617394 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -1645,6 +1645,13 @@ color: #ef4444; } +.secondary-action { + min-width: 98px; + border: 1px solid #bfdbfe; + background: #eff6ff; + color: #2563eb; +} + .return-action { min-width: 98px; border: 1px solid #fed7aa; diff --git a/web/src/components/audit/AuditAssetList.vue b/web/src/components/audit/AuditAssetList.vue index b684192..d7a700a 100644 --- a/web/src/components/audit/AuditAssetList.vue +++ b/web/src/components/audit/AuditAssetList.vue @@ -16,7 +16,6 @@ :current-page="currentPage" :page-size="pageSize" :page-size-options="pageSizeOptions" - :pages="pageNumbers" :show-page-size="true" :summary="paginationSummary" :total="visibleSkills.length" @@ -326,14 +325,6 @@ const pagedSkills = computed(() => { const start = (currentPage.value - 1) * pageSize.value return props.visibleSkills.slice(start, start + pageSize.value) }) -const pageNumbers = computed(() => { - const total = totalPages.value - if (total <= 7) { - return Array.from({ length: total }, (_, index) => index + 1) - } - const start = Math.max(1, Math.min(currentPage.value - 3, total - 6)) - return Array.from({ length: 7 }, (_, index) => start + index) -}) const paginationSummary = computed(() => `共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页` ) diff --git a/web/src/components/audit/DigitalEmployeeListPanel.vue b/web/src/components/audit/DigitalEmployeeListPanel.vue index 8ff6e81..da3a97f 100644 --- a/web/src/components/audit/DigitalEmployeeListPanel.vue +++ b/web/src/components/audit/DigitalEmployeeListPanel.vue @@ -10,7 +10,6 @@ :current-page="currentPage" :page-size="pageSize" :page-size-options="pageSizeOptions" - :pages="pageNumbers" :show-page-size="true" :summary="paginationSummary" :total="visibleEmployees.length" @@ -225,14 +224,6 @@ const pagedEmployees = computed(() => { const start = (currentPage.value - 1) * pageSize.value return props.visibleEmployees.slice(start, start + pageSize.value) }) -const pageNumbers = computed(() => { - const total = totalPages.value - if (total <= 7) { - return Array.from({ length: total }, (_, index) => index + 1) - } - const start = Math.max(1, Math.min(currentPage.value - 3, total - 6)) - return Array.from({ length: 7 }, (_, index) => start + index) -}) const paginationSummary = computed(() => `共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页` ) diff --git a/web/src/components/audit/DigitalEmployeeRunProducts.vue b/web/src/components/audit/DigitalEmployeeRunProducts.vue index f42fbf3..d6b850a 100644 --- a/web/src/components/audit/DigitalEmployeeRunProducts.vue +++ b/web/src/components/audit/DigitalEmployeeRunProducts.vue @@ -110,6 +110,26 @@

本次运行没有生成新的风险观察。

+
+
+

财务经营快照

+ {{ summary.period || summary.month || '本期' }} +
+

+ 本次产物已刷新财务看板缓存,沉淀报销金额、预算使用、费用结构和高额单据等经营指标。 +

+
+ +
+
+

提醒与待办沉淀

+ {{ summary.reminder_count || summary.reminders || 0 }} 条 +
+

+ 本次产物已生成审批提醒、预算编制提醒、报销逾期提醒和差旅申请闭环提醒。 +

+
+

待复核线索

@@ -230,6 +250,12 @@ const productSubtitle = computed(() => { if (productKind.value === 'risk_graph') { return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。' } + if (productKind.value === 'finance_snapshot') { + return '展示本次财务经营快照沉淀的预算、费用和报销统计。' + } + if (productKind.value === 'reminder_scan') { + return '展示本次定时提醒扫描生成的待办和触达结果。' + } if (productKind.value === 'employee_profile') { return '展示本次画像巡检写入的员工画像快照摘要。' } @@ -245,6 +271,12 @@ const productBadge = computed(() => { if (productKind.value === 'risk_graph') { return '风险观察' } + if (productKind.value === 'finance_snapshot') { + return '财务快照' + } + if (productKind.value === 'reminder_scan') { + return '提醒事项' + } if (productKind.value === 'employee_profile') { return '画像快照' } @@ -281,6 +313,25 @@ const metrics = computed(() => { buildMetric('图谱关系', payload.graph_edge_count) ] } + if (productKind.value === 'finance_snapshot') { + return [ + buildMetric('报销单数', payload.claim_count ?? payload.claims ?? payload.total_claims), + buildMetric( + '报销金额', + formatMoney(payload.claim_amount ?? payload.reimbursement_amount ?? payload.total_amount) + ), + buildMetric('预算使用率', formatPercent(payload.budget_usage_rate ?? payload.budget_rate)), + buildMetric('高额单据', payload.high_value_claim_count ?? payload.high_amount_claims) + ] + } + if (productKind.value === 'reminder_scan') { + return [ + buildMetric('提醒人数', payload.recipient_count), + buildMetric('提醒事项', payload.reminder_count), + buildMetric('待审批', payload.approval_pending_count), + buildMetric('逾期报销', payload.reimbursement_overdue_count) + ] + } if (productKind.value === 'employee_profile') { return [ buildMetric('目标员工', payload.target_employee_count), @@ -376,6 +427,23 @@ function formatWindowDays(value) { return days.length ? days.map((item) => `${item}天`).join(' / ') : '-' } +function formatMoney(value) { + const amount = Number(value) + if (!Number.isFinite(amount)) { + return '-' + } + return `¥${amount.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` +} + +function formatPercent(value) { + const numericValue = Number(value) + if (!Number.isFinite(numericValue)) { + return '-' + } + const percent = numericValue > 1 ? numericValue : numericValue * 100 + return `${Math.round(percent)}%` +} + function observationGraphCount(item) { return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length } diff --git a/web/src/components/audit/DigitalEmployeeWorkRecords.vue b/web/src/components/audit/DigitalEmployeeWorkRecords.vue index 44ff558..9a347a8 100644 --- a/web/src/components/audit/DigitalEmployeeWorkRecords.vue +++ b/web/src/components/audit/DigitalEmployeeWorkRecords.vue @@ -14,7 +14,6 @@ :show-pagination="!loading && !errorMessage && visibleRuns.length > 0" :current-page="currentPage" :page-size="pageSize" - :pages="pageNumbers" :show-page-size="false" :summary="paginationSummary" :total="filteredRuns.length" @@ -303,6 +302,7 @@ import { import { formatWorkRecordDateTime, formatWorkRecordSummary, + compactDigitalEmployeeWorkRecords, resolveWorkRecordModuleLabel, resolveWorkRecordSourceLabel, resolveWorkRecordStatusLabel, @@ -456,14 +456,6 @@ const visibleRuns = computed(() => { return filteredRuns.value.slice(start, start + pageSize) }) -const pageNumbers = computed(() => { - const total = totalPages.value - if (total <= 7) { - return Array.from({ length: total }, (_, index) => index + 1) - } - const start = Math.max(1, Math.min(currentPage.value - 3, total - 6)) - return Array.from({ length: 7 }, (_, index) => start + index) -}) const paginationSummary = computed(() => `共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页` ) @@ -523,7 +515,7 @@ async function loadWorkRecords(showToast = false) { try { const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 }) - runs.value = Array.isArray(payload) ? payload : [] + runs.value = Array.isArray(payload) ? compactDigitalEmployeeWorkRecords(payload) : [] emit('summary-change', { total: workRecordSummary.value.total, succeeded: workRecordSummary.value.succeeded, diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index a86564d..e8bcee3 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -191,41 +191,6 @@
-
-
-
-

我的待办

- {{ todoAlertCount }} -
- -
- -
- -
- -
-

费用进度

@@ -238,7 +203,7 @@ :key="item.id" type="button" class="progress-row" - @click="openPromptAssistant(`查询 ${item.id} 的费用进度`)" + @click="openWorkbenchTarget(item)" > {{ item.id }} @@ -247,17 +212,17 @@ @@ -357,7 +322,6 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import PanelHead from '../shared/PanelHead.vue' import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue' -import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue' import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' @@ -365,11 +329,8 @@ import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposer import { buildExpenseStatItems, filterAssistantCapabilitiesForUser, - progressItems, - progressSteps, quickPromptItems, resolveWorkbenchCapabilityGridClass, - todoItems, } from '../../data/personalWorkbench.js' import { fetchAgentRuns } from '../../services/agentAssets.js' import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js' @@ -394,7 +355,7 @@ const props = defineProps({ workbenchSummary: { type: Object, default: () => ({}) } }) -const emit = defineEmits(['open-assistant']) +const emit = defineEmits(['open-assistant', 'open-document']) const { currentUser } = useSystemState() const { toast } = useToast() const assistantDraft = ref('') @@ -494,9 +455,12 @@ const currentUserProfileKey = computed(() => { user.employee_no ].map((item) => String(item || '').trim()).filter(Boolean).join('|') }) -const visibleTodoItems = computed(() => todoItems.slice(0, 5)) -const visibleProgressItems = computed(() => progressItems.slice(0, 5)) -const todoAlertCount = computed(() => visibleTodoItems.value.length) +const visibleProgressItems = computed(() => { + const rows = Array.isArray(props.workbenchSummary.progressItems) + ? props.workbenchSummary.progressItems + : [] + return rows.slice(0, 5) +}) function buildSelectedFileKey(file) { return [file?.name, file?.size, file?.lastModified, file?.type].join('__') @@ -625,6 +589,20 @@ function openPromptAssistant(prompt) { emitAssistant(payload) } +function openWorkbenchTarget(item) { + const target = item?.target || {} + if (target.type === 'document' && (target.id || target.claimNo)) { + emit('open-document', { + claimId: target.id, + id: target.id || target.claimNo, + claimNo: target.claimNo + }) + return + } + + openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`) +} + function openCapabilityAssistant(item) { if (pendingAction.value) { return diff --git a/web/src/components/charts/BarChart.vue b/web/src/components/charts/BarChart.vue index c0d9cf5..ee4e23c 100644 --- a/web/src/components/charts/BarChart.vue +++ b/web/src/components/charts/BarChart.vue @@ -9,7 +9,10 @@ - {{ item.name || item.shortName }} + + {{ item.name || item.shortName }} + {{ item.meta }} +
@@ -90,7 +93,11 @@ const chartOptions = computed(() => ({ fontWeight: 700 }, extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);', - formatter: (params) => `${params.marker}${params.name}: ${formatValue(params.value)}` + formatter: (params) => { + const item = resolvedItems.value.find((row) => (row.name || row.shortName) === params.name) + const meta = item?.meta ? `
${item.meta}` : '' + return `${params.marker}${params.name}: ${formatValue(params.value)}${meta}` + } }, xAxis: { type: 'value', @@ -180,7 +187,8 @@ const formatValue = (value) => { } .rank-labels { - flex: 0 0 auto; + flex: 0 0 min(34%, 150px); + min-width: 112px; display: flex; flex-direction: column; justify-content: space-around; @@ -214,10 +222,24 @@ const formatValue = (value) => { display: block; } +.rank-copy { + min-width: 0; + display: grid; + gap: 2px; +} + .rank-name { color: #475569; font-size: 13px; - font-weight: 500; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; +} + +.rank-meta { + color: #94a3b8; + font-size: 11px; + font-weight: 650; } .chart-area { diff --git a/web/src/components/dashboard/RiskObservationDashboard.vue b/web/src/components/dashboard/RiskObservationDashboard.vue index d5f3cc4..00a5740 100644 --- a/web/src/components/dashboard/RiskObservationDashboard.vue +++ b/web/src/components/dashboard/RiskObservationDashboard.vue @@ -1,9 +1,14 @@ diff --git a/web/src/views/EmployeeManagementView.vue b/web/src/views/EmployeeManagementView.vue index 47f84c2..9185079 100644 --- a/web/src/views/EmployeeManagementView.vue +++ b/web/src/views/EmployeeManagementView.vue @@ -657,47 +657,16 @@ -
- 共 {{ totalCount }} 条,目前第 {{ currentPage }} 页 -
- - - -
- -
+ diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue index 5ae5064..39cad12 100644 --- a/web/src/views/LogsView.vue +++ b/web/src/views/LogsView.vue @@ -14,9 +14,8 @@ :empty="!systemLogLoading && !visibleSystemLogEntries.length" :total="totalCount" :total-pages="totalPages" - :pages="visiblePageItems" :page-size-options="pageSizeOptions" - :summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} 页`" + :summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} / ${totalPages} 页`" :show-pagination="!systemLogLoading && filteredSystemLogEntries.length > 0" loading-title="系统日志同步中" loading-message="正在加载系统运行日志记录" diff --git a/web/src/views/OverviewView.vue b/web/src/views/OverviewView.vue index b22ce8a..ea9be3f 100644 --- a/web/src/views/OverviewView.vue +++ b/web/src/views/OverviewView.vue @@ -69,7 +69,7 @@
-

部门报销排行(费用金额)

+

部门报销排行

-

个人报销排行(本月)

+

个人报销排行

+
@@ -92,22 +99,33 @@
-

本月高额单据

+

高额单据

+ {{ activeDepartmentRange }}
-
-
-
- {{ item.claimNo }} - {{ item.employeeName }} · {{ item.departmentName || '未归属部门' }} -
-
- {{ item.amountLabel }} - {{ item.expenseTypeLabel }} · {{ item.statusLabel }} +
+
+ +
+ +
+
+
+ {{ item.claimNo }} + {{ item.employeeName }} · {{ item.departmentName || '未归属部门' }} +
+
+ {{ item.amountLabel }} + {{ item.expenseTypeLabel }} · {{ item.statusLabel }} +
@@ -158,6 +176,7 @@ :dashboard="riskDashboard" :loading="riskDashboardLoading" :error="riskDashboardError" + :last-updated-at="riskDashboardLastUpdatedAt" :level-legend="riskLevelLegend" :source-legend="riskSourceLegend" :signal-ranking="riskSignalRanking" @@ -358,6 +377,8 @@ const { activeTrendRange, budgetMetrics, budgetSummary, + departmentEmployeeCenterValue, + departmentEmployeeLegend, departmentRangeOptions, digitalEmployeeCategoryRows, digitalEmployeeDashboard, @@ -371,6 +392,7 @@ const { rankedEmployees, riskDashboard, riskDashboardError, + riskDashboardLastUpdatedAt, riskDashboardLoading, riskDailyTrendRows, riskKpiMetrics, diff --git a/web/src/views/PersonalWorkbenchView.vue b/web/src/views/PersonalWorkbenchView.vue index cfc1a3c..6b7364a 100644 --- a/web/src/views/PersonalWorkbenchView.vue +++ b/web/src/views/PersonalWorkbenchView.vue @@ -4,6 +4,7 @@ :assistant-modal-open="assistantModalOpen" :workbench-summary="workbenchSummary" @open-assistant="emit('open-assistant', $event)" + @open-document="emit('open-document', $event)" /> @@ -15,5 +16,5 @@ defineProps({ workbenchSummary: { type: Object, default: () => ({}) } }) -const emit = defineEmits(['open-assistant']) +const emit = defineEmits(['open-assistant', 'open-document']) diff --git a/web/src/views/PoliciesView.vue b/web/src/views/PoliciesView.vue index 256a880..4c41138 100644 --- a/web/src/views/PoliciesView.vue +++ b/web/src/views/PoliciesView.vue @@ -146,35 +146,15 @@
-
- 共 {{ totalCount }} 条,目前第 {{ currentPage }} 页 -
- - - -
- -
+
diff --git a/web/src/views/ReceiptFolderView.vue b/web/src/views/ReceiptFolderView.vue index 783b28d..2a60f0d 100644 --- a/web/src/views/ReceiptFolderView.vue +++ b/web/src/views/ReceiptFolderView.vue @@ -112,28 +112,16 @@ -
- 共 {{ filteredRows.length }} 条,目前第 {{ currentPage }} 页 -
- - - -
- -
+ { ].filter(Boolean).join('').toLowerCase().includes(normalized)) }) const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value))) +const pageSummary = computed(() => `共 ${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`) const visibleRows = computed(() => { const start = (currentPage.value - 1) * pageSize.value return filteredRows.value.slice(start, start + pageSize.value) @@ -599,6 +588,11 @@ function switchStatus(status) { activeStatus.value = status } +function changePageSize(size) { + pageSize.value = Number(size) || pageSize.value + currentPage.value = 1 +} + async function reloadReceipts() { loading.value = true error.value = '' diff --git a/web/src/views/TravelRequestDetailView.vue b/web/src/views/TravelRequestDetailView.vue index 4d8781b..b27bc52 100644 --- a/web/src/views/TravelRequestDetailView.vue +++ b/web/src/views/TravelRequestDetailView.vue @@ -476,6 +476,16 @@ {{ deleteBusy ? '删除中' : deleteActionLabel }} +