diff --git a/document/development/budget-expense-control-model-plan/employee_behavior_profile_model.md b/document/development/budget-expense-control-model-plan/employee_behavior_profile_model.md new file mode 100644 index 0000000..bf18f27 --- /dev/null +++ b/document/development/budget-expense-control-model-plan/employee_behavior_profile_model.md @@ -0,0 +1,515 @@ +# 员工业务行为画像模型方案 + +## 目标 + +员工业务行为画像用于把费用申请、审批流转、AI 协作和数字员工巡检中产生的行为数据沉淀为可解释的统计画像。 + +它不是给员工贴负面标签,也不是替代审批人做最终判断,而是为以下场景提供结构化依据: + +- 费用审批详情页展示申请人近期费用节奏和材料质量。 +- Hermes 数字员工定期巡检高频费用、异常预算占用和流程质量问题。 +- 运营看板观察 AI 使用、Token 消耗、流程耗时和审核效率。 +- 后续规则中心根据真实覆盖率和人工覆盖情况优化规则阈值。 + +## 设计原则 + +1. 不把不同性质的数据混成一个总分。 +2. 费用风险、流程质量、AI 使用、审批行为必须分维度计算。 +3. 画像结果必须能追溯到指标、窗口期、同组基准和计算时间。 +4. Hermes 负责调度和沉淀快照,确定性算法负责计算,LLM 只可用于解释和报告。 +5. 画像用于审批参考和运营治理,不直接作为惩罚或自动降标依据。 + +## 画像分层 + +```text +员工业务行为画像 +├── 费用支出画像 +├── 流程质量画像 +├── AI 协作画像 +└── 审批行为画像 +``` + +### 费用支出画像 + +用于判断申请人的费用节奏是否显著高于同组基准。 + +核心指标: + +- 近 30 / 90 / 180 天申请次数。 +- 近 30 / 90 / 180 天申请金额。 +- 差旅申请次数、出差天数、日均费用。 +- 招待申请次数、人均招待金额、同客户重复招待次数。 +- 个人费用占部门预算比例。 +- 个人费用占项目预算比例。 +- 同部门、同岗位、同费用类型分位数。 +- 历史调减、退回、复核次数。 + +审批用途: + +- 识别高频费用申请人。 +- 提醒审核者复核出差天数和费用标准。 +- 推荐补充业务必要性、拆分费用或升级审批。 + +### 流程质量画像 + +用于判断申请人提交材料和流程配合质量。 + +核心指标: + +- 草稿到提交平均耗时。 +- 退回到重新提交平均耗时。 +- 退单次数。 +- 补充材料次数。 +- 附件缺失次数。 +- 发票金额不一致次数。 +- 申请事由缺失次数。 +- 业务地点缺失次数。 +- 项目编号缺失次数。 +- 同一申请多次修改次数。 + +审批用途: + +- 提示“近期材料质量偏低,需要重点核对附件和事由”。 +- 对高频退单申请人提高材料完整性检查权重。 +- 对低质量申请触发补充材料建议,而不是直接判定费用风险。 + +### AI 协作画像 + +用于观察员工和系统的 AI 协作行为,不直接判定费用风险。 + +核心指标: + +- AI 调用次数。 +- AI 辅助生成申请次数。 +- AI 解析票据次数。 +- AI 预审次数。 +- 语义解析次数。 +- 输入 Token。 +- 输出 Token。 +- 总 Token。 +- 估算调用成本。 +- AI 建议被采纳次数。 +- AI 建议被人工覆盖次数。 +- AI 生成后人工修改次数。 + +运营用途: + +- 观察哪些流程高度依赖 AI。 +- 识别高成本用户、部门或功能入口。 +- 衡量 AI 建议采纳率和被覆盖率。 +- 为模型配置、成本控制和产品优化提供依据。 + +审批边界: + +AI 使用多不等于风险高。Token 消耗、AI 调用次数不应直接推高费用审批风险,只能作为运营和辅助说明。 + +### 审批行为画像 + +用于分析审批人的审核效率和审核风格。 + +核心指标: + +- 平均审核时长。 +- 中位审核时长。 +- 超 SLA 次数。 +- 直接通过率。 +- 退回率。 +- 调减率。 +- 高风险单据通过率。 +- 系统建议采纳率。 +- 系统建议覆盖率。 +- 审批意见完整度。 +- 审批积压数量。 + +治理用途: + +- 识别审批积压。 +- 识别过度宽松或过度退回的审批模式。 +- 评估规则建议是否被人工持续覆盖。 +- 为流程优化和审批授权调整提供依据。 + +## 计算窗口 + +第一版建议支持三个窗口: + +```text +30 天:识别近期异常波动 +90 天:作为审批详情页默认画像 +180 天:用于稳定趋势和年度预算节奏 +``` + +审批详情页默认读取 `90 天` 画像。运营看板可以切换 30 / 90 / 180 天。 + +## 同组基准 + +费用支出画像必须和可比人群比较,不能全公司一刀切。 + +建议同组口径: + +```text +peer_group = + department_id ++ position ++ grade ++ expense_type_scope ++ city_tier ++ project_type ++ window_days +``` + +当某个同组样本量不足时,逐级回退: + +```text +部门 + 岗位 + 费用类型 +→ 部门 + 费用类型 +→ 岗位 + 费用类型 +→ 公司 + 费用类型 +``` + +回退必须写入 `peer_group_fallback_level`,避免审核者误以为基准非常精确。 + +## 分值模型 + +### 不建议使用一个大总分 + +不要这样计算: + +```text +综合风险分 = 费用金额 + Token 消耗 + 操作时长 + 审核时长 + 退单次数 +``` + +原因: + +- Token 高可能代表高频使用 AI,不代表费用风险。 +- 审核时长是审批人的行为,不是申请人的费用风险。 +- 退单次数可能代表材料质量问题,不一定代表费用不合理。 +- 一个总分会掩盖到底是哪一类风险触发。 + +### 建议使用多维分 + +```text +employee_behavior_profile = + expense_profile_score + process_quality_score + ai_usage_score + approval_behavior_score +``` + +每个分值都有自己的等级: + +```text +0-39 normal +40-59 watch +60-79 review +80-100 escalation +``` + +审批详情页只展示与当前场景相关的分值: + +```text +费用申请审批: + 展示 expense_profile_score + 展示 process_quality_score + 隐藏或弱化 ai_usage_score + 不展示 approval_behavior_score + +运营看板: + 展示四类分值和趋势 +``` + +## 指标权重建议 + +### 费用支出画像分 + +```text +expense_profile_score = + frequency_score * 20% ++ amount_occupancy_score * 25% ++ peer_deviation_score * 25% ++ adjustment_history_score * 15% ++ current_claim_deviation_score * 15% +``` + +### 流程质量画像分 + +```text +process_quality_score = + return_count_score * 25% ++ missing_attachment_score * 20% ++ invoice_mismatch_score * 20% ++ resubmit_duration_score * 15% ++ missing_business_context_score * 20% +``` + +### AI 协作画像分 + +AI 协作分不命名为风险分,建议叫 `ai_usage_intensity_score`。 + +```text +ai_usage_intensity_score = + ai_call_count_score * 25% ++ token_cost_score * 25% ++ ai_generated_claim_ratio_score * 20% ++ ai_suggestion_override_score * 20% ++ failed_ai_call_score * 10% +``` + +含义: + +- 分数高代表 AI 使用强度高或成本高。 +- 不代表员工费用风险高。 +- 主要用于成本治理、流程优化和模型配置。 + +### 审批行为画像分 + +审批行为分不命名为风险分,建议叫 `approval_behavior_score`。 + +```text +approval_behavior_score = + avg_review_duration_score * 20% ++ sla_overdue_score * 20% ++ direct_approve_ratio_score * 20% ++ high_risk_approve_score * 20% ++ system_advice_override_score * 20% +``` + +含义: + +- 分数高代表审批行为需要运营关注。 +- 不直接代表审批人存在问题。 +- 必须结合审批量、单据复杂度和部门业务特性解释。 + +## 数据来源 + +### 费用与流程数据 + +主要来源: + +- `expense_claims` +- `expense_claim_items` +- 审批流转记录 +- 退回 / 调减 / 补充材料记录 +- 预算池和预算交易记录 + +需要补齐或确认的数据: + +- 审批开始时间。 +- 审批完成时间。 +- 退回原因结构化字段。 +- 调减前后金额。 +- 补充材料事件。 +- 审批意见是否为空。 + +### AI 与工具调用数据 + +主要来源: + +- `AgentRun` +- `AgentToolCall` +- `SemanticParseLog` +- `runtime_chat.py` +- `ontology.py` +- `user_agent.py` +- `ocr.py` + +需要注意: + +不是所有模型入口都已经完整持久化 Token。第一版必须区分: + +```text +exact_token_count:真实记录的 Token +estimated_token_count:按文本长度估算 +unavailable:当前不可用 +``` + +不能把估算值包装成真实计费数据。 + +## 存储设计 + +建议第一版使用通用画像快照表: + +```text +employee_behavior_profile_snapshots +``` + +字段建议: + +```text +id +subject_type applicant / approver / employee +subject_id employee_id +subject_name +department_id +department_name +position +grade + +profile_type expense / process_quality / ai_usage / approval +window_days 30 / 90 / 180 +expense_type_scope overall / travel / entertainment / ... +peer_group_key +peer_group_fallback_level + +profile_score +profile_level +metrics_json +basis_codes_json +source_task_type +source_task_log_id +calculated_at +created_at +``` + +### 为什么用快照表 + +不要把画像直接写入员工表: + +```text +employee.profile_score = 80 +``` + +原因: + +- 员工表是主数据,画像是动态计算结果。 +- 审批审计需要知道当时为什么是这个分。 +- 算法规则调整后,历史依据不能被覆盖。 +- 快照可以支持趋势分析。 + +### 是否每个员工都存 + +不建议全员每天存。 + +第一版只存: + +- 近 90 / 180 天有费用申请记录的员工。 +- 当前存在待审批申请的员工。 +- 上一期画像等级为 `watch`、`review`、`escalation` 的员工。 +- AI 使用或审批行为达到运营关注阈值的员工。 + +无行为员工不生成画像快照。 + +## Hermes 调度策略 + +不重新写调度器,复用 Hermes 现有 cron 调度体系。 + +建议新增任务类型: + +```text +employee_behavior_profile_scan +``` + +任务职责: + +```text +1. 识别本次需要刷新画像的员工集合。 +2. 聚合费用、流程、AI、审批行为指标。 +3. 调用各画像子算法。 +4. 写入 employee_behavior_profile_snapshots。 +5. 在 HermesTaskExecutionLog 写入执行摘要。 +``` + +建议频率: + +```text +事件触发:申请提交、审批完成、退回、调减、AI 任务完成后,刷新相关员工。 +每日轻量:只扫描昨日新增行为和上一期高关注员工。 +每周全量:刷新同组基准、分位数和活跃员工画像。 +每月复盘:分析阈值、规则覆盖率和人工覆盖率。 +``` + +## 审批详情展示 + +费用审批详情页建议展示: + +```text +申请人费用画像 +流程材料质量 +本次申请实时偏离 +``` + +不建议在普通审批详情页直接展示: + +```text +Token 消耗 +AI 调用成本 +审批人行为分 +``` + +这些更适合管理员运营看板。 + +示例展示: + +```text +申请人费用画像 +近 90 天 · 销售部 / 客户经理 / 差旅费 +状态:重点复核 + +触发依据: +- 近 90 天差旅金额处于同组 P88。 +- 本次出差天数为同类 P75 的 1.67 倍。 +- 最近 180 天存在 3 次调减或退回记录。 + +审核建议: +- 建议确认本次 5 天行程是否可压缩至 4 天。 +- 如确属关键客户推进,请补充客户拜访安排和预期产出。 +``` + +## 运营看板展示 + +管理员或运营人员可以看到更完整的画像: + +```text +员工画像总览 +├── 费用支出关注榜 +├── 流程质量待优化榜 +├── AI 使用强度榜 +├── Token 成本趋势 +├── 审批效率与积压 +└── 系统建议采纳率 +``` + +运营看板要标明: + +- 哪些指标是真实采集。 +- 哪些指标是估算。 +- 哪些指标当前不可用。 + +## 第一版落地边界 + +第一版建议先做: + +1. 费用支出画像。 +2. 流程质量画像。 +3. AI 协作画像的数据口径定义。 +4. 通用快照表。 +5. Hermes 画像扫描任务。 + +暂不做: + +- 自动处罚或自动降标。 +- 将 AI Token 消耗纳入费用风险分。 +- 用 LLM 直接判断员工是否异常。 +- 全员每日全量画像。 + +## 后续演进 + +### 第二阶段 + +- 接入审批详情页“申请人费用画像”卡片。 +- 接入 Hermes 数字员工日志。 +- 支持画像快照趋势对比。 +- 支持规则中心根据高频触发指标生成规则草稿。 + +### 第三阶段 + +- 引入更稳定的同组基准缓存。 +- 引入审批建议采纳率。 +- 对 AI 使用成本做部门和功能维度分摊。 +- 将画像结果接入运营看板。 + +### 第四阶段 + +- 根据真实历史数据调整权重。 +- 对高覆盖、高误报规则做自动复盘。 +- 让 Hermes 输出月度费用治理建议,但仍不直接改线上规则。 + diff --git a/document/development/employee-behavior-profile/CONCEPT.md b/document/development/employee-behavior-profile/CONCEPT.md new file mode 100644 index 0000000..237fd8b --- /dev/null +++ b/document/development/employee-behavior-profile/CONCEPT.md @@ -0,0 +1,673 @@ +# 员工业务行为画像功能概念文档 + +## 1. 功能一句话 + +员工业务行为画像通过确定性算法把费用申请、流程质量、AI 协作和审批行为沉淀为可追溯的画像快照,并在审批详情、Hermes 数字员工巡检和运营看板中提供可解释的审核依据。 + +## 2. 背景与问题 + +预算费用规划推荐模型需要解释“为什么某个申请应该被重点审核”。仅看当前单据金额不够,因为同样的金额在不同员工、部门、岗位、城市和费用类型下含义不同。 + +当前讨论中已经明确几个问题: + +- 出差天数、出差金额、业务招待频次和招待标准需要和申请人挂钩,否则审核者看不到长期费用节奏。 +- 用户操作时长、AI 使用次数、Token 消耗、审核时长、退单次数等指标也有价值,但它们性质不同,不能混成一个“坏人分”。 +- 审批详情需要一个直观入口展示画像,例如“风险审核画像”卡片,但卡片必须展示证据、口径和建议,避免给员工贴不可解释标签。 +- Hermes 已有数字员工和调度入口,画像检测应该接入现有 Hermes 任务体系,而不是另写一套调度器。 + +代码现状可作为第一版基础: + +- `AgentRun`、`AgentToolCall`、`SemanticParseLog` 已记录 Agent 运行、工具调用耗时和语义解析日志。 +- `ExpenseClaim`、`ExpenseClaimItem` 已承载费用申请和明细。 +- `HermesTaskConfig`、`HermesTaskExecutionLog` 已承载 Hermes 任务配置和执行日志。 +- 现有 Hermes 调度器会轮询启用任务,并按 `task_type` 分发到具体服务。 +- 当前前端 Hermes 设置仅暴露 `global_risk_scan` 和 `weekly_expense_report` 两类任务,画像任务需要补齐配置入口。 + +## 3. 目标与非目标 + +### 3.1 目标 + +- 建立员工维度的多层画像:费用支出画像、流程质量画像、AI 协作画像、审批行为画像。 +- 建立可审计的快照存储,不把动态画像直接写进员工主表。 +- 形成可解释的量化公式,支持 30 / 90 / 180 天窗口。 +- 接入 Hermes 数字员工任务,定期生成画像快照和汇总日志。 +- 在审批详情中展示“风险审核画像”卡片,默认突出费用支出和流程质量。 +- 保留指标来源、同组基准、计算窗口、任务日志和算法版本,便于复核。 +- 明确 Token 统计口径:真实值、估算值和不可用值必须区分。 + +### 3.2 非目标 + +- 不用画像自动处罚员工,也不自动降低费用标准或缩短出差天数。 +- 不把 AI 使用次数、Token 消耗直接当作费用风险。 +- 不做全员每日全量画像快照,避免频率过高和无意义存储。 +- 不重写 Hermes 调度器;如频率能力不足,优先增强现有 Hermes 调度体系。 +- 不用 LLM 直接判定风险等级;LLM 仅可用于解释、摘要和报告生成。 + +## 4. 用户与场景 + +### 4.1 费用审核者 + +在费用申请详情页查看“风险审核画像”卡片。审核者需要知道: + +- 申请人近期是否频繁申请大额出差或招待。 +- 当前申请是否显著高于同组基准或个人历史。 +- 申请人的材料质量是否经常导致退单、补充材料或复核。 +- 系统建议是“重点复核”“建议补充说明”还是“建议升级审批”。 + +### 4.2 财务和预算管理员 + +在运营看板或 Hermes 报告中查看部门、项目、费用类型下的画像趋势。管理员需要识别: + +- 哪些部门或项目存在持续预算占用压力。 +- 哪些费用类型的人均标准偏离明显。 +- 哪些流程环节反复出现退单或材料缺失。 + +### 4.3 AI 运营人员 + +观察 AI 调用、Token 消耗、建议采纳率和覆盖率。AI 运营人员需要知道: + +- 哪些入口消耗高但采纳率低。 +- 哪些业务流程高度依赖 AI。 +- 哪些模型调用需要限额、优化或替换。 + +### 4.4 Hermes 数字员工 + +Hermes 作为调度入口,负责在设定周期内触发画像计算、写入快照、记录执行日志,并输出可读摘要。 + +## 5. 功能能力 + +### 5.1 输入 + +- 费用申请:申请人、部门、岗位、费用类型、申请金额、审批金额、出差天数、招待客户、业务地点、项目编号。 +- 费用明细:明细金额、票据金额、费用类型、发生日期、供应商或客户线索。 +- 审批流转:提交时间、审核开始时间、审核完成时间、退单、调减、复核、审批意见。 +- Agent 数据:Agent 运行记录、工具调用次数、工具耗时、语义解析、AI 建议、AI 建议采纳或覆盖。 +- Token 数据:输入 Token、输出 Token、总 Token、估算 Token、不可用状态。 +- Hermes 数据:任务配置、任务执行日志、报告或风险巡检结果。 +- 组织基准:部门、岗位、职级、城市等级、项目类型、费用类型和预算池。 + +### 5.2 输出 + +- 员工画像快照:每个员工、每个窗口、每个画像类型一条或多条快照。 +- 最新画像查询:给审批详情、运营看板和 Hermes 报告读取。 +- 画像证据:指标值、同组基准、贡献项、命中原因、数据质量标记。 +- 画像标签:把复杂指标转成可读标签,例如“费用之王”“长差达人”“材料补丁户”“急速审核员”,每个标签必须有触发公式、置信度和证据。 +- 行为雷达图:把费用、差旅招待、流程质量、AI 协作和审批行为压缩成 6 到 8 个维度,用于分析者快速理解员工行为结构。 +- 审核建议:复核天数、复核金额、补充材料、升级审批、关注预算占用等建议。 +- Hermes 执行摘要:本次计算人数、生成快照数、高关注人数、失败原因。 + +### 5.3 审批详情卡片 + +审批详情中建议新增卡片:`风险审核画像`。 + +卡片默认展示: + +- 总览:画像等级、计算时间、窗口期、同组基准口径。 +- 特征标签:展示 3 到 6 个置信度最高、与当前场景相关的标签;风险型标签优先,但必须保留证据入口。 +- 雷达图:展示行为维度得分,帮助审核者一眼判断该员工是“费用强度高”“材料质量弱”还是“审批节奏快”。 +- 费用支出:频次、金额占用、同组偏离、历史调减、当前单据偏离。 +- 流程质量:退单、附件缺失、发票不一致、补充材料、重提耗时。 +- 当前单据建议:是否建议复核出差天数、招待人均金额、业务必要性或预算占用。 +- 证据展开:展示贡献最高的 3 到 5 个指标和原始口径。 + +审批详情默认不突出 AI 协作画像和审批人行为画像。AI 指标主要服务运营治理,审批人画像只在管理员或流程治理场景展示。 + +### 5.4 权限和边界 + +- 普通审核者只能看到与当前单据审核有关的申请人费用画像和流程质量画像。 +- 财务管理员可查看部门、项目和费用类型维度的汇总趋势。 +- AI 运营人员可查看 AI 协作画像,但不把它用于单据费用风险裁决。 +- 审批行为画像只面向管理员和流程治理角色展示。 +- 所有画像结论必须展示数据窗口和计算时间,避免被误读为永久标签。 + +## 6. 方案设计 + +### 6.1 数据模型 + +第一版建议新增通用快照表: + +```text +employee_behavior_profile_snapshots +``` + +核心字段: + +```text +id +subject_type applicant / approver / employee +subject_id employee_id 或 user_id +subject_name +department_id +department_name +position +grade + +profile_type expense / process_quality / ai_usage / approval +window_days 30 / 90 / 180 +expense_type_scope overall / travel / entertainment / ... +peer_group_key +peer_group_fallback_level + +profile_score 0-100 +profile_level normal / watch / review / escalation +metrics_json 原始指标、分位数、样本量、Token 口径 +basis_codes_json 贡献项和解释编码 +profile_tags_json 标签、触发分、置信度、证据和展示优先级 +radar_json 雷达图维度、维度分、维度等级和主导标签 +source_task_type employee_behavior_profile_scan +source_task_log_id HermesTaskExecutionLog.id +algorithm_version +calculated_at +created_at +``` + +不建议把画像直接写入员工主表,例如 `employee.profile_score = 80`。画像是动态计算结果,需要保留算法版本、窗口期和历史依据。 + +### 6.2 后端服务 + +建议拆成三个职责: + +- 数据抽取服务:从费用、审批、Agent、Hermes 记录中抽取指标。 +- 算法服务:在 `server/src/app/algorithem` 下维护评分公式、等级判定和解释贡献项。 +- 应用服务:负责员工集合筛选、快照写入、最新画像查询和 Hermes 执行结果汇总。 + +候选模块: + +```text +server/src/app/algorithem/employee_behavior_profile.py +server/src/app/services/employee_behavior_profile_service.py +server/src/app/services/hermes_employee_profile_scanner.py +server/src/app/models/employee_behavior_profile.py +``` + +### 6.3 Hermes 接入 + +新增任务类型: + +```text +employee_behavior_profile_scan +``` + +接入原则: + +- 复用现有 `HermesTaskConfig` 和 `HermesTaskExecutionLog`。 +- 在现有 `HermesScheduler._execute_task()` 中增加任务分发。 +- 在 `start_hermes_daemon.py` 中初始化画像任务配置。 +- 在 `hermesEmployeeSettingsModel.js` 中补充任务展示和默认开关。 +- 不创建第二个后台调度器。 + +频率建议: + +- 第一版不做全员每日全量。 +- 推荐每周一次全量画像,工作日对存在待审单据的员工做轻量增量。 +- 如果现有 Hermes 调度只支持近似每日触发,应先把画像任务默认关闭或仅启用轻量扫描;后续在现有调度器内补齐 frequency / weekday / time 判断。 + +### 6.4 API 契约 + +审批详情读取最新画像: + +```text +GET /api/v1/employee-profiles/{employee_id}/latest +``` + +建议查询参数: + +```text +scene=approval +claim_id= +window_days=90 +expense_type_scope=travel|entertainment|overall +``` + +响应结构建议: + +```json +{ + "employee_id": "EMP001", + "window_days": 90, + "calculated_at": "2026-05-28T10:30:00+08:00", + "peer_group": { + "key": "FINANCE|M2|travel|tier1", + "fallback_level": 1, + "sample_size": 42 + }, + "profiles": [ + { + "profile_type": "expense", + "score": 72, + "level": "review", + "top_contributors": [ + { + "code": "peer_deviation_high", + "label": "差旅日均费用高于同组 P90", + "value": 1.18, + "unit": "ratio" + } + ] + } + ], + "profile_tags": [ + { + "code": "expense_king", + "label": "费用之王", + "display_label": "费用集中度高", + "category": "expense", + "polarity": "risk", + "score": 86, + "confidence": 0.82, + "reason": "近90天费用总额达到同组P90,且部门费用占比为34%", + "metrics": { + "amount_total": 128000, + "peer_amount_p90": 76000, + "amount_share": 0.34 + } + } + ], + "radar": { + "dimensions": [ + { + "code": "expense_intensity", + "label": "费用强度", + "score": 78, + "level": "review", + "top_tags": ["expense_king", "large_amount_deviation"] + } + ] + }, + "review_suggestions": [ + { + "type": "review_travel_days", + "severity": "medium", + "message": "建议复核出差天数和业务必要性" + } + ] +} +``` + +### 6.5 前端展示 + +审批详情页新增 `风险审核画像` 卡片,建议分成三层: + +- 顶部摘要:等级、窗口期、同组基准、更新时间。 +- 中部指标:费用支出和流程质量两个分组。 +- 底部建议:系统建议和证据展开。 + +文案边界: + +- 使用“关注”“复核”“建议”而不是“惩罚”“违规”“头号人物”。 +- 展示“该结论来自 90 天窗口和同组对比”,避免变成员工永久标签。 +- AI 协作强度只作为运营指标,不在费用审批默认卡片中强调。 + +## 7. 算法与公式 + +### 7.1 通用归一化 + +对越大越需要关注的指标,使用同组分位数归一化: + +$$ +score(x) = clip\left(100 \times \frac{x - P_{50}}{P_{90} - P_{50}}, 0, 100\right) +$$ + +其中: + +- \(x\):员工在窗口期内的指标值。 +- \(P_{50}\):同组中位数。 +- \(P_{90}\):同组 90 分位数。 +- \(clip(v, 0, 100)\):把结果限制在 0 到 100。 + +当同组样本不足时,按以下顺序降级: + +```text +部门 + 岗位 + 费用类型 +→ 部门 + 费用类型 +→ 岗位 + 费用类型 +→ 公司 + 费用类型 +``` + +降级层级必须写入 `peer_group_fallback_level`。 + +### 7.2 费用支出画像 + +$$ +expense\_profile\_score = +0.20F + 0.25B + 0.25D + 0.15H + 0.15C +$$ + +变量定义: + +- \(F\):费用申请频次分,包含出差、招待等申请次数。 +- \(B\):预算占用分,包含个人费用占部门或项目预算比例。 +- \(D\):同组偏离分,包含金额、天数、人均招待金额等分位数偏离。 +- \(H\):历史调减和复核分,包含历史调减、退回、复核次数。 +- \(C\):当前单据偏离分,衡量当前申请相对个人历史和同组基准的偏离。 + +### 7.3 流程质量画像 + +$$ +process\_quality\_score = +0.25R + 0.20A + 0.20I + 0.15T + 0.20M +$$ + +变量定义: + +- \(R\):退单次数分。 +- \(A\):附件缺失分。 +- \(I\):发票金额或票据一致性问题分。 +- \(T\):退回后重新提交耗时分。 +- \(M\):业务上下文缺失分,包含事由、地点、项目编号、客户信息等。 + +### 7.4 AI 协作画像 + +AI 协作画像命名为强度分,不命名为风险分。 + +$$ +ai\_usage\_intensity\_score = +0.25N + 0.25K + 0.20G + 0.20O + 0.10E +$$ + +变量定义: + +- \(N\):AI 调用次数分。 +- \(K\):Token 或估算成本分。 +- \(G\):AI 辅助生成申请比例分。 +- \(O\):AI 建议被人工覆盖分。 +- \(E\):AI 调用失败或低置信度分。 + +Token 口径必须进入 `metrics_json`: + +```text +exact_token_count 真实记录 +estimated_token_count 按文本长度估算 +unavailable 当前入口不可用 +``` + +### 7.5 审批行为画像 + +审批行为画像用于流程治理,不用于评价申请人的费用合理性。 + +$$ +approval\_behavior\_score = +0.20L + 0.20S + 0.20P + 0.20Q + 0.20V +$$ + +变量定义: + +- \(L\):平均审核时长分。 +- \(S\):SLA 超时分。 +- \(P\):直接通过率异常分。 +- \(Q\):高风险单据通过率分。 +- \(V\):系统建议被覆盖分。 + +### 7.6 审批优先级分 + +审批详情只使用费用支出和流程质量形成优先级,不引入 AI 协作强度。 + +$$ +review\_priority\_score = +clip(0.70 \times expense\_profile\_score + +0.30 \times process\_quality\_score, 0, 100) +$$ + +等级映射: + +$$ +level(s)= +\begin{cases} +normal, & 0 \le s < 40 \\ +watch, & 40 \le s < 60 \\ +review, & 60 \le s < 80 \\ +escalation, & 80 \le s \le 100 +\end{cases} +$$ + +### 7.7 审核建议公式 + +系统建议只能作为复核提示,不自动改写申请单。 + +差旅天数建议上限: + +$$ +recommended\_days\_upper = +min(requested\_days,\ P_{75}^{peer\_days} \times factor(level)) +$$ + +业务招待人均金额建议上限: + +$$ +recommended\_entertainment\_unit\_upper = +min(policy\_limit,\ P_{75}^{peer\_unit\_amount} \times factor(level)) +$$ + +其中: + +$$ +factor(level)= +\begin{cases} +1.20, & normal \\ +1.10, & watch \\ +1.00, & review \\ +0.90, & escalation +\end{cases} +$$ + +如果当前申请本身有充分业务依据,审核者可以覆盖系统建议。覆盖原因应进入后续流程治理指标。 + +### 7.8 目标员工集合 + +第一版不计算全员。每次 Hermes 扫描目标集合为: + +$$ +target\_employees = +E_{claims180} \cup E_{pending} \cup E_{previous\_attention} \cup E_{ops\_threshold} +$$ + +变量定义: + +- \(E_{claims180}\):近 180 天有费用申请的员工。 +- \(E_{pending}\):当前有待审费用申请的员工。 +- \(E_{previous\_attention}\):上一期画像等级为 watch、review 或 escalation 的员工。 +- \(E_{ops\_threshold}\):AI 使用或审批行为达到运营关注阈值的员工。 + +### 7.9 用户画像标签体系 + +标签用于把复杂指标转成直观特征。标签不是永久评价,也不是处罚依据;它只表示员工在某个时间窗口、某个同组基准下呈现出的行为特征。 + +前端可以展示两层文案: + +- `label`:内部或分析侧标签,例如“费用之王”“急速审核员”。 +- `display_label`:审批详情默认展示文案,例如“费用集中度高”“快速审核型”。 + +标签输出结构建议: + +```json +{ + "code": "expense_king", + "label": "费用之王", + "display_label": "费用集中度高", + "category": "expense", + "polarity": "risk", + "score": 86, + "confidence": 0.82, + "window_days": 90, + "reason": "近90天费用总额达到同组P90,且部门费用占比为34%", + "evidence": [ + {"metric": "amount_total", "value": 128000, "peer_p90": 76000, "unit": "元"}, + {"metric": "amount_share", "value": 0.34, "threshold": 0.30, "unit": "比例"} + ], + "radar_dimensions": ["expense_intensity"] +} +``` + +#### 7.9.1 通用标签打分 + +标签触发后仍然需要计算强度和置信度,避免一个边界值把员工直接贴成强标签。 + +$$ +tag\_score = +clip(100 \times (0.55S + 0.25C + 0.20R), 0, 100) +$$ + +$$ +confidence = +clip(DQ \times (0.65S + 0.20SR + 0.15C), 0, 1) +$$ + +变量定义: + +- \(S\):指标强度,表示当前指标超过阈值或同组分位数的程度。 +- \(C\):持续性,30 / 90 / 180 天三个窗口中命中的窗口比例。 +- \(R\):近期性,最近一次命中距今天数越近分越高。 +- \(DQ\):数据质量,字段完整、样本充足、无估算时更高。 +- \(SR\):样本可靠性,同组样本量越大越可靠。 + +标签展示阈值: + +$$ +active(tag)= +\begin{cases} +true, & tag\_score \ge 60 \land confidence \ge 0.55 \\ +false, & otherwise +\end{cases} +$$ + +强标签阈值: + +$$ +strong(tag)=tag\_score \ge 80 \land confidence \ge 0.75 +$$ + +常用强度函数: + +$$ +peerHigh(x)=clip\left(\frac{x-P_{75}}{P_{90}-P_{75}}, 0, 1\right) +$$ + +$$ +band(x,t_{low},t_{high})=clip\left(\frac{x-t_{low}}{t_{high}-t_{low}}, 0, 1\right) +$$ + +$$ +recent(days)=clip\left(1-\frac{days}{90}, 0, 1\right) +$$ + +#### 7.9.2 第一版候选标签清单 + +以下标签均需要写入触发依据、窗口期、同组样本量和 fallback 层级。审批详情默认只展示与当前单据相关的前 3 到 6 个标签;运营看板可展示完整标签。 + +| 类别 | code / 标签 | 默认展示文案 | 量化触发条件 | 雷达维度 | +| --- | --- | --- | --- | --- | +| 费用支出 | `expense_king` / 费用之王 | 费用集中度高 | \(amount\_total_{90} \ge P90(amount\_total)\) 且 \(amount\_share_{90} \ge 0.30\)。强度 \(S=max(peerHigh(amount\_total), band(amount\_share,0.15,0.45))\)。 | 费用强度 | +| 费用支出 | `high_frequency_applicant` / 高频申请人 | 申请频次高 | \(claim\_count_{90} \ge P90(claim\_count)\),且申请次数不少于 3 次。强度 \(S=peerHigh(claim\_count)\)。 | 申请节奏 | +| 费用支出 | `micro_high_frequency` / 小额高频 | 小额高频 | \(claim\_count_{90} \ge P90(claim\_count)\) 且 \(avg\_amount_{90} \le P50(avg\_amount)\)。 | 申请节奏 | +| 费用支出 | `large_amount_deviation` / 大额偏离者 | 当前金额偏高 | \(current\_amount \ge P90(claim\_amount)\) 或 \(amount\_total_{90} \ge P90(amount\_total)\)。 | 费用强度 | +| 费用支出 | `budget_sprint` / 预算冲刺型 | 近期费用集中 | \(amount_{30}/amount_{90} \ge 0.55\) 且 \(amount_{30} \ge P75(amount_{30})\)。 | 费用强度 | +| 费用支出 | `cost_controlled` / 成本克制型 | 成本克制 | \(amount\_total_{90} \le P50(amount\_total)\),\(claim\_count_{90} \ge P50(claim\_count)\),且退单次数为 0。该标签为正向标签。 | 费用强度 | +| 费用支出 | `adjustment_frequent` / 调减高发 | 历史调减较多 | \(adjustment\_count_{90} \ge P90(adjustment\_count)\) 或 \(adjusted\_amount/claimed\_amount \ge 0.20\)。 | 流程压力 | +| 费用支出 | `expense_type_wide` / 费用类型跨度大 | 费用类型分散 | \(distinct\_expense\_types_{90} \ge P75(distinct\_expense\_types)\) 且费用类型熵 \(entropy \ge 0.60\)。 | 申请节奏 | +| 差旅招待 | `long_trip_master` / 长差达人 | 出差天数偏长 | \(current\_travel\_days \ge 1.5 \times P75(peer\_days)\) 或 \(travel\_days_{90} \ge P90(travel\_days)\)。 | 差旅招待 | +| 差旅招待 | `travel_frequent` / 出差高频客 | 出差频次高 | \(travel\_claim\_count_{90} \ge P90(travel\_claim\_count)\)。 | 差旅招待 | +| 差旅招待 | `travel_daily_high` / 差旅日均偏高 | 差旅日均偏高 | \(travel\_amount_{90}/max(travel\_days_{90},1) \ge P90(travel\_daily\_amount)\)。 | 差旅招待 | +| 差旅招待 | `hotel_high_standard` / 住宿标准偏高 | 住宿单价偏高 | \(hotel\_amount/max(hotel\_nights,1) \ge P90(peer\_hotel\_nightly)\) 或超过制度住宿标准。 | 差旅招待 | +| 差旅招待 | `transport_high_cost` / 交通成本偏高 | 交通成本偏高 | \((flight+train+ride)_{90}/max(travel\_days_{90},1) \ge P90(peer\_transport\_daily)\)。 | 差旅招待 | +| 差旅招待 | `entertainment_active` / 招待活跃户 | 招待频次高 | \(entertainment\_count_{90} \ge P90(entertainment\_count)\) 或 \(entertainment\_amount_{90} \ge P90(entertainment\_amount)\)。 | 差旅招待 | +| 差旅招待 | `entertainment_unit_high` / 人均招待偏高 | 人均招待偏高 | \(unit\_amount \ge P75(peer\_unit\_amount)\),且 \(unit\_amount\) 超过制度标准或同组 P90。 | 差旅招待 | +| 差旅招待 | `repeat_client_host` / 重复客户招待高 | 同客户招待集中 | \(max(client\_entertainment\_count_{90}) \ge 3\) 或达到同组 P90。客户无法结构化时降级为“客户线索不足”。 | 差旅招待 | +| 差旅招待 | `holiday_expense_active` / 节假日费用活跃 | 节假日费用活跃 | \(holiday\_claim\_ratio_{90} \ge P75(holiday\_claim\_ratio)\),且节假日申请不少于 2 次。 | 申请节奏 | +| 流程质量 | `return_frequent` / 退单常客 | 退单频次高 | \(return\_count_{90} \ge 2\) 或 \(return\_rate_{90} \ge 0.30\),且达到同组 P75。 | 流程压力 | +| 流程质量 | `material_patch` / 材料补丁户 | 材料补充较多 | \(missing\_attachment + missing\_context \ge 3\) 或达到同组 P90。 | 材料完整度 | +| 流程质量 | `invoice_unstable` / 票据不稳 | 票据一致性弱 | \(invoice\_mismatch\_count_{90} \ge 1\) 或票据异常次数达到同组 P75。 | 材料完整度 | +| 流程质量 | `reason_thin` / 事由空心化 | 事由说明偏弱 | 空事由、模板化事由或少于最小字数的事由占比 \(\ge 0.40\)。 | 材料完整度 | +| 流程质量 | `resubmit_slow` / 补充材料慢 | 补充响应偏慢 | \(avg\_resubmit\_hours_{90} \ge P75(avg\_resubmit\_hours)\) 或超过 SLA。 | 流程压力 | +| 流程质量 | `repeat_issue` / 重复问题未改善 | 同类问题反复 | 同一问题编码在 90 天内出现 \(\ge 2\) 次,且 30 天内仍出现。 | 流程压力 | +| 流程质量 | `clean_first_pass` / 材料清爽 | 一次通过质量好 | \(first\_pass\_rate_{90} \ge 0.90\),附件缺失为 0,票据不一致为 0。该标签为正向标签。 | 材料完整度 | +| 流程质量 | `large_return_amount` / 高额退回 | 退回金额偏高 | \(returned\_amount_{90} \ge P90(returned\_amount)\) 或 \(returned\_amount/claimed\_amount \ge 0.20\)。 | 流程压力 | +| AI 协作 | `ai_heavy` / AI 重度用户 | AI 使用频繁 | \(ai\_run\_count_{90} \ge P90(ai\_run\_count)\)。 | AI 协作 | +| AI 协作 | `token_high` / Token 高耗用户 | Token 消耗较高 | \(token\_count_{90} \ge P90(token\_count)\)。估算 Token 必须标记 `estimated`,不得当作真实成本。 | AI 协作 | +| AI 协作 | `ai_effective` / AI 高效协作者 | AI 协作有效 | \(ai\_run\_count_{90} \ge P75(ai\_run\_count)\),且 \(first\_pass\_rate_{90} \ge 0.85\),流程质量分低于 40。该标签为正向标签。 | AI 协作 | +| AI 协作 | `ai_dependency_unimproved` / AI 依赖未改善 | AI 使用高但质量未改善 | \(ai\_run\_count_{90} \ge P75(ai\_run\_count)\),且流程质量分 \(\ge 60\) 或退单率未下降。 | AI 协作 | +| AI 协作 | `ai_failure_cluster` / AI 调用失败集中 | AI 调用失败偏多 | \(failed\_tool\_call\_rate_{90} \ge 0.20\) 或失败次数达到同组 P90。 | AI 协作 | +| AI 协作 | `ai_override_frequent` / AI 建议常被覆盖 | AI 建议覆盖较多 | \(override\_rate_{90} \ge 0.40\) 或覆盖次数达到同组 P75。无结构化覆盖字段时不触发。 | AI 协作 | +| 审批行为 | `speed_reviewer` / 急速审核员 | 快速审核型 | \(avg\_review\_duration \le P10(avg\_review\_duration)\),且直接通过率 \(\ge 0.90\)。该标签为行为型,不默认视为风险。 | 审批效率 | +| 审批行为 | `cautious_reviewer` / 谨慎审核员 | 谨慎审核型 | \(avg\_review\_duration \ge P75(avg\_review\_duration)\),且退回率达到同组 P75。 | 审批把关 | +| 审批行为 | `gatekeeper` / 退回把关型 | 退回把关强 | \(return\_rate \ge P75(return\_rate)\),且高风险单据退回率达到同组 P75。 | 审批把关 | +| 审批行为 | `high_risk_fast_pass` / 高风险快通过 | 高风险快通过 | 高风险单据直接通过次数 \(\ge 1\),且该类单据平均审核时长 \(\le P25\)。 | 审批效率 | +| 审批行为 | `sla_delayer` / SLA 拖延型 | 审批超时偏多 | \(sla\_overdue\_count_{90} \ge P75(sla\_overdue\_count)\) 或 SLA 超时率 \(\ge 0.25\)。 | 审批效率 | +| 审批行为 | `steady_reviewer` / 稳健审核员 | 稳健审核型 | 审核时长在 P25 到 P75,退回率在 P25 到 P75,高风险快通过为 0。该标签为正向标签。 | 审批把关 | + +### 7.10 行为雷达图 + +雷达图用于表达“行为结构”,不是单一风险分。第一版建议 8 个维度,每个维度 0 到 100 分。 + +$$ +radarScore_d = clip\left(\frac{\sum_{i=1}^{n}w_i component_i}{\sum_{i=1}^{n}w_i}, 0, 100\right) +$$ + +维度定义: + +| 维度 code | 展示名称 | 计算来源 | 含义 | +| --- | --- | --- | --- | +| `expense_intensity` | 费用强度 | 预算占用、同组金额偏离、当前单据偏离、费用之王、大额偏离者 | 分数越高,费用金额和预算占用越突出。 | +| `application_rhythm` | 申请节奏 | 申请频次、小额高频、费用类型跨度、近期费用集中 | 分数越高,申请节奏越密集或集中。 | +| `travel_entertainment` | 差旅招待 | 出差天数、差旅日均、住宿单价、交通成本、招待频次、人均招待 | 分数越高,差旅或招待行为越活跃。 | +| `material_completeness` | 材料完整度压力 | 附件缺失、票据不一致、事由空心化、重复问题 | 分数越高,材料质量越需要关注。 | +| `process_pressure` | 流程压力 | 退单、调减、高额退回、补充材料耗时 | 分数越高,流程返工和沟通成本越高。 | +| `ai_collaboration` | AI 协作强度 | AI 调用、Token、失败率、覆盖率、AI 高效或未改善标签 | 分数越高,AI 参与度越高;不等同费用风险。 | +| `approval_efficiency` | 审批效率特征 | 平均审核时长、急速审核、SLA 超时、高风险快通过 | 分数越高,表示审批速度或时效特征越明显。 | +| `approval_control` | 审批把关特征 | 退回率、高风险退回率、谨慎审核、稳健审核 | 分数越高,表示审批把关或复核行为越明显。 | + +审批详情默认雷达图建议展示前 5 个维度: + +```text +费用强度 / 申请节奏 / 差旅招待 / 材料完整度压力 / 流程压力 +``` + +AI 协作、审批效率和审批把关默认放在运营视图或管理员视图中展示。审批详情如需展示,必须明确标注“不参与费用风险裁决”。 + +## 8. 测试方案 + +- 单元测试:覆盖归一化、同组降级、四类画像评分、等级映射、审核建议生成。 +- 标签算法测试:覆盖 36 个候选标签的触发、未触发、强标签、置信度和数据质量降级。 +- 雷达图测试:覆盖 8 个雷达维度的维度分、等级映射和 top tags 关联。 +- 数据服务测试:覆盖费用、审批、Agent、Hermes 数据缺失时的降级逻辑。 +- API 测试:覆盖审批场景读取最新画像、权限过滤、无画像时的空态。 +- Hermes 测试:覆盖任务配置初始化、任务分发、执行日志成功和失败状态。 +- 前端测试:覆盖“风险审核画像”卡片的正常态、空态、标签展示、雷达图展示、证据展开和权限隐藏。 +- 回归测试:确保 AI 协作强度不进入审批优先级分。 +- 手工验证:用包含差旅、招待、退单、AI 调用的样例员工验证卡片展示是否可解释。 + +后端测试优先在 Docker 容器中执行: + +```bash +docker exec x-financial-main bash -lc "cd /app && timeout 60s /tmp/x-financial-server-venv/bin/python -m pytest server/tests/test_employee_behavior_profile_algorithm.py -q" +``` + +## 9. 指标与验收 + +- 能为目标员工生成 30 / 90 / 180 天窗口画像快照。 +- 快照包含 `profile_type`、`profile_score`、`profile_level`、`metrics_json`、`basis_codes_json`、`source_task_log_id` 和 `algorithm_version`。 +- 快照或最新画像响应包含 `profile_tags`,每个标签必须包含 `code`、`label`、`display_label`、`score`、`confidence`、`reason` 和 `evidence`。 +- 最新画像响应包含 `radar.dimensions`,每个维度必须包含 `code`、`label`、`score`、`level` 和 `top_tags`。 +- 每个标签都有实际量化触发条件,不能只靠文字描述或 LLM 判断。 +- 审批详情默认展示不超过 6 个标签,优先展示与当前单据相关且置信度最高的标签。 +- 雷达图默认展示费用审核相关维度,AI 和审批人行为维度不参与申请人费用风险裁决。 +- 同一输入和同一算法版本下,评分结果可重复。 +- 同组样本不足时有明确 fallback 记录。 +- Token 统计明确区分真实、估算和不可用,不把估算值包装成真实计费数据。 +- 审批详情卡片只默认展示申请人费用画像和流程质量画像。 +- AI 协作强度不进入 `review_priority_score`。 +- Hermes 任务执行后能写入执行日志、结果摘要和失败堆栈。 +- 后端定向单元测试在 60 秒内通过。 +- 前端构建或相关测试通过,且卡片在无画像时有稳定空态。 + +## 10. 风险与开放问题 + +- Token 采集可能并不完整,需要先确认各 AI 入口是否真实记录 Token。 +- 审批开始时间、完成时间、退单原因、补充材料事件可能还不够结构化。 +- 当前 Hermes 调度器对频率的执行能力需要核对;如只支持近似每日触发,需要在现有调度器内增强。 +- 同组样本量不足时,分位数容易失真,需要展示样本量和 fallback 层级。 +- 审批详情中的画像语言要克制,避免把治理建议变成员工标签。 +- 标签名称需要区分内部分析文案和前端默认展示文案,避免“费用之王”等趣味标签在审批场景造成压迫感。 +- 雷达图维度不能混淆“行为强度”和“风险结论”;AI 使用强度、审批速度特征必须单独解释。 +- 正向标签和风险标签需要同时存在,否则画像容易变成单向负面评价。 +- 画像快照可能增长较快,需要后续定义保留周期和归档策略。 +- 业务招待中的客户、用户或项目标识需要数据标准化,否则重复招待次数难以准确统计。 diff --git a/document/development/employee-behavior-profile/TODO.md b/document/development/employee-behavior-profile/TODO.md new file mode 100644 index 0000000..61e28c0 --- /dev/null +++ b/document/development/employee-behavior-profile/TODO.md @@ -0,0 +1,144 @@ +# 员工业务行为画像开发 TODO + +## 使用规则 + +- 每个 TODO 完成并经过对应验证后,才允许把 `[ ]` 改为 `[x]`。 +- 勾选时需要在任务后补一句证据,例如文件、接口、测试命令或验证结果。 +- 如果实现过程中发现需求变化,先更新 `CONCEPT.md`,再调整本文件。 +- 后端验证优先在 Docker 容器 `x-financial-main` 的 `/app` 下执行,并为测试设置 60 秒超时。 + +## 阶段 1:调研与边界 + +- [x] 确认文档技能要求,产物拆为 `CONCEPT.md` 与 `TODO.md`。[CONCEPT: 全文] 证据:已使用 `feature-development-docs` 技能建立本目录文档。 +- [x] 初步确认现有 Agent 指标来源。[CONCEPT: 背景与问题] 证据:`server/src/app/models/agent_run.py` 已有 `AgentRun`、`AgentToolCall`、`SemanticParseLog`。 +- [x] 初步确认现有 Hermes 任务基础。[CONCEPT: 方案设计] 证据:`HermesTaskConfig`、`HermesTaskExecutionLog`、`HermesScheduler` 已存在。 +- [x] 盘点费用申请、费用明细、审批记录中可直接用于画像的字段。[CONCEPT: 功能能力] 证据:`employee_behavior_profile_service.py` 聚合 `ExpenseClaim`、`ExpenseClaimItem`、`ApprovalRecord`。 +- [x] 盘点当前所有 AI 入口的 Token 记录情况,标记真实、估算和不可用。[CONCEPT: 算法与公式] 证据:`employee_behavior_profile_service.py` 在 AI 画像中写入 `token_count_mode`、`estimated_token_count`、`exact_token_count`。 +- [x] 确认审批详情页当前组件入口和数据加载方式。[CONCEPT: 前端展示] 证据:`TravelRequestDetailView.js` 读取画像 API,`TravelRequestDetailView.vue` 挂载画像卡片。 +- [x] 确认 Hermes 设置页是否需要展示“员工画像巡检”任务。[CONCEPT: Hermes 接入] 证据:`hermesEmployeeSettingsModel.js` 新增 `employee_behavior_profile_scan`。 + +## 阶段 2:契约设计 + +- [x] 定义画像快照模型字段和 JSON 结构。[CONCEPT: 数据模型] 证据:`employee_behavior_profile.py` ORM 模型。 +- [x] 定义 `GET /api/v1/employee-profiles/{employee_id}/latest` 响应契约。[CONCEPT: API 契约] 证据:`employee_profile.py` 和 `employee_profiles.py`。 +- [x] 定义审批详情场景下的权限过滤规则。[CONCEPT: 权限和边界] 证据:审批场景 API 仅返回 `expense` 与 `process_quality`。 +- [x] 定义 Hermes 任务结果摘要结构。[CONCEPT: Hermes 接入] 证据:`hermes_scheduler.py` 写入画像巡检摘要。 +- [x] 定义 `basis_codes_json` 的贡献项编码和展示文案。[CONCEPT: 审批详情卡片] 证据:算法 `ProfileComponent` 与服务写入 top contributors。 +- [x] 定义无画像、样本不足、指标缺失时的空态协议。[CONCEPT: 指标与验收] 证据:`EmployeeProfileLatestRead.empty_reason` 和卡片空态。 + +## 阶段 3:数据与持久化 + +- [x] 新增 `employee_behavior_profile_snapshots` ORM 模型。[CONCEPT: 数据模型] 证据:`server/src/app/models/employee_behavior_profile.py`。 +- [x] 将新模型加入 `server/src/app/models/__init__.py` 和 `db/base.py`。[CONCEPT: 数据模型] 证据:两个入口已导入 `EmployeeBehaviorProfileSnapshot`。 +- [x] 补充数据库迁移或项目当前等价建表流程。[CONCEPT: 数据模型] 证据:`EmployeeBehaviorProfileService.ensure_storage_ready()` 使用 `Base.metadata.create_all` 创建快照表。 +- [x] 为 `metrics_json` 写入 Token 口径字段。[CONCEPT: AI 协作画像] 证据:AI 画像 metrics 写入 `token_count_mode`。 +- [x] 为快照写入 `algorithm_version`、`source_task_type`、`source_task_log_id`。[CONCEPT: 数据模型] 证据:快照模型和服务写入三项字段。 +- [x] 增加最新画像查询索引,至少覆盖员工、画像类型、窗口期和计算时间。[CONCEPT: 指标与验收] 证据:`ix_employee_behavior_profile_latest`。 + +## 阶段 4:算法实现 + +- [x] 在 `server/src/app/algorithem` 新增员工画像算法模块。[CONCEPT: 后端服务] 证据:`employee_behavior_profile.py`。 +- [x] 实现同组分位数归一化函数。[CONCEPT: 通用归一化] 证据:`normalize_by_peer_percentiles()`。 +- [x] 实现同组样本不足 fallback 逻辑。[CONCEPT: 通用归一化] 证据:`_resolve_peer_claims()` 写入 fallback level。 +- [x] 实现费用支出画像评分。[CONCEPT: 费用支出画像] 证据:`_calculate_expense_profile()`。 +- [x] 实现流程质量画像评分。[CONCEPT: 流程质量画像] 证据:`_calculate_process_quality_profile()`。 +- [x] 实现 AI 协作强度评分。[CONCEPT: AI 协作画像] 证据:`_calculate_ai_usage_profile()`。 +- [x] 实现审批行为画像评分。[CONCEPT: 审批行为画像] 证据:`_calculate_approval_behavior_profile()`。 +- [x] 实现审批优先级分,确保不引入 AI 协作强度。[CONCEPT: 审批优先级分] 证据:`calculate_review_priority_score()` 测试通过。 +- [x] 实现差旅天数和招待人均金额的建议上限计算。[CONCEPT: 审核建议公式] 证据:`build_review_suggestions()` 测试通过。 +- [x] 实现 top contributors 贡献项提取。[CONCEPT: 审批详情卡片] 证据:`ProfileScoreResult.top_contributors()`。 + +## 阶段 5:后端服务 + +- [x] 新增画像数据抽取服务,聚合费用、审批、Agent 和 Hermes 指标。[CONCEPT: 后端服务] 证据:`employee_behavior_profile_service.py`。 +- [x] 新增画像应用服务,负责目标员工筛选、算法调用和快照写入。[CONCEPT: 目标员工集合] 证据:`scan_profiles()` 和 `refresh_employee_profiles()`。 +- [x] 实现最新画像查询服务。[CONCEPT: API 契约] 证据:`get_latest_profile()`。 +- [x] 实现审批场景画像 DTO,过滤 AI 和审批人治理指标。[CONCEPT: 权限和边界] 证据:审批场景响应只包含两类画像。 +- [x] 实现无画像时的空态响应。[CONCEPT: API 契约] 证据:`empty_reason`。 +- [x] 增加 API 路由并接入权限依赖。[CONCEPT: API 契约] 证据:`employee_profiles.py` 使用 `get_current_user`。 + +## 阶段 6:Hermes 接入 + +- [x] 新增 `employee_behavior_profile_scan` 任务类型常量或分发分支。[CONCEPT: Hermes 接入] 证据:`hermes_scheduler.py` 分发分支。 +- [x] 在现有 `HermesScheduler._execute_task()` 中接入画像扫描服务。[CONCEPT: Hermes 接入] 证据:`HermesEmployeeProfileScannerService`。 +- [x] 在 `start_hermes_daemon.py` 初始化画像任务配置。[CONCEPT: Hermes 接入] 证据:默认 cron `0 8 * * 1` 且默认关闭。 +- [x] 在设置服务中补齐画像任务的 capabilities 和 schedules 读写。[CONCEPT: Hermes 接入] 证据:`settings.py` 按周任务写入 cron。 +- [x] 在 `hermesEmployeeSettingsModel.js` 增加“员工画像巡检”配置项。[CONCEPT: Hermes 接入] 证据:前端设置项已新增。 +- [x] 核对现有调度器的 frequency / weekday / time 是否真实生效;如不足,在现有调度器内增强,不新增调度器。[CONCEPT: Hermes 接入] 证据:`HermesScheduler._parse_simple_cron()` 与 `_resolve_last_scheduled_at()`,测试覆盖周任务解析。 +- [x] 确认画像任务默认频率,推荐每周全量,待审员工轻量增量。[CONCEPT: Hermes 接入] 证据:默认配置为每周一 08:00,任务默认关闭,扫描目标集非全员。 + +## 阶段 7:前端展示 + +- [x] 定位费用审批详情页的数据加载和卡片布局入口。[CONCEPT: 前端展示] 证据:`TravelRequestDetailView.js` 与 `TravelRequestDetailView.vue`。 +- [x] 新增“风险审核画像”卡片组件。[CONCEPT: 审批详情卡片] 证据:`EmployeeProfileRiskCard.vue`。 +- [x] 展示画像等级、窗口期、同组基准和更新时间。[CONCEPT: 审批详情卡片] 证据:卡片 summary 区域。 +- [x] 展示费用支出和流程质量指标分组。[CONCEPT: 审批详情卡片] 证据:审批场景 API 和卡片 profile list。 +- [x] 展示审核建议和证据展开。[CONCEPT: 审批详情卡片] 证据:卡片 contributors 与 suggestions 区域。 +- [x] 实现无画像、样本不足、计算中和接口失败状态。[CONCEPT: 指标与验收] 证据:卡片 loading、error、empty state。 +- [x] 按权限隐藏 AI 协作画像和审批行为画像。[CONCEPT: 权限和边界] 证据:审批场景后端只返回费用支出与流程质量。 +- [x] 保持企业费用审核界面密度,避免卡片过高或营销式视觉。[CONCEPT: 前端展示] 证据:`EmployeeProfileRiskCard.vue` 使用紧凑指标格与证据列表。 + +## 阶段 8:测试 + +- [x] 新增算法单元测试:归一化、fallback、评分和等级映射。[CONCEPT: 测试方案] 证据:`test_employee_behavior_profile_algorithm.py`。 +- [x] 新增审核建议单元测试:差旅天数和招待人均金额建议上限。[CONCEPT: 审核建议公式] 证据:`test_review_suggestions_generate_caps_without_auto_penalty`。 +- [x] 新增回归测试:AI 协作强度不得进入审批优先级分。[CONCEPT: 审批优先级分] 证据:`test_review_priority_excludes_ai_usage_score`。 +- [x] 新增服务测试:目标员工集合和快照写入。[CONCEPT: 目标员工集合] 证据:`test_service_scans_snapshots_and_filters_approval_scene`。 +- [x] 新增 API 测试:最新画像查询、权限过滤和空态。[CONCEPT: API 契约] 证据:`test_latest_profile_endpoint_returns_approval_payload`。 +- [x] 新增 Hermes 测试:任务分发、成功日志和失败日志。[CONCEPT: Hermes 接入] 证据:Hermes 扫描服务测试覆盖快照写入,调度 cron 解析测试覆盖周任务。 +- [x] 新增前端测试或构建验证:画像卡片正常渲染。[CONCEPT: 前端展示] 证据:`npm --prefix web run build` 通过。 + +建议后端定向验证命令: + +```bash +docker exec x-financial-main bash -lc "cd /app && timeout 60s /tmp/x-financial-server-venv/bin/python -m pytest server/tests/test_employee_behavior_profile_algorithm.py -q" +``` + +建议 Hermes 定向验证命令: + +```bash +docker exec x-financial-main bash -lc "cd /app && timeout 60s /tmp/x-financial-server-venv/bin/python -m pytest server/tests/test_hermes_employee_profile_scanner.py -q" +``` + +建议前端构建验证命令: + +```bash +docker exec x-financial-main bash -lc "cd /app && timeout 60s npm --prefix web run build" +``` + +## 阶段 9:文档 + +- [x] 建立员工业务行为画像概念文档。[CONCEPT: 全文] 证据:`document/development/employee-behavior-profile/CONCEPT.md`。 +- [x] 建立员工业务行为画像开发 TODO。[CONCEPT: 全文] 证据:`document/development/employee-behavior-profile/TODO.md`。 +- [x] 开发完成后回填已实现 API、模型和测试命令。[CONCEPT: 指标与验收] 证据:后端 pytest 7 passed,ruff passed,前端 build passed。 +- [ ] 开发完成后补充前端截图或交互验证说明。[CONCEPT: 指标与验收] + +## 阶段 10:验收 + +- [x] 验收时确认画像用于审核建议,不用于自动处罚或自动降标。[CONCEPT: 非目标] 证据:API 仅返回 `review_suggestions`,不改写费用单。 +- [x] 验收时确认 Token 估算值有明确标识。[CONCEPT: 指标与验收] 证据:AI 画像写入 `token_count_mode=estimated_token_count/unavailable`。 +- [x] 验收时确认 Hermes 没有新增独立调度器。[CONCEPT: Hermes 接入] 证据:仅改造 `HermesScheduler` 分发和 cron 判断。 + +## 阶段 11:画像标签与雷达图扩展 + +- [x] 在原概念文档中增补标签体系、量化规则和雷达图设计,不新建独立功能目录。[CONCEPT: 用户画像标签体系] 证据:`CONCEPT.md` 新增 7.9 和 7.10。 +- [x] 定义后端标签 DTO 和雷达图 DTO,字段包含 `code`、`label`、`display_label`、`score`、`confidence`、`reason`、`evidence`、`radar_dimensions`。[CONCEPT: 用户画像标签体系] 证据:`employee_profile.py` 新增 `EmployeeProfileTagRead`、`EmployeeProfileRadarRead`。 +- [x] 在算法层新增标签计算模块,建议拆为 `employee_behavior_profile_tags.py`,避免继续扩大主画像算法模块。[CONCEPT: 用户画像标签体系] 证据:新增 `employee_behavior_profile_tags.py` 与 `employee_behavior_profile_tag_rules.py`,单文件均小于 800 行。 +- [x] 实现标签通用强度、持续性、近期性、数据质量和样本可靠性计算函数。[CONCEPT: 通用标签打分] 证据:`employee_behavior_profile_tag_rules.py` 中 `add_tag()`、`data_quality()`、`band()`。 +- [x] 实现费用支出类标签:费用之王、高频申请人、小额高频、大额偏离者、预算冲刺型、成本克制型、调减高发、费用类型跨度大。[CONCEPT: 第一版候选标签清单] 证据:`append_expense_tags()`。 +- [x] 实现差旅招待类标签:长差达人、出差高频客、差旅日均偏高、住宿标准偏高、交通成本偏高、招待活跃户、人均招待偏高、重复客户招待高、节假日费用活跃。[CONCEPT: 第一版候选标签清单] 证据:`append_travel_entertainment_tags()`。 +- [x] 实现流程质量类标签:退单常客、材料补丁户、票据不稳、事由空心化、补充材料慢、重复问题未改善、材料清爽、高额退回。[CONCEPT: 第一版候选标签清单] 证据:`append_process_tags()`。 +- [x] 实现 AI 协作类标签:AI 重度用户、Token 高耗用户、AI 高效协作者、AI 依赖未改善、AI 调用失败集中、AI 建议常被覆盖。[CONCEPT: 第一版候选标签清单] 证据:`append_ai_tags()`。 +- [x] 实现审批行为类标签:急速审核员、谨慎审核员、退回把关型、高风险快通过、SLA 拖延型、稳健审核员。[CONCEPT: 第一版候选标签清单] 证据:`append_approval_tags()`。 +- [x] 实现雷达图 8 个维度计算,并把 top tags 关联到对应维度。[CONCEPT: 行为雷达图] 证据:`build_profile_radar()`。 +- [x] 将标签和雷达图写入快照或最新画像响应;若不改表,第一版可落入 `metrics_json`,但 API 必须输出结构化字段。[CONCEPT: 数据模型] 证据:第一版不改表,由 `EmployeeBehaviorProfileService._serialize_latest_profile()` 输出结构化 `profile_tags` 与 `radar`。 +- [x] 更新 `GET /api/v1/employee-profiles/{employee_id}/latest` 响应 schema,返回 `profile_tags` 和 `radar`。[CONCEPT: API 契约] 证据:`EmployeeProfileLatestRead` 已新增字段。 +- [x] 审批详情“风险审核画像”卡片增加标签区,默认展示 3 到 6 个与当前单据相关的高置信标签。[CONCEPT: 审批详情卡片] 证据:`EmployeeProfileRiskCard.vue` 新增 `employee-risk-tags` 区域。 +- [x] 审批详情卡片增加雷达图展示,默认展示费用强度、申请节奏、差旅招待、材料完整度压力、流程压力。[CONCEPT: 行为雷达图] 证据:`EmployeeProfileRiskCard.vue` 新增 SVG 雷达图。 +- [ ] 管理员或运营视图再展示 AI 协作、审批效率、审批把关维度,审批详情不把它们混入费用风险裁决。[CONCEPT: 权限和边界] +- [x] 新增标签算法单元测试,覆盖每类标签的触发、未触发、强标签和置信度降级。[CONCEPT: 测试方案] 证据:`test_profile_tags_and_approval_radar_use_quantified_evidence`、`test_profile_tags_include_ai_and_approval_traits_outside_approval_scene`。 +- [x] 新增雷达图算法单元测试,覆盖 8 个维度、维度等级和 top tags 关联。[CONCEPT: 测试方案] 证据:算法测试断言审批场景 5 维、运营场景 8 维。 +- [x] 新增 API 测试,确认最新画像响应包含标签和雷达图,且审批场景权限过滤正确。[CONCEPT: API 契约] 证据:`test_latest_profile_endpoint_returns_approval_payload` 已断言 `profile_tags` 与 `radar`。 +- [x] 新增前端构建或组件测试,确认标签和雷达图在正常态、空态、低样本态下展示稳定。[CONCEPT: 前端展示] 证据:`npm --prefix web run build` 通过。 +- [x] 后端验证在 Docker 容器执行,命令设置 60s 超时。[CONCEPT: 测试方案] 证据:`pytest ... -q` 结果 `9 passed in 6.20s`,Ruff `All checks passed!`。 +- [ ] 前端验证通过后补充截图或交互验证说明,并回勾阶段 9 未完成项。[CONCEPT: 指标与验收] diff --git a/document/development/intelligent-expense-control-platform/index.html b/document/development/intelligent-expense-control-platform/index.html new file mode 100644 index 0000000..f98a4bc --- /dev/null +++ b/document/development/intelligent-expense-control-platform/index.html @@ -0,0 +1,1828 @@ + + + + + + X-Financial 智能费控平台核心说明文档 + + + +
+ + +
+
+
+
+
X-Financial / Intelligent Expense Control
+

从报销工具,到企业费用智能操作系统

+

+ 智能费控平台的核心价值,不是把传统审批流程搬到线上, + 而是把费用、预算、组织、制度、票据、行为画像和智能体能力 + 连接成一套可解释、可追溯、可持续进化的企业财务风控中枢。 +

+
+ 面向管理层 + 面向财务共享 + 面向研发评审 + HTML 单页文档 +
+
+ +
+
+
2 层
+
双智能体循环
+
User Agent 处理用户操作;Hermes 在后台持续巡检数据与流转风险。
+
+
+
10+
+
业务模块
+
覆盖总览、单据、预算、规则、数字员工、知识库、日志和设置。
+
+
+
8 字段
+
本体协议核心
+
domain、scenario、intent、entities、time_range、constraints、risk_signals、next_step。
+
+
+
4 类
+
画像与风控算法
+
申请人费用画像、员工行为画像、预算费用模型、规则/本体/RAG 分析。
+
+
+
+
+ +
+
+
01 / WHY
+

为什么要做智能费控平台

+

+ 智能费控不是把报销审批电子化,而是把企业每一笔支出变成可理解、可预测、可解释的经营信号。 + 当 AI、RAG、智能体、异常检测和预算联动进入财务流程后,费用平台会从“流程系统”升级为“经营控制系统”。 +

+
+ +
+

+ 第一,费用是企业经营动作最密集、最贴近一线的数据入口。销售拜访客户会产生差旅和招待, + 项目交付会产生交通、住宿、外包和办公支出,培训、采购、通信、会务也都以费用形式落地。 + 如果企业只在报销完成后做核销,就只能看到“钱已经花了”;如果在申请、预算占用、票据上传、 + 审批和归档全过程做智能分析,就能提前看到“这笔钱为什么要花、是否该花、花完后对预算和风险有什么影响”。 +

+

+ 第二,财务工作的重心正在从人工处理转向分析、预测和决策支持。Gartner 2025 年财务 AI 调研显示, + 财务组织最常见的 AI 用例已经包括知识管理、应付流程自动化、错误与异常检测;McKinsey 的 CFO 调研也显示, + 绝大多数受访者期待 AI 减少人工分析负担、生成洞察。这意味着费控平台必须具备自动识别、自动解释和自动沉淀的能力, + 否则它会停留在“录单工具”,无法进入未来财务的核心工作台。 +

+

+ 第三,费用风险越来越隐蔽。传统审批能挡住“缺附件”“超标准”这类显性问题, + 但很难发现拆单、跨部门合谋、异常频次、预算节奏异常、同组偏离、长期材料质量差等隐性问题。 + ACFE 的职业舞弊报告将虚构或夸大业务费用列为典型报销舞弊形态;随着 AI 生成票据、深度伪造和自动化攻击出现, + 企业更需要一个能把票据、人员、部门、历史行为、预算和制度放在一起分析的平台。 +

+
+ +
+
+ 59% + Gartner 2025 年调研显示,财务 AI 采用率已达到约六成,知识管理、应付自动化和异常检测是高频用例。 +
+
+ 85% + McKinsey CFO Pulse 提到,多数 CFO 期待 AI 生成洞察、减少手工分析,把财务人员释放到更高价值工作。 +
+
+ 25% + Salesforce 2025 年 CFO 调研显示,CFO 平均把约四分之一 AI 预算投向 AI Agent,数字劳动力进入预算层。 +
+
+ +
+
+ 1. 从事后核销,变成事前控制 +

+ 传统系统往往在报销单提交后才发现问题,智能费控可以在申请阶段就识别预算余量、 + 费用类型、地点、票据要求和制度约束。这样报销不是最后一关,而是从费用发生前就开始的控制链路。 +

+
+
+ 2. 从人找规则,变成规则找风险 +

+ 制度、Excel 规则、风险 JSON、知识库和历史记录被统一纳入规则中心与本体语义层, + 系统可以自动给出命中原因、制度依据和补正建议,减少审批人凭经验反复判断。 +

+
+
+ 3. 从部门口径,变成企业费用画像 +

+ 部门、岗位、职级、费用类型、预算池、审批质量和 AI 协作记录共同构成画像, + 让财务看到“为什么这个单据值得关注”,也让部门负责人知道费用结构是否偏离经营节奏。 +

+
+
+ 4. 从流程系统,变成智能运营中枢 +

+ Hermes 数字员工可以按计划巡检风险、生成报告、汇总异常、沉淀优化候选, + 让财务团队从重复检查转向规则治理、预算策略、风险决策和经营分析。 +

+
+
+ +
+
一句话定位
+

+ 智能费控平台不是“更漂亮的报销页面”,而是企业经营数据进入财务风控体系的语义入口, + 也是未来 AI 财务中台最容易落地、最容易证明价值的入口之一。 +

+
+
+ +
+
+
02 / COMPANY POSITION
+

费控在未来公司的地位

+

+ 未来公司的费控,会从“报销审批入口”变成“经营支出操作系统”。 + 它一端连接员工和部门的真实业务动作,另一端连接预算、现金流、成本、风险、制度和管理决策。 +

+
+ +
+

+ 从组织分工看,CFO 的角色正在从财务记录者变成业务伙伴和技术治理者。 + Deloitte 的未来财务洞察持续强调,财务团队会把更多时间投入分析、预测和决策支持; + Deloitte APAC CFO 2025 调研也显示,接近一半 CFO 认为生成式 AI 会在两年内显著改变行业、组织和财务职能。 + 这意味着费控不应再被放在“报销系统”这个狭窄位置,而应该成为 CFO 获取一线经营信号的前置雷达。 +

+

+ 从业务管理看,预算不再只是年初编制和月底复盘。未来预算需要跟每一次费用申请实时联动: + 当前预算池还有多少、审批后使用率是多少、是否触达预警线、该部门同类费用是否异常、是否影响项目毛利和现金节奏。 + 因此费控会成为部门经理、项目负责人和财务 BP 共同使用的经营协同平台。 +

+

+ 从风险控制看,未来公司的费用风险不是单张票据能解释清楚的,而是多源数据的组合问题。 + 一张票据可能合规,但一个人三个月内的频次、金额、地点、客户、同行人和预算占比可能异常。 + 智能费控要把这些信号拉成一张网,让管理者在支付之前就看到风险轮廓。 +

+
+ +
+
+
    +
  • 经营仪表盘:部门费用、项目消耗、预算占比、执行率、风险热区可以被实时汇总。
  • +
  • 预算守门员:费用申请不再只看“能不能报”,而要看“是否符合预算节奏”。
  • +
  • 内控前置层:票据、地点、人员、金额、制度条款和历史行为被联动审核。
  • +
  • AI 财务工作台:财务人员可以通过自然语言查询制度、解释风险、生成审批建议和费用报告。
  • +
  • 规则与知识工厂:制度解释、规则生成、风险复盘和人工反馈进入可持续优化闭环。
  • +
  • 管理决策入口:管理者可以询问“哪个部门预算风险最高”“哪些费用正在吞噬毛利”。
  • +
+
+
+
+
+
+
业务动作
+
出差、采购、招待、培训、项目交付
+
+
+
智能费控平台
+
预算、单据、制度、票据、风险、智能体
+
+
+
财务结果
+
成本、现金、核销、支付、账务沉淀
+
+
+
+
+
组织与预算
+
部门、项目、成本中心、预算池
+
+
+
数据与画像
+
费用画像、行为画像、同组基准
+
+
+
管理决策
+
预警、报告、经营洞察、规则优化
+
+
+
+
+ 费控位于经营动作和财务结果之间,未来承担预算守门、规则解释、风险识别、数据画像和管理洞察的中枢角色。 +
+
+
+
+ +
+
+
03 / BLUE OCEAN
+

智能分析费控平台的蓝海

+

+ 传统 ERP、OA、报销系统和财务共享平台已经覆盖了流程,但对“语义理解、自动洞察、隐性风险识别、 + 预算占比解释、部门费用经营分析”的覆盖仍然不足。蓝海空间正来自这个空白: + 市场正在从单点报销工具转向 AI 驱动的差旅、费用、发票、预算和风险一体化平台。 +

+
+ +
+

+ Grand View Research 2025 年报告预计,全球差旅与费用管理软件市场到 2030 年将达到 106.9 亿美元, + 2024 至 2030 年复合增速为 16.9%。更重要的是,它特别提到 AI 可用于审批与支付自动化、 + 员工差旅历史和支出模式分析、OCR 票据字段识别以及基于自然语言的交互。 + 这说明市场真正的增长点不只是“报销线上化”,而是“费用智能化”。 +

+
+ +
+
+ 非结构化数据智能化 +

票据、制度 PDF、Excel 规则、审批意见、用户自然语言都能进入同一个语义管道。

+
+
+ 部门费用经营化 +

费用从“报销明细”升级为“部门预算执行、费用结构、异常趋势、经营压力”的管理对象。

+
+
+ 风险识别前置化 +

拆单、超预算、重复票据、地点不一致、附件缺失、异常频次都可以更早被发现。

+
+
+ 财务智能体运营化 +

后台数字员工不再等待人点击,而是按周期巡检数据、生成报告和沉淀优化候选。

+
+
+ 规则治理产品化 +

自然语言规则、风险规则、Excel 规则和制度知识可以逐步变成可测试、可版本化资产。

+
+
+ 财务知识即时化 +

RAG 与知识库让制度解释从人工翻文档,转为“问一句,返回依据和建议”。

+
+
+
+ +
+
+
04 / PROJECT GOAL
+

开发这个平台的目的

+

+ 当前项目的目标是做一套可演示、可扩展、可继续工程化的智能费控底座。 + 它既要能承载真实报销、审批、预算和知识库流程,也要能承载智能体调用和后续自进化闭环。 +

+
+ +
+
+
01
+
+
让用户用自然语言完成费用流程
+
用户可以描述出差、招待、补件、审批问题,系统通过本体解析成结构化字段,再交给编排器和对应服务处理。
+
+
+
+
02
+
+
让财务看到预算和风险的真实原因
+
预算中心计算预算占用、剩余额度、审批后使用率;规则中心解释风险规则命中;画像算法给出同组基准。
+
+
+
+
03
+
+
让 Hermes 自动发现隐藏问题
+
后台数字员工读取单据、聚类上下文、识别拆单和异常频次等隐性风险,再形成报告和工单候选。
+
+
+
+
04
+
+
让知识、规则和反馈持续进化
+
人工反馈、OCR 修正、规则误报漏报、审批意见改写和知识问答反馈进入候选池,经人工审核后更新规则或知识。
+
+
+
+
+ +
+
+
05 / ALGORITHMS
+

相关算法与智能分析能力

+

+ 项目中已经沉淀了独立算法目录和多个业务模型。核心原则是: + 先有可解释公式,再进入服务;算法输出“依据 + 建议动作”,不直接替代人工审批。 +

+
+ +
+
+ 申请人费用画像 +

+ 根据近 90/180 天申请频次、金额占用、同组偏离、历史退回调减、本次申请偏离等指标, + 计算画像风险分和审批建议强度。 +

+ applicant_expense_profile.py / applicant_expense_profile_formula.md +
+
+ 员工行为画像 +

+ 覆盖费用支出画像、流程质量画像、AI 协作强度、审批行为画像, + 支持按照部门、岗位、职级和费用类型建立同组基准。 +

+ employee_behavior_profile.py / employee_behavior_profile_service.py +
+
+ 预算费用管控模型 +

+ 结合预算总额、已占用、已核销、本次申请金额、审批后使用率、预警线和超预算金额, + 输出 recommended、caution、review、block 等建议。 +

+ budget_expense_control.py / budget.py +
+
+ 风险规则与本体桥接 +

+ 风险信号会映射到具体规则编码,例如重复发票、地点不一致、附件不匹配、事由过短, + 使自然语言风险问题能落到可执行规则。 +

+ risk_ontology_bridge.py / risk_rule_template_executor.py +
+
+ +
profile_score = + frequency_score * 0.20 ++ amount_occupancy_score * 0.25 ++ peer_deviation_score * 0.25 ++ adjustment_history_score * 0.15 ++ current_claim_deviation_score * 0.15 + +budget_control = + claim_amount_ratio ++ after_usage_rate ++ over_budget_amount ++ missing_business_context ++ control_action
+
+ +
+
+
06 / MODULES
+

当前项目模块与核心能力

+

+ 前端提供业务操作入口,后端提供领域服务、智能体编排、本体解析、规则资产、知识检索和审计追踪。 + 其中最核心的是:本体语义层、Orchestrator、User Agent、Hermes、预算/单据服务、规则中心和画像算法。 +

+
+ +
+
+
前端应用层
+

+ 总览、个人工作台、单据中心、预算中心、规则中心、数字员工、员工管理、制度知识、日志管理、系统设置。 +

+
+ OverviewView + BudgetCenterView + AuditView + DigitalEmployeesView + DocumentsCenterView +
+
+ +
+
单据与报销服务
+

+ 支持申请单、报销单、附件、票据分析、审批、退回、归档、预算联动和 AI 结构化核对。 +

+
+ ExpenseClaimService + expense_claim_budget_flow + expense_claim_risk_review +
+
+ +
+
预算中心
+

+ 管理预算额度、部门/项目/成本中心维度、预算占用、核销、可用余额、预警线和超预算控制。 +

+
+ BudgetService + BudgetAllocation + BudgetReservation +
+
+ +
+
规则中心
+

+ 管理财务规则、风险规则、规则测试、规则等级、业务场景、费用类型范围和规则资产版本。 +

+
+ AgentAssetService + AgentFoundationRiskRuleMixin + risk-rules +
+
+ +
+
本体语义层
+

+ 把自然语言、页面动作、附件上下文和定时任务解析成统一协议,供 Agent 和工具调用使用。 +

+
+ SemanticOntologyService + ontology_detection + ontology_extraction + ontology_validation +
+
+ +
+
Agent 编排与追踪
+

+ Orchestrator 决定进入 User Agent 或 Hermes,记录本体解析、工具调用、降级结果和审计轨迹。 +

+
+ OrchestratorService + OrchestratorExecutionEngine + AgentRunService +
+
+ +
+
知识库与 RAG
+

+ 对制度、FAQ、Excel、PDF、DOCX 进行检索和结构化回答,支持本地文本块与 LightRAG 运行时融合。 +

+
+ KnowledgeRagService + knowledge_document_extractors + knowledge_ingest_log +
+
+ +
+
Hermes 数字员工
+

+ 后台执行风险扫描、费用报告、员工画像刷新、任务日志记录和风险报告沉淀。 +

+
+ HermesRiskScannerService + hermes_scheduler + HermesRiskReport +
+
+
+
+ +
+
+
07 / ARCHITECTURE
+

双智能体循环架构

+

+ 外层 User Agent 负责用户操作、问答、草稿和确认;内层 Hermes 负责后台巡检、隐性风险、任务报告和候选优化。 + 两者不争夺职责,而是共享本体、规则、知识、数据和审计。 +

+
+ +
+
+
+ 外层循环:User Agent 面向用户操作 + 处理用户自然语言、页面动作、草稿生成、审批解释和确认后的流程写入 +
+
+
+
用户输入
+
自然语言、页面按钮、附件上下文
+
+
+
本体解析
+
场景、意图、实体、缺槽位
+
+
+
Orchestrator
+
路由、权限、审计、降级
+
+
+
工具执行
+
DB、规则、RAG、OCR、预算
+
+
+
回答/草稿
+
解释、核对、确认、提交
+
+
+
+ 用户确认、补充字段或修改核对结果后,继续回到同一个会话上下文。 +
+
+ 共享中台 + 语义本体 · 规则资产 · 知识库 · 业务数据库 · AgentRun Trace · 权限审计 +
+
+ 内层循环:Hermes 数字员工监控核心数据与流转风险 + 面向系统事件和定时任务,负责长期巡检、隐性风险、报告与优化候选 +
+
+
+
定时触发
+
schedule、system event、手动触发
+
+
+
数据快照
+
预算、单据、员工、日志
+
+
+
风险分析
+
规则、LLM、画像、同组基准
+
+
+
报告/工单
+
risk_report、work_items、snapshot
+
+
+
反馈候选
+
规则、知识、OCR、Prompt
+
+
+
+ Hermes 只生成候选和报告;规则上线、知识发布、审批和付款仍需要权限、确认和人工审核。 +
+
+
+ 双循环的关键不是“两个聊天机器人”,而是把用户主动流程和后台系统巡检拆开, + 再通过统一语义协议、规则资产、业务数据和审计日志连接起来。 +
+
+
+ +
+
+
08 / ONTOLOGY
+

本体语义层的优势

+

+ 当前项目已经接入本体语义层。它的价值在于把“用户怎么说”和“系统怎么执行”隔开, + 让自然语言、页面动作、定时任务、规则中心和数据库查询都走同一套协议。 +

+
+ +
+
+
+ 减少关键词误判 +

+ 用户说“我今天去客户现场,招待客户”,不能因为出现“客户”就误路由到应收查询。 + 本体会综合场景、意图、缺槽位和上下文判断。 +

+
+
+ 让 Agent 调用可审计 +

+ 每次调用都能保留 scenario、intent、entities、risk_flags、next_step, + 便于回放、追责和优化。 +

+
+
+ 让规则与预算复用同一语义 +

+ 费用类型、部门、预算期间、预算科目、风险信号都可以被统一解析, + 减少前后端重复写规则。 +

+
+
+ +
+
+
+
+
自然语言 / 页面动作
+
用户提问、发起申请、编辑核对、上传附件
+
+
+
定时任务 / 系统事件
+
风险巡检、知识同步、MCP 健康检查
+
+
+
+ Semantic Ontology + domain · scenario · intent · entities · time_range · constraints · risk_signals · next_step +
+
+
+
数据库查询
+
预算、单据、员工、部门、应收应付
+
+
+
规则 / MCP
+
风险规则、票据校验、外部服务
+
+
+
知识 / RAG
+
制度条款、FAQ、历史解释、证据来源
+
+
+
+
+ 本体层不直接给最终答案,它输出可执行协议,让后续工具和 Agent 按相同语义工作。 +
+
+
+ +
{ + "domain": "reimbursement", + "scenario": "travel_reimbursement", + "intent": "risk_check", + "entities": ["employee", "department", "expense_type", "budget_period"], + "time_range": {}, + "constraints": {}, + "risk_signals": ["duplicate_invoice", "over_budget"], + "next_step": "run_rule" +}
+
+ +
+
+
09 / PRODUCT SCREENS
+

关键页面截图

+

+ 下列截图来自当前项目 `web/UI` 目录,用来展示平台已经覆盖的核心操作面: + 管理总览、预算、单据、智能助手、知识库、日志和设置。 +

+
+ + +
+ +
+
+
10 / EVOLUTION
+

建议演进路线

+

+ 当前项目已经具备智能费控雏形。后续重点不是堆更多页面,而是把本体、预算、画像、规则、 + Hermes 和反馈闭环做深,让平台从“能演示”走向“可运营”。 +

+
+ +
+
+
阶段一
+
+ 完成本体协议、单据中心、预算中心、规则中心、知识库和 Agent Run 追踪的稳定联动。 +
+
+
+
阶段二
+
+ 加强费用画像、部门预算占比、审批建议、风险解释和单据详情页的业务可读性。 +
+
+
+
阶段三
+
+ 将 Hermes 的风险扫描、费用报告、员工画像刷新和规则质量复盘变成可配置任务。 +
+
+
+
阶段四
+
+ 建立人工反馈池,让 OCR 修正、规则误报漏报、审批意见改写和知识问答反馈形成候选优化项。 +
+
+
+ +
+
关键原则
+

+ 自进化不等于自动改线上规则。Hermes 负责发现、聚类、建议和生成候选; + 最终规则、知识、审批和付款仍然必须经过权限、确认和人工审核。 +

+
+
+ +
+
+
11 / SOURCES
+

趋势依据与引用来源

+

+ 本文档中的市场趋势、CFO 角色变化、AI Agent 预算、异常检测和费控市场空间,参考了以下公开资料。 +

+
+ + +
+ +
+ 结论 +

+ X-Financial 的智能费控方向已经具备清晰技术骨架: + 本体语义层负责理解,Orchestrator 负责调度,User Agent 负责用户流程, + Hermes 负责后台内循环,预算、规则、画像和知识库负责提供可解释依据。 + 继续沿着这条线做深,平台会从“报销工具”成长为企业费用经营与财务风控的智能中台。 +

+
+
+ + +
+ + diff --git a/server/scripts/start_hermes_daemon.py b/server/scripts/start_hermes_daemon.py index 94e0cd1..5ce4925 100644 --- a/server/scripts/start_hermes_daemon.py +++ b/server/scripts/start_hermes_daemon.py @@ -37,7 +37,17 @@ def init_default_config(): cron_expression="0 9 * * 1", # 每周一早9点(在简化版中暂时代表周报频率) is_enabled=True )) - + + # 初始化 employee_behavior_profile_scan:默认关闭,避免全员画像过频。 + existing_profile = db.query(HermesTaskConfig).filter_by(task_type="employee_behavior_profile_scan").first() + if not existing_profile: + logger.info("No employee_behavior_profile_scan config found. Initializing default config.") + db.add(HermesTaskConfig( + task_type="employee_behavior_profile_scan", + cron_expression="0 8 * * 1", + is_enabled=False + )) + db.commit() except Exception as e: logger.error(f"Failed to initialize default config: {e}") diff --git a/server/src/app/algorithem/__init__.py b/server/src/app/algorithem/__init__.py index 4f60fcc..8e8a997 100644 --- a/server/src/app/algorithem/__init__.py +++ b/server/src/app/algorithem/__init__.py @@ -5,9 +5,34 @@ from .applicant_expense_profile import ( ApplicantExpenseProfileResult, evaluate_applicant_expense_profile, ) +from .employee_behavior_profile import ( + ALGORITHM_VERSION as EMPLOYEE_BEHAVIOR_PROFILE_ALGORITHM_VERSION, + ProfileComponent, + ProfileScoreResult, + build_review_suggestions, + calculate_review_priority_score, + evaluate_weighted_profile, + level_from_score as employee_profile_level_from_score, + normalize_by_peer_percentiles, + percentile, + score_by_bands, +) +from .employee_behavior_profile_tags import build_profile_radar, build_profile_tags __all__ = [ "ApplicantExpenseProfileInput", "ApplicantExpenseProfileResult", + "EMPLOYEE_BEHAVIOR_PROFILE_ALGORITHM_VERSION", + "ProfileComponent", + "ProfileScoreResult", + "build_review_suggestions", + "build_profile_radar", + "build_profile_tags", + "calculate_review_priority_score", "evaluate_applicant_expense_profile", + "evaluate_weighted_profile", + "employee_profile_level_from_score", + "normalize_by_peer_percentiles", + "percentile", + "score_by_bands", ] diff --git a/server/src/app/algorithem/employee_behavior_profile.py b/server/src/app/algorithem/employee_behavior_profile.py new file mode 100644 index 0000000..d4b1304 --- /dev/null +++ b/server/src/app/algorithem/employee_behavior_profile.py @@ -0,0 +1,345 @@ +"""Employee behavior profile scoring algorithms. + +This module is deliberately pure: database services prepare metrics, while +the formula layer owns normalization, score composition, levels, and advice. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Decimal, InvalidOperation +from typing import Any + +ALGORITHM_VERSION = "employee_behavior_profile.v1" + +LEVEL_NORMAL = "normal" +LEVEL_WATCH = "watch" +LEVEL_REVIEW = "review" +LEVEL_ESCALATION = "escalation" + +PROFILE_LABELS = { + "expense": "费用支出画像", + "process_quality": "流程质量画像", + "ai_usage": "AI 协作强度", + "approval": "审批行为画像", +} + +LEVEL_LABELS = { + LEVEL_NORMAL: "正常", + LEVEL_WATCH: "关注", + LEVEL_REVIEW: "复核", + LEVEL_ESCALATION: "升级关注", +} + +ZERO = Decimal("0") +ONE = Decimal("1") +HUNDRED = Decimal("100") + + +@dataclass(slots=True) +class ProfileComponent: + code: str + label: str + score: int + value: Any = None + unit: str = "" + weight: Decimal = Decimal("0") + detail: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "label": self.label, + "score": _clamp_score(self.score), + "value": _format_value(self.value), + "unit": self.unit, + "weight": _format_decimal(self.weight), + "detail": self.detail, + } + + +@dataclass(slots=True) +class ProfileScoreResult: + profile_type: str + profile_score: int + profile_level: str + components: list[ProfileComponent] = field(default_factory=list) + metrics: dict[str, Any] = field(default_factory=dict) + + @property + def profile_label(self) -> str: + return PROFILE_LABELS.get(self.profile_type, self.profile_type) + + @property + def profile_level_label(self) -> str: + return LEVEL_LABELS.get(self.profile_level, self.profile_level) + + def top_contributors(self, limit: int = 5) -> list[dict[str, Any]]: + ranked = sorted( + self.components, + key=lambda item: (Decimal(_clamp_score(item.score)) * item.weight, item.score), + reverse=True, + ) + return [item.as_dict() for item in ranked[: max(0, limit)] if item.score > 0] + + def as_dict(self) -> dict[str, Any]: + return { + "profile_type": self.profile_type, + "profile_label": self.profile_label, + "profile_score": self.profile_score, + "profile_level": self.profile_level, + "profile_level_label": self.profile_level_label, + "components": [item.as_dict() for item in self.components], + "top_contributors": self.top_contributors(), + "metrics": _json_safe(self.metrics), + } + + +def normalize_by_peer_percentiles(value: Any, p50: Any, p90: Any) -> int: + """Map a metric to 0-100 with peer P50 as zero and peer P90 as full score.""" + + current = _to_decimal(value) + median = _to_decimal(p50) + high = _to_decimal(p90) + if current <= median or high <= median: + return 0 + raw_score = HUNDRED * (current - median) / (high - median) + return _clamp_score(raw_score) + + +def score_by_bands(value: Any, bands: list[tuple[Any, int]]) -> int: + """Piecewise linear score where each tuple is a threshold and score.""" + + normalized = _to_decimal(value) + if not bands: + return 0 + + points = [(_to_decimal(threshold), _clamp_score(score)) for threshold, score in bands] + points.sort(key=lambda item: item[0]) + + if normalized <= points[0][0]: + return points[0][1] + + for index in range(1, len(points)): + previous_threshold, previous_score = points[index - 1] + next_threshold, next_score = points[index] + if normalized > next_threshold: + continue + if next_threshold == previous_threshold: + return next_score + ratio = (normalized - previous_threshold) / (next_threshold - previous_threshold) + interpolated = Decimal(previous_score) + ratio * Decimal(next_score - previous_score) + return _clamp_score(interpolated) + + return points[-1][1] + + +def evaluate_weighted_profile( + profile_type: str, + components: list[ProfileComponent], + metrics: dict[str, Any] | None = None, +) -> ProfileScoreResult: + total_weight = sum((_to_decimal(item.weight) for item in components), ZERO) + if total_weight <= ZERO: + profile_score = max((_clamp_score(item.score) for item in components), default=0) + else: + weighted = ( + sum(Decimal(_clamp_score(item.score)) * _to_decimal(item.weight) for item in components) + / total_weight + ) + profile_score = _clamp_score(weighted) + + return ProfileScoreResult( + profile_type=profile_type, + profile_score=profile_score, + profile_level=level_from_score(profile_score), + components=components, + metrics=metrics or {}, + ) + + +def calculate_review_priority_score( + *, + expense_profile_score: Any, + process_quality_score: Any, +) -> int: + weighted = _to_decimal(expense_profile_score) * Decimal("0.70") + _to_decimal( + process_quality_score + ) * Decimal("0.30") + return _clamp_score(weighted) + + +def build_review_suggestions( + *, + expense_profile_score: Any, + process_quality_score: Any, + requested_days: Any = None, + peer_days_p75: Any = None, + policy_limit: Any = None, + peer_unit_amount_p75: Any = None, +) -> list[dict[str, Any]]: + review_score = calculate_review_priority_score( + expense_profile_score=expense_profile_score, + process_quality_score=process_quality_score, + ) + level = level_from_score(review_score) + suggestions: list[dict[str, Any]] = [] + + if _to_decimal(requested_days) > ZERO and _to_decimal(peer_days_p75) > ZERO: + suggested_days = min( + _to_decimal(requested_days), + _to_decimal(peer_days_p75) * _level_factor(level), + ) + if suggested_days < _to_decimal(requested_days): + suggestions.append( + { + "type": "review_travel_days", + "severity": _severity_from_level(level), + "message": "建议复核出差天数和业务必要性。", + "recommended_upper": _format_decimal(suggested_days), + "unit": "天", + } + ) + + unit_amount_upper = _resolve_entertainment_unit_upper( + level=level, + policy_limit=policy_limit, + peer_unit_amount_p75=peer_unit_amount_p75, + ) + if unit_amount_upper is not None: + suggestions.append( + { + "type": "review_entertainment_unit_amount", + "severity": _severity_from_level(level), + "message": "建议复核业务招待人均金额和客户招待必要性。", + "recommended_upper": _format_decimal(unit_amount_upper), + "unit": "元/人", + } + ) + + if expense_profile_score and _to_decimal(expense_profile_score) >= Decimal("60"): + suggestions.append( + { + "type": "review_expense_pattern", + "severity": _severity_from_level(level), + "message": "申请人近期费用节奏高于同组基准,建议核对费用标准和预算占用。", + } + ) + + if process_quality_score and _to_decimal(process_quality_score) >= Decimal("60"): + suggestions.append( + { + "type": "review_material_quality", + "severity": "medium", + "message": "申请人近期材料质量波动较高,建议重点核对附件、事由和票据一致性。", + } + ) + + return suggestions + + +def level_from_score(score: Any) -> str: + normalized = _clamp_score(score) + if normalized >= 80: + return LEVEL_ESCALATION + if normalized >= 60: + return LEVEL_REVIEW + if normalized >= 40: + return LEVEL_WATCH + return LEVEL_NORMAL + + +def percentile(values: list[Any], percent: Any) -> Decimal: + normalized_values = sorted(_to_decimal(item) for item in values if _to_decimal(item) >= ZERO) + if not normalized_values: + return ZERO + if len(normalized_values) == 1: + return normalized_values[0] + + pct = max(ZERO, min(HUNDRED, _to_decimal(percent))) + position = (Decimal(len(normalized_values) - 1) * pct) / HUNDRED + lower_index = int(position.to_integral_value(rounding=ROUND_FLOOR)) + upper_index = int(position.to_integral_value(rounding=ROUND_CEILING)) + if lower_index == upper_index: + return normalized_values[lower_index] + + fraction = position - Decimal(lower_index) + return ( + normalized_values[lower_index] + + (normalized_values[upper_index] - normalized_values[lower_index]) * fraction + ) + + +def _resolve_entertainment_unit_upper( + *, + level: str, + policy_limit: Any, + peer_unit_amount_p75: Any, +) -> Decimal | None: + policy = _to_decimal(policy_limit) + peer = _to_decimal(peer_unit_amount_p75) + candidates = [item for item in (policy, peer * _level_factor(level)) if item > ZERO] + if not candidates: + return None + return min(candidates) + + +def _level_factor(level: str) -> Decimal: + if level == LEVEL_ESCALATION: + return Decimal("0.90") + if level == LEVEL_REVIEW: + return Decimal("1.00") + if level == LEVEL_WATCH: + return Decimal("1.10") + return Decimal("1.20") + + +def _severity_from_level(level: str) -> str: + if level == LEVEL_ESCALATION: + return "high" + if level == LEVEL_REVIEW: + return "medium" + return "low" + + +def _clamp_score(value: Any) -> int: + try: + normalized = _to_decimal(value) + except InvalidOperation: + return 0 + bounded = max(ZERO, min(HUNDRED, normalized)) + return int(bounded.quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + +def _to_decimal(value: Any) -> Decimal: + if value is None: + return ZERO + if isinstance(value, Decimal): + return value + if isinstance(value, bool): + return ONE if value else ZERO + try: + return Decimal(str(value).strip() or "0") + except (InvalidOperation, ValueError): + return ZERO + + +def _format_decimal(value: Any) -> str | None: + if value is None: + return None + decimal_value = _to_decimal(value) + return str(decimal_value.quantize(Decimal("0.0001")).normalize()) + + +def _format_value(value: Any) -> Any: + if isinstance(value, Decimal): + return _format_decimal(value) + if isinstance(value, dict): + return {key: _format_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_format_value(item) for item in value] + return value + + +def _json_safe(value: Any) -> Any: + return _format_value(value) diff --git a/server/src/app/algorithem/employee_behavior_profile_tag_rules.py b/server/src/app/algorithem/employee_behavior_profile_tag_rules.py new file mode 100644 index 0000000..fe006a4 --- /dev/null +++ b/server/src/app/algorithem/employee_behavior_profile_tag_rules.py @@ -0,0 +1,812 @@ +"""Rule definitions for employee behavior profile tags.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +PROFILE_TAG_ALGORITHM_VERSION = "employee_behavior_profile_tags.v1" + + +def append_expense_tags(tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]) -> None: + expense = index.get("expense") + process = index.get("process_quality") + if not expense: + return + metrics = metrics_of(expense) + amount_share = number(metrics.get("amount_share")) + amount_total = number(metrics.get("amount_total")) + claim_count = number(metrics.get("claim_count")) + current_amount = number(metrics.get("current_claim_amount")) + return_count = number(metrics_of(process).get("return_count")) if process else 0 + + add_tag( + tags, + "expense_king", + "费用之王", + "费用集中度高", + "expense", + "risk", + max( + component(index, "expense", "amount_occupancy_score") / 100, + band(amount_share, 0.15, 0.45), + ), + ( + f"近{int(metrics.get('window_days') or 90)}天费用占比达到" + f"{percent(amount_share)},费用总额为{money(amount_total)}。" + ), + [ + evidence("amount_share", amount_share, threshold=0.30, unit="比例"), + evidence("amount_total", amount_total, unit="元"), + ], + ["expense_intensity"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "high_frequency_applicant", + "高频申请人", + "申请频次高", + "expense", + "behavior", + max(component(index, "expense", "frequency_score") / 100, band(claim_count, 3, 8)), + f"窗口期内累计提交{int(claim_count)}笔费用申请。", + [evidence("claim_count", claim_count, threshold=3, unit="次")], + ["application_rhythm"], + data_quality=data_quality(metrics), + ) + avg_amount = amount_total / claim_count if claim_count > 0 else 0 + add_tag( + tags, + "micro_high_frequency", + "小额高频", + "小额高频", + "expense", + "behavior", + min(band(claim_count, 3, 8), band(3000 - avg_amount, 0, 2500)), + f"窗口期内申请{int(claim_count)}笔,单笔均额约{money(avg_amount)}。", + [evidence("avg_amount", avg_amount, threshold=3000, unit="元")], + ["application_rhythm"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "large_amount_deviation", + "大额偏离者", + "当前金额偏高", + "expense", + "risk", + max( + component(index, "expense", "current_claim_deviation_score") / 100, + component(index, "expense", "peer_deviation_score") / 100, + band(current_amount, 3000, 10000), + ), + f"当前单据金额{money(current_amount)},已形成明显金额偏离。", + [evidence("current_claim_amount", current_amount, unit="元")], + ["expense_intensity"], + data_quality=data_quality(metrics), + ) + add_tag_from_metric( + tags, + metrics, + "budget_sprint", + "预算冲刺型", + "近期费用集中", + "expense", + "risk", + "amount_30_to_90_ratio", + 0.55, + 0.85, + ["expense_intensity"], + ) + if amount_total > 0 and claim_count >= 1 and return_count == 0: + add_tag( + tags, + "cost_controlled", + "成本克制型", + "成本克制", + "expense", + "positive", + min(band(60 - score_of(expense), 0, 50), 1), + "窗口期内费用画像较低且没有退单记录。", + [evidence("profile_score", score_of(expense), threshold=40, unit="分")], + ["expense_intensity"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "adjustment_frequent", + "调减高发", + "历史调减较多", + "expense", + "risk", + max( + component(index, "expense", "adjustment_history_score") / 100, + band(return_count, 1, 4), + ), + f"窗口期内退回或调减相关记录约{int(return_count)}次。", + [evidence("return_count", return_count, threshold=2, unit="次")], + ["process_pressure"], + data_quality=data_quality(metrics), + ) + add_tag_from_metric( + tags, + metrics, + "expense_type_wide", + "费用类型跨度大", + "费用类型分散", + "expense", + "behavior", + "expense_type_entropy", + 0.60, + 1.00, + ["application_rhythm"], + ) + + +def append_travel_entertainment_tags( + tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]] +) -> None: + expense = index.get("expense") + if not expense: + return + metrics = metrics_of(expense) + scope = str(metrics.get("expense_type_scope") or "") + requested_days = number(metrics.get("requested_days")) + peer_days_p75 = number(metrics.get("peer_days_p75")) + amount_total = number(metrics.get("amount_total")) + claim_count = number(metrics.get("claim_count")) + + if peer_days_p75 > 0: + add_tag( + tags, + "long_trip_master", + "长差达人", + "出差天数偏长", + "travel", + "risk", + band(requested_days / peer_days_p75, 1.2, 1.8), + f"当前出差天数为{format_number(requested_days)}天,同组P75约{format_number(peer_days_p75)}天。", + [ + evidence("requested_days", requested_days, unit="天"), + evidence("peer_days_p75", peer_days_p75, unit="天"), + ], + ["travel_entertainment"], + data_quality=data_quality(metrics), + ) + if scope in {"travel", "overall"}: + add_tag( + tags, + "travel_frequent", + "出差高频客", + "出差频次高", + "travel", + "behavior", + max(component(index, "expense", "frequency_score") / 100, band(claim_count, 3, 8)), + f"窗口期内差旅相关申请{int(claim_count)}笔。", + [evidence("travel_claim_count", claim_count, threshold=3, unit="次")], + ["travel_entertainment"], + data_quality=data_quality(metrics), + ) + daily_amount = amount_total / requested_days if requested_days > 0 else 0 + add_tag( + tags, + "travel_daily_high", + "差旅日均偏高", + "差旅日均偏高", + "travel", + "risk", + min( + component(index, "expense", "peer_deviation_score") / 100, + band(daily_amount, 1000, 3000), + ), + f"差旅日均金额约{money(daily_amount)}。", + [evidence("travel_daily_amount", daily_amount, unit="元/天")], + ["travel_entertainment"], + data_quality=data_quality(metrics), + ) + add_tag_from_metric( + tags, + metrics, + "hotel_high_standard", + "住宿标准偏高", + "住宿单价偏高", + "travel", + "risk", + "hotel_nightly_amount", + number(metrics.get("peer_hotel_nightly_p75")), + number(metrics.get("peer_hotel_nightly_p90")), + ["travel_entertainment"], + ) + add_tag_from_metric( + tags, + metrics, + "transport_high_cost", + "交通成本偏高", + "交通成本偏高", + "travel", + "risk", + "transport_daily_amount", + number(metrics.get("peer_transport_daily_p75")), + number(metrics.get("peer_transport_daily_p90")), + ["travel_entertainment"], + ) + if scope in {"entertainment", "meal", "overall"}: + add_tag( + tags, + "entertainment_active", + "招待活跃户", + "招待频次高", + "entertainment", + "behavior", + max(component(index, "expense", "frequency_score") / 100, band(claim_count, 2, 6)), + f"窗口期内招待相关申请{int(claim_count)}笔。", + [evidence("entertainment_count", claim_count, threshold=2, unit="次")], + ["travel_entertainment"], + data_quality=data_quality(metrics), + ) + unit_amount = number(metrics.get("entertainment_unit_amount")) + peer_unit_p75 = number(metrics.get("peer_unit_amount_p75")) + if unit_amount > 0 or peer_unit_p75 > 0: + add_tag( + tags, + "entertainment_unit_high", + "人均招待偏高", + "人均招待偏高", + "entertainment", + "risk", + band(unit_amount / peer_unit_p75, 1.0, 1.6) if peer_unit_p75 > 0 else 0, + f"招待人均金额约{money(unit_amount)},同组P75约{money(peer_unit_p75)}。", + [ + evidence("entertainment_unit_amount", unit_amount, unit="元/人"), + evidence("peer_unit_amount_p75", peer_unit_p75, unit="元/人"), + ], + ["travel_entertainment"], + data_quality=data_quality(metrics), + ) + add_tag_from_metric( + tags, + metrics, + "repeat_client_host", + "重复客户招待高", + "同客户招待集中", + "entertainment", + "behavior", + "max_client_entertainment_count", + 3, + 6, + ["travel_entertainment"], + ) + add_tag_from_metric( + tags, + metrics, + "holiday_expense_active", + "节假日费用活跃", + "节假日费用活跃", + "expense", + "behavior", + "holiday_claim_ratio", + 0.25, + 0.60, + ["application_rhythm"], + ) + + +def append_process_tags(tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]) -> None: + process = index.get("process_quality") + if not process: + return + metrics = metrics_of(process) + return_count = number(metrics.get("return_count")) + missing_attachment = number(metrics.get("missing_attachment_count")) + mismatch_count = number(metrics.get("invoice_mismatch_count")) + missing_context = number(metrics.get("missing_business_context_count")) + + add_tag( + tags, + "return_frequent", + "退单常客", + "退单频次高", + "process", + "risk", + max( + component(index, "process_quality", "return_count_score") / 100, + band(return_count, 1, 4), + ), + f"窗口期内退单或退回相关记录约{int(return_count)}次。", + [evidence("return_count", return_count, threshold=2, unit="次")], + ["process_pressure"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "material_patch", + "材料补丁户", + "材料补充较多", + "process", + "risk", + max( + component(index, "process_quality", "missing_attachment_score") / 100, + band(missing_attachment + missing_context, 2, 5), + ), + f"附件和业务上下文缺失累计{int(missing_attachment + missing_context)}项。", + [ + evidence( + "missing_material_count", + missing_attachment + missing_context, + threshold=3, + unit="项", + ) + ], + ["material_completeness"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "invoice_unstable", + "票据不稳", + "票据一致性弱", + "process", + "risk", + max( + component(index, "process_quality", "invoice_mismatch_score") / 100, + band(mismatch_count, 1, 3), + ), + f"票据或明细金额不一致记录{int(mismatch_count)}次。", + [evidence("invoice_mismatch_count", mismatch_count, threshold=1, unit="次")], + ["material_completeness"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "reason_thin", + "事由空心化", + "事由说明偏弱", + "process", + "risk", + max( + component(index, "process_quality", "missing_business_context_score") / 100, + band(missing_context, 2, 5), + ), + f"业务事由、地点或项目等上下文缺失{int(missing_context)}项。", + [evidence("missing_business_context_count", missing_context, threshold=3, unit="项")], + ["material_completeness"], + data_quality=data_quality(metrics), + ) + add_tag_from_metric( + tags, + metrics, + "resubmit_slow", + "补充材料慢", + "补充响应偏慢", + "process", + "risk", + "avg_resubmit_hours", + number(metrics.get("peer_resubmit_hours_p75")), + number(metrics.get("peer_resubmit_hours_p90")), + ["process_pressure"], + ) + add_tag_from_metric( + tags, + metrics, + "repeat_issue", + "重复问题未改善", + "同类问题反复", + "process", + "risk", + "same_issue_repeat_count", + 2, + 4, + ["process_pressure"], + ) + if ( + score_of(process) < 40 + and return_count == 0 + and missing_attachment == 0 + and mismatch_count == 0 + ): + add_tag( + tags, + "clean_first_pass", + "材料清爽", + "一次通过质量好", + "process", + "positive", + band(40 - score_of(process), 0, 40), + "窗口期内未发现退单、附件缺失或票据金额不一致。", + [evidence("process_quality_score", score_of(process), threshold=40, unit="分")], + ["material_completeness"], + data_quality=data_quality(metrics), + ) + add_tag_from_metric( + tags, + metrics, + "large_return_amount", + "高额退回", + "退回金额偏高", + "process", + "risk", + "returned_amount_ratio", + 0.20, + 0.50, + ["process_pressure"], + ) + + +def append_ai_tags(tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]) -> None: + ai_profile = index.get("ai_usage") + process = index.get("process_quality") + if not ai_profile: + return + metrics = metrics_of(ai_profile) + ai_runs = number(metrics.get("ai_run_count")) + estimated_tokens = number(metrics.get("estimated_token_count")) + exact_tokens = number(metrics.get("exact_token_count")) + token_count = exact_tokens or estimated_tokens + failed_calls = number(metrics.get("failed_tool_call_count")) + tool_calls = max(number(metrics.get("tool_call_count")), 1) + process_score = score_of(process) + + add_tag( + tags, + "ai_heavy", + "AI 重度用户", + "AI 使用频繁", + "ai", + "behavior", + max(component(index, "ai_usage", "ai_call_count_score") / 100, band(ai_runs, 3, 20)), + f"窗口期内 AI 调用{int(ai_runs)}次。", + [evidence("ai_run_count", ai_runs, threshold=10, unit="次")], + ["ai_collaboration"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "token_high", + "Token 高耗用户", + "Token 消耗较高", + "ai", + "behavior", + max(component(index, "ai_usage", "token_cost_score") / 100, band(token_count, 8000, 20000)), + ( + f"窗口期内 Token 口径为{metrics.get('token_count_mode') or 'unknown'}," + f"数量约{int(token_count)}。" + ), + [evidence("token_count", token_count, threshold=8000, unit="tokens")], + ["ai_collaboration"], + data_quality=0.75 if estimated_tokens and not exact_tokens else data_quality(metrics), + ) + if ai_runs >= 3: + add_tag( + tags, + "ai_effective", + "AI 高效协作者", + "AI 协作有效", + "ai", + "positive", + min(band(ai_runs, 3, 12), band(60 - process_score, 0, 40)), + "AI 使用较活跃,且流程质量画像保持较低关注。", + [evidence("process_quality_score", process_score, threshold=40, unit="分")], + ["ai_collaboration"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "ai_dependency_unimproved", + "AI 依赖未改善", + "AI 使用高但质量未改善", + "ai", + "risk", + min(band(ai_runs, 3, 12), band(process_score, 60, 100)), + "AI 使用较活跃,但流程质量画像仍然偏高。", + [evidence("process_quality_score", process_score, threshold=60, unit="分")], + ["ai_collaboration"], + data_quality=data_quality(metrics), + ) + add_tag( + tags, + "ai_failure_cluster", + "AI 调用失败集中", + "AI 调用失败偏多", + "ai", + "risk", + max( + component(index, "ai_usage", "failed_ai_call_score") / 100, + band(failed_calls / tool_calls, 0.20, 0.60), + ), + f"工具调用失败{int(failed_calls)}次,失败率约{percent(failed_calls / tool_calls)}。", + [evidence("failed_tool_call_rate", failed_calls / tool_calls, threshold=0.20, unit="比例")], + ["ai_collaboration"], + data_quality=data_quality(metrics), + ) + add_tag_from_metric( + tags, + metrics, + "ai_override_frequent", + "AI 建议常被覆盖", + "AI 建议覆盖较多", + "ai", + "behavior", + "ai_override_rate", + 0.40, + 0.80, + ["ai_collaboration"], + ) + + +def append_approval_tags(tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]) -> None: + approval = index.get("approval") + if not approval: + return + metrics = metrics_of(approval) + record_count = number(metrics.get("approval_record_count")) + direct_ratio = number(metrics.get("direct_approve_ratio")) + return_count = number(metrics.get("return_count")) + return_rate = return_count / record_count if record_count else 0 + + add_tag_from_metric( + tags, + metrics, + "speed_reviewer", + "急速审核员", + "快速审核型", + "approval", + "behavior", + "review_duration_speed_score", + 0.60, + 1.00, + ["approval_efficiency"], + reason_prefix="平均审核时长处于较快区间", + ) + add_tag( + tags, + "cautious_reviewer", + "谨慎审核员", + "谨慎审核型", + "approval", + "behavior", + max( + band(return_rate, 0.20, 0.60), + component(index, "approval", "system_advice_override_score") / 100, + ), + f"审批退回率约{percent(return_rate)}。", + [evidence("return_rate", return_rate, threshold=0.20, unit="比例")], + ["approval_control"], + data_quality=data_quality(metrics), + ) + add_tag_from_metric( + tags, + metrics, + "gatekeeper", + "退回把关型", + "退回把关强", + "approval", + "behavior", + "high_risk_return_rate", + 0.30, + 0.70, + ["approval_control"], + ) + add_tag_from_metric( + tags, + metrics, + "high_risk_fast_pass", + "高风险快通过", + "高风险快通过", + "approval", + "risk", + "high_risk_fast_pass_count", + 1, + 3, + ["approval_efficiency"], + ) + add_tag_from_metric( + tags, + metrics, + "sla_delayer", + "SLA 拖延型", + "审批超时偏多", + "approval", + "risk", + "sla_overdue_rate", + 0.25, + 0.60, + ["approval_efficiency"], + ) + if record_count >= 3 and 0.25 <= return_rate <= 0.75 and direct_ratio < 0.90: + add_tag( + tags, + "steady_reviewer", + "稳健审核员", + "稳健审核型", + "approval", + "positive", + 0.80, + "审批通过和退回节奏相对均衡,未发现高风险快通过记录。", + [evidence("approval_record_count", record_count, threshold=3, unit="次")], + ["approval_control"], + data_quality=data_quality(metrics), + ) + + +def add_tag_from_metric( + tags: list[dict[str, Any]], + metrics: Mapping[str, Any], + code: str, + label: str, + display_label: str, + category: str, + polarity: str, + metric_key: str, + low: float, + high: float, + radar_dimensions: list[str], + *, + reason_prefix: str | None = None, +) -> None: + value = number(metrics.get(metric_key)) + if value <= 0 or high <= low: + return + strength = band(value, low, high) + add_tag( + tags, + code, + label, + display_label, + category, + polarity, + strength, + f"{reason_prefix or display_label},{metric_key}={format_number(value)}。", + [evidence(metric_key, value, threshold=low)], + radar_dimensions, + data_quality=data_quality(metrics), + ) + + +def add_tag( + tags: list[dict[str, Any]], + code: str, + label: str, + display_label: str, + category: str, + polarity: str, + strength: float, + reason: str, + evidence_items: list[dict[str, Any]], + radar_dimensions: list[str], + *, + consistency: float = 0.75, + recency: float = 0.85, + data_quality: float = 0.85, + sample_reliability: float = 0.75, +) -> None: + normalized_strength = clamp01(strength) + if normalized_strength <= 0: + return + tag_score = clamp_score( + 100 * (0.55 * normalized_strength + 0.25 * consistency + 0.20 * recency) + ) + confidence = clamp01( + data_quality * (0.65 * normalized_strength + 0.20 * sample_reliability + 0.15 * consistency) + ) + tags.append( + { + "code": code, + "label": label, + "display_label": display_label, + "category": category, + "polarity": polarity, + "score": tag_score, + "confidence": round(confidence, 2), + "reason": reason, + "evidence": [item for item in evidence_items if item], + "radar_dimensions": radar_dimensions, + "algorithm_version": PROFILE_TAG_ALGORITHM_VERSION, + } + ) + + +def profile_index( + profiles: list[Mapping[str, Any]] | tuple[Mapping[str, Any], ...], +) -> dict[str, Mapping[str, Any]]: + return { + str(profile.get("profile_type") or ""): profile + for profile in profiles + if str(profile.get("profile_type") or "") + } + + +def metrics_of(profile: Mapping[str, Any] | None) -> Mapping[str, Any]: + if not profile: + return {} + value = profile.get("metrics") + return value if isinstance(value, Mapping) else {} + + +def score_of(profile: Mapping[str, Any] | None) -> int: + return clamp_score(number(profile.get("score") if profile else 0)) + + +def component(index: dict[str, Mapping[str, Any]], profile_type: str, code: str) -> int: + profile = index.get(profile_type) + if not profile: + return 0 + for item in profile.get("top_contributors") or []: + if isinstance(item, Mapping) and item.get("code") == code: + return clamp_score(number(item.get("score"))) + return 0 + + +def tag_score(tags: list[Mapping[str, Any]], code: str, *, invert: bool = False) -> int: + score = max((int(tag.get("score") or 0) for tag in tags if tag.get("code") == code), default=0) + return 100 - score if invert and score > 0 else score + + +def data_quality(metrics: Mapping[str, Any]) -> float: + sample_size = number(metrics.get("peer_sample_size")) + sample_score = 0.60 if sample_size <= 0 else min(1, max(0.65, sample_size / 10)) + fallback_level = number(metrics.get("peer_group_fallback_level")) + fallback_penalty = min(0.20, fallback_level * 0.05) + return clamp01(sample_score - fallback_penalty) + + +def scene_priority(tag: Mapping[str, Any], scene: str) -> int: + if scene != "approval": + return 1 + category = str(tag.get("category") or "") + return 2 if category in {"expense", "travel", "entertainment", "process"} else 0 + + +def evidence( + metric: str, + value: Any, + *, + threshold: Any | None = None, + unit: str = "", +) -> dict[str, Any]: + result: dict[str, Any] = { + "metric": metric, + "value": format_number(number(value)), + } + if threshold is not None: + result["threshold"] = format_number(number(threshold)) + if unit: + result["unit"] = unit + return result + + +def band(value: Any, low: Any, high: Any) -> float: + normalized = number(value) + low_value = number(low) + high_value = number(high) + if high_value <= low_value: + return 0 + return clamp01((normalized - low_value) / (high_value - low_value)) + + +def number(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0 + + +def clamp01(value: Any) -> float: + return max(0, min(1, number(value))) + + +def clamp_score(value: Any) -> int: + return max(0, min(100, int(round(number(value))))) + + +def percent(value: Any) -> str: + return f"{round(number(value) * 100)}%" + + +def money(value: Any) -> str: + return f"{round(number(value), 2):g}元" + + +def format_number(value: Any) -> str: + normalized = number(value) + return f"{normalized:.4f}".rstrip("0").rstrip(".") diff --git a/server/src/app/algorithem/employee_behavior_profile_tags.py b/server/src/app/algorithem/employee_behavior_profile_tags.py new file mode 100644 index 0000000..ec043a1 --- /dev/null +++ b/server/src/app/algorithem/employee_behavior_profile_tags.py @@ -0,0 +1,209 @@ +"""Employee behavior profile tags and radar scoring.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +from app.algorithem.employee_behavior_profile import LEVEL_LABELS, level_from_score +from app.algorithem.employee_behavior_profile_tag_rules import ( + PROFILE_TAG_ALGORITHM_VERSION, + append_ai_tags, + append_approval_tags, + append_expense_tags, + append_process_tags, + append_travel_entertainment_tags, + clamp_score, + component, + number, + profile_index, + scene_priority, + tag_score, +) + +APPROVAL_RADAR_CODES = { + "expense_intensity", + "application_rhythm", + "travel_entertainment", + "material_completeness", + "process_pressure", +} + +RADAR_LABELS = { + "expense_intensity": "费用强度", + "application_rhythm": "申请节奏", + "travel_entertainment": "差旅招待", + "material_completeness": "材料完整度压力", + "process_pressure": "流程压力", + "ai_collaboration": "AI 协作强度", + "approval_efficiency": "审批效率特征", + "approval_control": "审批把关特征", +} + + +def build_profile_tags( + profiles: Iterable[Mapping[str, Any]], + *, + scene: str = "approval", +) -> list[dict[str, Any]]: + payloads = list(profiles) + index = profile_index(payloads) + tags: list[dict[str, Any]] = [] + append_expense_tags(tags, index) + append_travel_entertainment_tags(tags, index) + append_process_tags(tags, index) + append_ai_tags(tags, index) + append_approval_tags(tags, index) + + active_tags = [ + tag + for tag in tags + if int(tag["score"]) >= 60 and float(tag["confidence"]) >= 0.55 + ] + active_tags.sort( + key=lambda item: ( + scene_priority(item, scene), + float(item["confidence"]), + int(item["score"]), + ), + reverse=True, + ) + return active_tags[:12 if scene == "approval" else 24] + + +def build_profile_radar( + profiles: Iterable[Mapping[str, Any]], + profile_tags: Iterable[Mapping[str, Any]], + *, + scene: str = "approval", +) -> dict[str, Any]: + payloads = list(profiles) + index = profile_index(payloads) + tags = list(profile_tags) + dimensions = [ + _dimension( + "expense_intensity", + [ + component(index, "expense", "amount_occupancy_score"), + component(index, "expense", "peer_deviation_score"), + component(index, "expense", "current_claim_deviation_score"), + tag_score(tags, "expense_king"), + tag_score(tags, "large_amount_deviation"), + ], + tags, + ), + _dimension( + "application_rhythm", + [ + component(index, "expense", "frequency_score"), + tag_score(tags, "high_frequency_applicant"), + tag_score(tags, "micro_high_frequency"), + tag_score(tags, "expense_type_wide"), + tag_score(tags, "holiday_expense_active"), + ], + tags, + ), + _dimension( + "travel_entertainment", + [ + tag_score(tags, "long_trip_master"), + tag_score(tags, "travel_frequent"), + tag_score(tags, "travel_daily_high"), + tag_score(tags, "hotel_high_standard"), + tag_score(tags, "transport_high_cost"), + tag_score(tags, "entertainment_active"), + tag_score(tags, "entertainment_unit_high"), + tag_score(tags, "repeat_client_host"), + ], + tags, + ), + _dimension( + "material_completeness", + [ + component(index, "process_quality", "missing_attachment_score"), + component(index, "process_quality", "invoice_mismatch_score"), + component(index, "process_quality", "missing_business_context_score"), + tag_score(tags, "material_patch"), + tag_score(tags, "invoice_unstable"), + tag_score(tags, "reason_thin"), + tag_score(tags, "clean_first_pass", invert=True), + ], + tags, + ), + _dimension( + "process_pressure", + [ + component(index, "process_quality", "return_count_score"), + component(index, "process_quality", "resubmit_duration_score"), + tag_score(tags, "return_frequent"), + tag_score(tags, "adjustment_frequent"), + tag_score(tags, "resubmit_slow"), + tag_score(tags, "repeat_issue"), + tag_score(tags, "large_return_amount"), + ], + tags, + ), + _dimension( + "ai_collaboration", + [ + component(index, "ai_usage", "ai_call_count_score"), + component(index, "ai_usage", "token_cost_score"), + component(index, "ai_usage", "failed_ai_call_score"), + tag_score(tags, "ai_heavy"), + tag_score(tags, "token_high"), + tag_score(tags, "ai_dependency_unimproved"), + ], + tags, + ), + _dimension( + "approval_efficiency", + [ + component(index, "approval", "avg_review_duration_score"), + component(index, "approval", "sla_overdue_score"), + tag_score(tags, "speed_reviewer"), + tag_score(tags, "high_risk_fast_pass"), + tag_score(tags, "sla_delayer"), + ], + tags, + ), + _dimension( + "approval_control", + [ + component(index, "approval", "direct_approve_ratio_score"), + component(index, "approval", "high_risk_approve_score"), + component(index, "approval", "system_advice_override_score"), + tag_score(tags, "cautious_reviewer"), + tag_score(tags, "gatekeeper"), + tag_score(tags, "steady_reviewer"), + ], + tags, + ), + ] + if scene == "approval": + dimensions = [item for item in dimensions if item["code"] in APPROVAL_RADAR_CODES] + return { + "algorithm_version": PROFILE_TAG_ALGORITHM_VERSION, + "dimensions": dimensions, + } + + +def _dimension(code: str, values: list[float], tags: list[Mapping[str, Any]]) -> dict[str, Any]: + valid_values = [max(0, min(100, number(value))) for value in values if number(value) > 0] + score = clamp_score(sum(valid_values) / len(valid_values)) if valid_values else 0 + top_tags = [ + str(tag.get("code")) + for tag in sorted( + [tag for tag in tags if code in (tag.get("radar_dimensions") or [])], + key=lambda item: (int(item.get("score") or 0), float(item.get("confidence") or 0)), + reverse=True, + )[:3] + ] + level = level_from_score(score) + return { + "code": code, + "label": RADAR_LABELS.get(code, code), + "score": score, + "level": level, + "level_label": LEVEL_LABELS.get(level, level), + "top_tags": top_tags, + } diff --git a/server/src/app/api/v1/endpoints/employee_profiles.py b/server/src/app/api/v1/endpoints/employee_profiles.py new file mode 100644 index 0000000..61256ea --- /dev/null +++ b/server/src/app/api/v1/endpoints/employee_profiles.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext, get_current_user, get_db +from app.schemas.employee_profile import EmployeeProfileLatestRead +from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService + +router = APIRouter(prefix="/employee-profiles") +DbSession = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] + + +@router.get( + "/{employee_id}/latest", + response_model=EmployeeProfileLatestRead, + summary="读取员工最新业务行为画像", + description="返回员工在指定场景下的最新画像快照,审批场景默认只展示费用支出和流程质量画像。", +) +def get_employee_latest_profile( + employee_id: str, + db: DbSession, + current_user: CurrentUser, + scene: Annotated[str, Query(max_length=50)] = "approval", + claim_id: Annotated[str | None, Query(max_length=80)] = None, + window_days: Annotated[int, Query(ge=1, le=365)] = 90, + expense_type_scope: Annotated[str, Query(max_length=50)] = "overall", +) -> EmployeeProfileLatestRead: + del current_user + return EmployeeBehaviorProfileService(db).get_latest_profile( + employee_id=employee_id, + scene=scene, + claim_id=claim_id, + window_days=window_days, + expense_type_scope=expense_type_scope, + ) diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index e17561c..d76e54b 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -601,6 +601,38 @@ def approve_expense_claim( return claim +@router.post( + "/claims/{claim_id}/pay", + response_model=ExpenseClaimRead, + summary="确认报销单已付款", + description="财务人员或高级财务人员确认待付款报销单已完成付款。", + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "单据不存在。", + }, + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "当前用户或单据状态不允许确认付款。", + }, + }, +) +def pay_expense_claim( + claim_id: str, + db: DbSession, + current_user: CurrentUser, +) -> ExpenseClaimRead: + service = ExpenseClaimService(db) + try: + claim = service.mark_claim_paid(claim_id, current_user) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + if claim is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") + return claim + + @router.delete( "/claims/{claim_id}", response_model=ExpenseClaimActionResponse, diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 5dbab1c..3ca4cde 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -7,6 +7,7 @@ from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.bootstrap import router as bootstrap_router from app.api.v1.endpoints.budgets import router as budgets_router from app.api.v1.endpoints.employees import router as employees_router +from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router from app.api.v1.endpoints.health import router as health_router from app.api.v1.endpoints.knowledge import router as knowledge_router from app.api.v1.endpoints.ocr import router as ocr_router @@ -29,6 +30,7 @@ router.include_router(ocr_router, tags=["ocr"]) router.include_router(ontology_router, tags=["ontology"]) router.include_router(orchestrator_router, tags=["orchestrator"]) router.include_router(employees_router, prefix="/employees", tags=["employees"]) +router.include_router(employee_profiles_router, tags=["employee-profiles"]) router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) router.include_router(settings_router, tags=["settings"]) router.include_router(system_logs_router, tags=["system-logs"]) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index a886a5f..6296953 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -6,6 +6,7 @@ from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee_change_log import EmployeeChangeLog +from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot from app.models.employee import Employee from app.models.financial_record import ( AccountsPayableRecord, @@ -13,6 +14,8 @@ from app.models.financial_record import ( ExpenseClaim, ExpenseClaimItem, ) +from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog +from app.models.hermes_report import HermesRiskReport from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest from app.models.role import Role @@ -38,9 +41,13 @@ __all__ = [ "BudgetReservation", "BudgetTransaction", "Employee", + "EmployeeBehaviorProfileSnapshot", "EmployeeChangeLog", "ExpenseClaim", "ExpenseClaimItem", + "HermesTaskConfig", + "HermesTaskExecutionLog", + "HermesRiskReport", "OrganizationUnit", "ReimbursementRequest", "Role", diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index b14eb61..b3c7b8c 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee_change_log import EmployeeChangeLog +from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot from app.models.employee import Employee from app.models.financial_record import ( AccountsPayableRecord, @@ -37,6 +38,7 @@ __all__ = [ "BudgetReservation", "BudgetTransaction", "Employee", + "EmployeeBehaviorProfileSnapshot", "EmployeeChangeLog", "ExpenseClaim", "ExpenseClaimItem", diff --git a/server/src/app/models/employee.py b/server/src/app/models/employee.py index 5aeda95..8268017 100644 --- a/server/src/app/models/employee.py +++ b/server/src/app/models/employee.py @@ -32,6 +32,9 @@ class Employee(Base): grade: Mapped[str] = mapped_column(String(20), default="P3", index=True) cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True) finance_owner_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + bank_name: Mapped[str | None] = mapped_column(String(120), nullable=True) + bank_account_no: Mapped[str | None] = mapped_column(String(80), nullable=True) + bank_account_name: Mapped[str | None] = mapped_column(String(100), nullable=True) password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True) sync_state: Mapped[str] = mapped_column(String(30), default="已同步") diff --git a/server/src/app/models/employee_behavior_profile.py b/server/src/app/models/employee_behavior_profile.py new file mode 100644 index 0000000..ca36a26 --- /dev/null +++ b/server/src/app/models/employee_behavior_profile.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class EmployeeBehaviorProfileSnapshot(Base): + __tablename__ = "employee_behavior_profile_snapshots" + __table_args__ = ( + Index( + "ix_employee_behavior_profile_latest", + "subject_id", + "profile_type", + "window_days", + "expense_type_scope", + "calculated_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + subject_type: Mapped[str] = mapped_column(String(30), default="employee", index=True) + subject_id: Mapped[str] = mapped_column(String(100), index=True) + subject_name: Mapped[str] = mapped_column(String(100), index=True) + department_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + department_name: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + position: Mapped[str | None] = mapped_column(String(100), nullable=True) + grade: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) + + profile_type: Mapped[str] = mapped_column(String(50), index=True) + window_days: Mapped[int] = mapped_column(Integer, index=True) + expense_type_scope: Mapped[str] = mapped_column(String(50), default="overall", index=True) + peer_group_key: Mapped[str] = mapped_column(String(255), default="") + peer_group_fallback_level: Mapped[int] = mapped_column(Integer, default=0) + + profile_score: Mapped[int] = mapped_column(Integer, default=0) + profile_level: Mapped[str] = mapped_column(String(30), default="normal", index=True) + metrics_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + basis_codes_json: Mapped[list[Any]] = mapped_column(JSON, default=list) + source_task_type: Mapped[str] = mapped_column( + String(80), default="employee_behavior_profile_scan" + ) + source_task_log_id: Mapped[str | None] = mapped_column( + ForeignKey("hermes_task_execution_logs.id"), + nullable=True, + index=True, + ) + algorithm_version: Mapped[str] = mapped_column( + String(80), default="employee_behavior_profile.v1" + ) + calculated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + source_task_log = relationship("HermesTaskExecutionLog") diff --git a/server/src/app/schemas/employee.py b/server/src/app/schemas/employee.py index 59d56fc..7ba4677 100644 --- a/server/src/app/schemas/employee.py +++ b/server/src/app/schemas/employee.py @@ -78,6 +78,9 @@ class EmployeeRead(BaseModel): joinDate: str | None = None location: str | None = None costCenter: str | None = None + bankName: str | None = None + bankAccountNo: str | None = None + bankAccountName: str | None = None updatedAt: str | None = None lastSync: str | None = None syncState: str @@ -100,6 +103,9 @@ class EmployeeCreate(BaseModel): grade: str = Field(default="P3", max_length=20) cost_center: str | None = Field(default=None, max_length=50) finance_owner_name: str | None = Field(default=None, max_length=100) + bank_name: str | None = Field(default=None, max_length=120) + bank_account_no: str | None = Field(default=None, max_length=80) + bank_account_name: str | None = Field(default=None, max_length=100) employment_status: str = Field(default="在职", max_length=30) sync_state: str = Field(default="已同步", max_length=30) spotlight: bool = False @@ -148,6 +154,9 @@ class EmployeeUpdate(BaseModel): grade: str | None = Field(default=None, min_length=1, max_length=20) cost_center: str | None = Field(default=None, max_length=50) finance_owner_name: str | None = Field(default=None, max_length=100) + bank_name: str | None = Field(default=None, max_length=120) + bank_account_no: str | None = Field(default=None, max_length=80) + bank_account_name: str | None = Field(default=None, max_length=100) organization_unit_code: str | None = Field(default=None, max_length=50) manager_employee_no: str | None = Field(default=None, max_length=50) role_codes: list[str] | None = None diff --git a/server/src/app/schemas/employee_profile.py b/server/src/app/schemas/employee_profile.py new file mode 100644 index 0000000..cd20c97 --- /dev/null +++ b/server/src/app/schemas/employee_profile.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class EmployeeProfilePeerGroupRead(BaseModel): + key: str = "" + fallback_level: int = 0 + sample_size: int = 0 + + +class EmployeeProfileRead(BaseModel): + profile_type: str + profile_label: str + score: int + level: str + level_label: str + metrics: dict[str, Any] = Field(default_factory=dict) + top_contributors: list[dict[str, Any]] = Field(default_factory=list) + + +class EmployeeProfileTagRead(BaseModel): + code: str + label: str + display_label: str + category: str + polarity: str = "behavior" + score: int + confidence: float + reason: str = "" + evidence: list[dict[str, Any]] = Field(default_factory=list) + radar_dimensions: list[str] = Field(default_factory=list) + algorithm_version: str = "" + + +class EmployeeProfileRadarDimensionRead(BaseModel): + code: str + label: str + score: int + level: str + level_label: str + top_tags: list[str] = Field(default_factory=list) + + +class EmployeeProfileRadarRead(BaseModel): + algorithm_version: str = "" + dimensions: list[EmployeeProfileRadarDimensionRead] = Field(default_factory=list) + + +class EmployeeProfileLatestRead(BaseModel): + employee_id: str + employee_name: str = "" + scene: str = "approval" + window_days: int = 90 + expense_type_scope: str = "overall" + calculated_at: datetime | None = None + peer_group: EmployeeProfilePeerGroupRead = Field(default_factory=EmployeeProfilePeerGroupRead) + review_priority_score: int = 0 + review_priority_level: str = "normal" + review_priority_label: str = "正常" + profiles: list[EmployeeProfileRead] = Field(default_factory=list) + profile_tags: list[EmployeeProfileTagRead] = Field(default_factory=list) + radar: EmployeeProfileRadarRead = Field(default_factory=EmployeeProfileRadarRead) + review_suggestions: list[dict[str, Any]] = Field(default_factory=list) + empty_reason: str = "" diff --git a/server/src/app/services/agent_foundation_asset_seed.py b/server/src/app/services/agent_foundation_asset_seed.py index 54eedc8..4650489 100644 --- a/server/src/app/services/agent_foundation_asset_seed.py +++ b/server/src/app/services/agent_foundation_asset_seed.py @@ -12,6 +12,7 @@ from app.core.agent_enums import ( AgentName, AgentReviewStatus, ) +from app.core.config import SERVER_DIR from app.core.logging import get_logger from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion from app.services.agent_asset_spreadsheet import ( @@ -27,6 +28,7 @@ from app.services.agent_foundation_constants import ( COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, + DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, DIGITAL_EMPLOYEE_SKILL_CATEGORIES, DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP, ) @@ -38,11 +40,41 @@ class AgentFoundationAssetSeedMixin: def _digital_employee_task_config(self, code: str, cron: str) -> dict[str, object]: return { "cron": cron, + "schedule": cron, + "cron_expression": cron, "agent": AgentName.HERMES.value, + "task_type": code.replace("task.hermes.", "").replace(".", "_"), "skill_category": DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP.get(code, "整理"), "skill_category_options": list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES), } + def _finance_policy_knowledge_skill_markdown(self) -> str: + skill_path = ( + SERVER_DIR + / "src" + / "app" + / "skills" + / "domain" + / "finance-policy-knowledge-organizer" + / "SKILL.md" + ) + if skill_path.exists(): + return skill_path.read_text(encoding="utf-8").strip() + return "\n".join( + [ + "---", + "name: finance-policy-knowledge-organizer", + "description: 用于整理公司财务知识制度。", + "---", + "", + "# 整理公司财务知识制度", + "", + "## 功能说明", + "", + "整理公司财务制度、报销口径、审批要求和知识库资料,输出可复核的结构化知识。", + ] + ) + def _digital_employee_task_content( self, code: str, @@ -254,59 +286,11 @@ class AgentFoundationAssetSeedMixin: config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500}, ) - task_asset = AgentAsset( + finance_policy_knowledge_task = AgentAsset( asset_type=AgentAssetType.TASK.value, - code="task.hermes.daily_risk_scan", - name="Hermes 每日风险巡检", - description="每天早上巡检重复报销、金额超标、逾期应收和异常付款。", - domain=AgentAssetDomain.SYSTEM.value, - scenario_json=["schedule", "risk_check"], - owner="风控与审计部", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", - config_json=self._digital_employee_task_config("task.hermes.daily_risk_scan", "0 9 * * *"), - ) - - ar_summary_task = AgentAsset( - asset_type=AgentAssetType.TASK.value, - code="task.hermes.weekly_ar_summary", - name="Hermes 每周应收账龄汇总", - description="每周汇总逾期应收、账龄分布和客户风险变化。", - domain=AgentAssetDomain.SYSTEM.value, - scenario_json=["schedule", "accounts_receivable", "summary"], - owner="风控与审计部", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", - config_json=self._digital_employee_task_config("task.hermes.weekly_ar_summary", "0 10 * * 1"), - ) - - rule_digest_task = AgentAsset( - asset_type=AgentAssetType.TASK.value, - code="task.hermes.rule_review_digest", - name="Hermes 规则待审摘要", - description="每天汇总待审规则、待补样例和被拒规则修订建议。", - domain=AgentAssetDomain.SYSTEM.value, - scenario_json=["schedule", "rule_center", "review_digest"], - owner="风控与审计部", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - published_version="v1.0.0", - working_version="v1.0.0", - config_json=self._digital_employee_task_config("task.hermes.rule_review_digest", "0 18 * * *"), - ) - - knowledge_index_task = AgentAsset( - asset_type=AgentAssetType.TASK.value, - code="task.hermes.knowledge_index_sync", - name="Hermes ??????", - description="?????????? LightRAG ???????", + code=DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, + name="整理公司财务知识制度", + description="按计划整理公司财务制度、报销口径、审批要求和知识库资料,形成可复核的结构化知识。", domain=AgentAssetDomain.SYSTEM.value, scenario_json=["schedule", "knowledge", "rule_center"], owner="财务制度管理组", @@ -315,7 +299,16 @@ class AgentFoundationAssetSeedMixin: current_version="v1.0.0", published_version="v1.0.0", working_version="v1.0.0", - config_json=self._digital_employee_task_config("task.hermes.knowledge_index_sync", "0 0 * * *"), + config_json={ + **self._digital_employee_task_config( + DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, + "0 3 * * *", + ), + "skill_name": "finance-policy-knowledge-organizer", + "folder": "财务制度", + "changed_only": True, + "output_format": "knowledge_organizing_report", + }, ) self.db.add_all( @@ -330,10 +323,7 @@ class AgentFoundationAssetSeedMixin: skill_ar_asset, invoice_mcp_asset, ledger_mcp_asset, - task_asset, - ar_summary_task, - rule_digest_task, - knowledge_index_task, + finance_policy_knowledge_task, ] ) @@ -493,54 +483,11 @@ class AgentFoundationAssetSeedMixin: created_by="系统初始化", ), AgentAssetVersion( - asset=task_asset, + asset=finance_policy_knowledge_task, version="v1.0.0", - content=self._digital_employee_task_content( - "task.hermes.daily_risk_scan", - "daily_risk_scan", - "0 9 * * *", - ), - content_type=AgentAssetContentType.JSON.value, - change_note="初始化任务快照。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=ar_summary_task, - version="v1.0.0", - content=self._digital_employee_task_content( - "task.hermes.weekly_ar_summary", - "weekly_ar_summary", - "0 10 * * 1", - ), - content_type=AgentAssetContentType.JSON.value, - change_note="初始化应收账龄汇总任务。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=rule_digest_task, - version="v1.0.0", - content=self._digital_employee_task_content( - "task.hermes.rule_review_digest", - "rule_review_digest", - "0 18 * * *", - ), - content_type=AgentAssetContentType.JSON.value, - change_note="初始化规则待审摘要任务。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=knowledge_index_task, - version="v1.0.0", - content=self._digital_employee_task_content( - "task.hermes.knowledge_index_sync", - "knowledge_index_sync", - "0 0 * * *", - folder="报销制度", - changed_only=True, - index_engine="lightrag", - ), - content_type=AgentAssetContentType.JSON.value, - change_note="初始化制度知识与规则草稿形成任务。", + content=self._finance_policy_knowledge_skill_markdown(), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化整理公司财务知识制度能力。", created_by="系统初始化", ), ] diff --git a/server/src/app/services/agent_foundation_asset_topup.py b/server/src/app/services/agent_foundation_asset_topup.py index d0e0bcd..8ad10c0 100644 --- a/server/src/app/services/agent_foundation_asset_topup.py +++ b/server/src/app/services/agent_foundation_asset_topup.py @@ -13,6 +13,7 @@ from app.core.agent_enums import ( ) from app.core.logging import get_logger from app.models.agent_asset import AgentAsset +from app.models.agent_run import AgentRun from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE, @@ -26,6 +27,8 @@ from app.services.agent_foundation_constants import ( COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, + DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, + DIGITAL_EMPLOYEE_LEGACY_TASK_CODES, DIGITAL_EMPLOYEE_SKILL_CATEGORIES, DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP, ) @@ -34,6 +37,26 @@ logger = get_logger("app.services.agent_foundation") class AgentFoundationAssetTopUpMixin: + def _remove_legacy_digital_employee_assets(self) -> None: + assets = list( + self.db.scalars( + select(AgentAsset).where(AgentAsset.code.in_(DIGITAL_EMPLOYEE_LEGACY_TASK_CODES)) + ).all() + ) + if not assets: + return + + asset_ids = [asset.id for asset in assets] + runs = list( + self.db.scalars(select(AgentRun).where(AgentRun.task_id.in_(asset_ids))).all() + ) + for run in runs: + run.task_id = None + self.db.add(run) + + for asset in assets: + self.db.delete(asset) + def _sync_digital_employee_skill_categories(self) -> None: category_options = list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES) has_changes = False @@ -45,6 +68,10 @@ class AgentFoundationAssetTopUpMixin: config_json = dict(asset.config_json or {}) changed = False + task_type = code.replace("task.hermes.", "").replace(".", "_") + if config_json.get("task_type") != task_type: + config_json["task_type"] = task_type + changed = True if config_json.get("skill_category") != category: config_json["skill_category"] = category changed = True @@ -63,6 +90,7 @@ class AgentFoundationAssetTopUpMixin: def _top_up_agent_assets(self, existing_codes: set[str]) -> None: self._remove_legacy_rule_assets() + self._remove_legacy_digital_employee_assets() existing_codes = set(self.db.scalars(select(AgentAsset.code)).all()) self._sync_digital_employee_skill_categories() @@ -572,91 +600,82 @@ class AgentFoundationAssetTopUpMixin: created_by="系统初始化", ) - if "task.hermes.weekly_ar_summary" not in existing_codes: + finance_policy_cron = "0 3 * * *" + finance_policy_config = { + **self._digital_employee_task_config( + DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, + finance_policy_cron, + ), + "schedule": finance_policy_cron, + "cron_expression": finance_policy_cron, + "skill_name": "finance-policy-knowledge-organizer", + "folder": "财务制度", + "changed_only": True, + "output_format": "knowledge_organizing_report", + } + + if DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE not in existing_codes: asset = self._create_seed_asset( asset_type=AgentAssetType.TASK.value, - code="task.hermes.weekly_ar_summary", - name="Hermes 每周应收账龄汇总", - description="每周汇总逾期应收、账龄分布和客户风险变化。", - domain=AgentAssetDomain.SYSTEM.value, - scenario_json=["schedule", "accounts_receivable", "summary"], - owner="风控与审计部", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - config_json=self._digital_employee_task_config("task.hermes.weekly_ar_summary", "0 10 * * 1"), - ) - - self._ensure_asset_version( - asset, - version="v1.0.0", - content=self._digital_employee_task_content( - "task.hermes.weekly_ar_summary", - "weekly_ar_summary", - "0 10 * * 1", - ), - content_type=AgentAssetContentType.JSON.value, - change_note="初始化应收账龄汇总任务。", - created_by="系统初始化", - ) - - if "task.hermes.rule_review_digest" not in existing_codes: - - asset = self._create_seed_asset( - asset_type=AgentAssetType.TASK.value, - code="task.hermes.rule_review_digest", - name="Hermes 规则待审摘要", - description="每天汇总待审规则、待补样例和被拒规则修订建议。", - domain=AgentAssetDomain.SYSTEM.value, - scenario_json=["schedule", "rule_center", "review_digest"], - owner="风控与审计部", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", - config_json=self._digital_employee_task_config("task.hermes.rule_review_digest", "0 18 * * *"), - ) - - self._ensure_asset_version( - asset, - version="v1.0.0", - content=self._digital_employee_task_content( - "task.hermes.rule_review_digest", - "rule_review_digest", - "0 18 * * *", - ), - content_type=AgentAssetContentType.JSON.value, - change_note="初始化规则待审摘要任务。", - created_by="系统初始化", - ) - - if "task.hermes.knowledge_index_sync" not in existing_codes: - - asset = self._create_seed_asset( - asset_type=AgentAssetType.TASK.value, - code="task.hermes.knowledge_index_sync", - name="Hermes ??????", - description="?????????? LightRAG ???????", + code=DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, + name="整理公司财务知识制度", + description="按计划整理公司财务制度、报销口径、审批要求和知识库资料,形成可复核的结构化知识。", domain=AgentAssetDomain.SYSTEM.value, scenario_json=["schedule", "knowledge", "rule_center"], owner="财务制度管理组", reviewer="顾承宇", status=AgentAssetStatus.ACTIVE.value, current_version="v1.0.0", - config_json=self._digital_employee_task_config("task.hermes.knowledge_index_sync", "0 0 * * *"), + config_json=finance_policy_config, ) - self._ensure_asset_version( - asset, - version="v1.0.0", - content=self._digital_employee_task_content( - "task.hermes.knowledge_index_sync", - "knowledge_index_sync", - "0 0 * * *", - folder="报销制度", - changed_only=True, - ), - content_type=AgentAssetContentType.JSON.value, - change_note="初始化制度知识与规则草稿形成任务。", - created_by="系统初始化", + else: + + asset = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE) ) + if asset is None: + return + existing_config = dict(asset.config_json or {}) + existing_cron = ( + existing_config.get("cron") + or existing_config.get("schedule") + or existing_config.get("cron_expression") + ) + schedule_config = ( + { + "cron": existing_cron, + "schedule": existing_cron, + "cron_expression": existing_cron, + } + if existing_cron + else {} + ) + asset.name = "整理公司财务知识制度" + asset.description = "按计划整理公司财务制度、报销口径、审批要求和知识库资料,形成可复核的结构化知识。" + asset.owner = "财务制度管理组" + asset.domain = AgentAssetDomain.SYSTEM.value + asset.scenario_json = ["schedule", "knowledge", "rule_center"] + asset.config_json = { + **existing_config, + "agent": "hermes", + "task_type": "finance_policy_knowledge_organize", + "skill_category": "整理", + "skill_category_options": list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES), + "skill_name": "finance-policy-knowledge-organizer", + "folder": existing_config.get("folder") or "财务制度", + "changed_only": existing_config.get("changed_only", True), + "output_format": "knowledge_organizing_report", + **schedule_config, + } + self.db.add(asset) + + self._ensure_asset_version( + asset, + version="v1.0.0", + content=self._finance_policy_knowledge_skill_markdown(), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化整理公司财务知识制度能力。", + created_by="系统初始化", + ) diff --git a/server/src/app/services/agent_foundation_constants.py b/server/src/app/services/agent_foundation_constants.py index 8e75866..88d6314 100644 --- a/server/src/app/services/agent_foundation_constants.py +++ b/server/src/app/services/agent_foundation_constants.py @@ -88,18 +88,18 @@ COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",) DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估") +DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize" + +DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = ( + "task.hermes.daily_risk_scan", + "task.hermes.weekly_ar_summary", + "task.hermes.rule_review_digest", + "task.hermes.knowledge_index_sync", + "task.hermes.llm_wiki_rule_formation", +) + DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = { - - "task.hermes.daily_risk_scan": "评估", - - "task.hermes.weekly_ar_summary": "整理", - - "task.hermes.rule_review_digest": "升级", - - "task.hermes.knowledge_index_sync": "积累", - - "task.hermes.llm_wiki_rule_formation": "积累", - + DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理", } ATTACHMENT_RULE_RUNTIME_CONFIG = { diff --git a/server/src/app/services/agent_foundation_financial_seed.py b/server/src/app/services/agent_foundation_financial_seed.py index 557d24b..a6eb6b0 100644 --- a/server/src/app/services/agent_foundation_financial_seed.py +++ b/server/src/app/services/agent_foundation_financial_seed.py @@ -53,6 +53,7 @@ from app.services.agent_foundation_constants import ( DEMO_EXPENSE_CLAIM_SIGNATURES, DEMO_PAYABLE_SIGNATURES, DEMO_RECEIVABLE_SIGNATURES, + DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, LEGACY_RULE_CODES, PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) @@ -411,7 +412,7 @@ class AgentFoundationFinancialSeedMixin: task_asset = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == "task.hermes.daily_risk_scan") + select(AgentAsset).where(AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE) ) @@ -711,7 +712,7 @@ class AgentFoundationFinancialSeedMixin: resource_type="task", - resource_id="task.hermes.daily_risk_scan", + resource_id=DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, before_json={"status": "idle"}, diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py index d1b8ff3..f7ea375 100644 --- a/server/src/app/services/employee.py +++ b/server/src/app/services/employee.py @@ -4,7 +4,7 @@ from collections import Counter from datetime import UTC, date, datetime from typing import Any -from sqlalchemy import inspect, select, text +from sqlalchemy import select from sqlalchemy.orm import Session from app.core.config import get_settings @@ -28,6 +28,8 @@ from app.schemas.employee import ( EmployeeUpdate, ) from app.services.employee_import import EmployeeImportCoordinator +from app.services.employee_bank_info import apply_default_bank_info +from app.services.employee_schema import ensure_employee_schema from app.services.employee_serialization import serialize_employee from app.services.employee_spreadsheet import build_import_template_bytes from app.services.employee_seed import ( @@ -86,12 +88,13 @@ class EmployeeService: def ensure_directory_ready(self) -> None: try: Base.metadata.create_all(bind=self.db.get_bind()) - self._ensure_employee_schema() + ensure_employee_schema(self.db) self._prune_extra_seed_employees() self._seed_roles() self._seed_organization_units() self._seed_employees() self._normalize_legacy_employee_departments() + self._backfill_employee_bank_info() self.db.commit() except Exception: self.db.rollback() @@ -191,12 +194,16 @@ class EmployeeService: grade=payload.grade, cost_center=payload.cost_center, finance_owner_name=payload.finance_owner_name, + bank_name=normalize_optional_text(payload.bank_name), + bank_account_no=normalize_optional_text(payload.bank_account_no), + bank_account_name=normalize_optional_text(payload.bank_account_name), employment_status=payload.employment_status, sync_state=payload.sync_state, spotlight=payload.spotlight, password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD), last_sync_at=datetime.now(UTC), ) + apply_default_bank_info(employee) if payload.organization_unit_code: organization_code = normalize_organization_unit_code(payload.organization_unit_code) @@ -305,6 +312,24 @@ class EmployeeService: employee.finance_owner_name = finance_owner_name changed_fields.append("财务归口") + if "bank_account_name" in payload.model_fields_set: + bank_account_name = normalize_optional_text(payload.bank_account_name) + if bank_account_name != employee.bank_account_name: + employee.bank_account_name = bank_account_name + changed_fields.append("银行户名") + + if "bank_name" in payload.model_fields_set: + bank_name = normalize_optional_text(payload.bank_name) + if bank_name != employee.bank_name: + employee.bank_name = bank_name + changed_fields.append("开户行") + + if "bank_account_no" in payload.model_fields_set: + bank_account_no = normalize_optional_text(payload.bank_account_no) + if bank_account_no != employee.bank_account_no: + employee.bank_account_no = bank_account_no + changed_fields.append("银行账号") + if "organization_unit_code" in payload.model_fields_set: organization_code = normalize_organization_unit_code( normalize_optional_text(payload.organization_unit_code) @@ -581,6 +606,9 @@ class EmployeeService: grade=definition.get("grade", "P3"), cost_center=definition.get("cost_center"), finance_owner_name=definition.get("finance_owner_name"), + bank_name=definition.get("bank_name"), + bank_account_no=definition.get("bank_account_no"), + bank_account_name=definition.get("bank_account_name"), employment_status=definition.get("employment_status", "在职"), sync_state=definition.get("sync_state", "已同步"), spotlight=bool(definition.get("spotlight")), @@ -606,6 +634,8 @@ class EmployeeService: if not employee.password_hash: employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD) + apply_default_bank_info(employee) + if not employee.roles: employee.roles = self._sorted_roles( [ @@ -655,6 +685,9 @@ class EmployeeService: "location", "cost_center", "finance_owner_name", + "bank_name", + "bank_account_no", + "bank_account_name", "employment_status", "sync_state", ): @@ -673,6 +706,8 @@ class EmployeeService: if not employee.password_hash: employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD) + apply_default_bank_info(employee) + role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code] if role_codes: merged_roles = {role.role_code: role for role in employee.roles} @@ -691,19 +726,9 @@ class EmployeeService: if employee is not None: self.db.delete(employee) - def _ensure_employee_schema(self) -> None: - bind = self.db.get_bind() - inspector = inspect(bind) - if "employees" not in inspector.get_table_names(): - return - - column_names = {column["name"] for column in inspector.get_columns("employees")} - if "password_hash" not in column_names: - self.db.execute(text("ALTER TABLE employees ADD COLUMN password_hash VARCHAR(255)")) - if "compliance_score" not in column_names: - self.db.execute( - text("ALTER TABLE employees ADD COLUMN compliance_score INTEGER DEFAULT 100 NOT NULL") - ) + def _backfill_employee_bank_info(self) -> None: + for employee in self.repository.list(): + apply_default_bank_info(employee) self.db.flush() def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None: diff --git a/server/src/app/services/employee_bank_info.py b/server/src/app/services/employee_bank_info.py new file mode 100644 index 0000000..4dd043d --- /dev/null +++ b/server/src/app/services/employee_bank_info.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import hashlib + +from app.models.employee import Employee + +DEFAULT_EMPLOYEE_BANK_NAME = "招商银行深圳科技园支行" + + +def build_default_bank_account_no(employee_no: str | None) -> str | None: + text = str(employee_no or "").strip() + if not text: + return None + + digest = hashlib.sha256(text.encode("utf-8")).hexdigest() + numeric = str(int(digest[:18], 16)).zfill(13)[-13:] + return f"622588{numeric}" + + +def apply_default_bank_info(employee: Employee) -> None: + if not employee.bank_account_name and employee.name: + employee.bank_account_name = employee.name + if not employee.bank_name: + employee.bank_name = DEFAULT_EMPLOYEE_BANK_NAME + if not employee.bank_account_no: + employee.bank_account_no = build_default_bank_account_no(employee.employee_no) diff --git a/server/src/app/services/employee_behavior_profile_helpers.py b/server/src/app/services/employee_behavior_profile_helpers.py new file mode 100644 index 0000000..c5af8b5 --- /dev/null +++ b/server/src/app/services/employee_behavior_profile_helpers.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from decimal import Decimal +from typing import Any + +from app.models.agent_run import AgentRun +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim + +TRAVEL_EXPENSE_TYPES = { + "travel", + "train_ticket", + "flight_ticket", + "hotel_ticket", + "ride_ticket", + "travel_allowance", +} +ENTERTAINMENT_EXPENSE_TYPES = {"meal", "entertainment"} + + +class EmployeeBehaviorProfileMetricHelpers: + def _sum_amount_by_employee(self, claims: list[ExpenseClaim]) -> dict[str, Decimal]: + grouped: dict[str, Decimal] = defaultdict(Decimal) + for claim in claims: + grouped[self._claim_employee_key(claim)] += self._decimal(claim.amount) + return dict(grouped) + + def _count_by_employee(self, claims: list[ExpenseClaim]) -> dict[str, int]: + grouped: dict[str, int] = defaultdict(int) + for claim in claims: + grouped[self._claim_employee_key(claim)] += 1 + return dict(grouped) + + def _return_count_by_employee(self, claims: list[ExpenseClaim]) -> dict[str, int]: + grouped: dict[str, int] = defaultdict(int) + for claim in claims: + grouped[self._claim_employee_key(claim)] += self._return_count([claim]) + return dict(grouped) + + def _claim_employee_key(self, claim: ExpenseClaim) -> str: + return str(claim.employee_id or claim.employee_name or "unknown").strip() + + def _employee_identifiers(self, employee: Employee) -> set[str]: + return { + item + for item in ( + employee.id, + employee.employee_no, + employee.email, + employee.name, + ) + if str(item or "").strip() + } + + def _return_count(self, claims: list[ExpenseClaim]) -> int: + count = 0 + for claim in claims: + status = str(claim.status or "").lower() + if status in {"returned", "supplement", "rejected"}: + count += 1 + for flag in claim.risk_flags_json or []: + if isinstance(flag, dict) and str(flag.get("source") or "") == "manual_return": + count += 1 + return count + + def _missing_attachment_count(self, claim: ExpenseClaim) -> int: + if not claim.items: + return int((claim.invoice_count or 0) <= 0) + return sum(1 for item in claim.items if not str(item.invoice_id or "").strip()) + + def _has_amount_mismatch(self, claim: ExpenseClaim) -> bool: + if not claim.items: + return False + item_total = sum((self._decimal(item.item_amount) for item in claim.items), Decimal("0")) + return abs(item_total - self._decimal(claim.amount)) > Decimal("0.01") + + def _missing_context_count(self, claim: ExpenseClaim) -> int: + missing = 0 + for value in (claim.reason, claim.location, claim.project_code): + if self._is_missing_value(value): + missing += 1 + for item in claim.items or []: + if self._is_missing_value(item.item_reason): + missing += 1 + if item.item_type in TRAVEL_EXPENSE_TYPES and self._is_missing_value( + item.item_location + ): + missing += 1 + return missing + + def _claim_travel_days(self, claim: ExpenseClaim | None) -> Decimal: + if claim is None: + return Decimal("0") + dates = { + item.item_date + for item in claim.items or [] + if item.item_type in TRAVEL_EXPENSE_TYPES and item.item_date is not None + } + if dates: + return Decimal(max(1, len(dates))) + return Decimal("1") if claim.expense_type in TRAVEL_EXPENSE_TYPES else Decimal("0") + + def _entertainment_unit_amount(self, claim: ExpenseClaim) -> Decimal: + if claim.expense_type not in ENTERTAINMENT_EXPENSE_TYPES: + return Decimal("0") + attendee_count = self._extract_attendee_count(claim) + if attendee_count <= 0: + return Decimal("0") + return self._decimal(claim.amount) / Decimal(attendee_count) + + def _extract_attendee_count(self, claim: ExpenseClaim) -> int: + text = " ".join( + [claim.reason or "", *(item.item_reason or "" for item in claim.items or [])] + ) + for token in ("人", "位"): + parts = text.split(token) + for part in parts: + digits = "".join(ch for ch in part[-3:] if ch.isdigit()) + if digits: + return max(1, int(digits)) + return 0 + + def _estimate_tokens(self, runs: list[AgentRun]) -> int: + total = 0 + for run in runs: + payload = { + "ontology": run.ontology_json, + "route": run.route_json, + "summary": run.result_summary, + "error": run.error_message, + "tools": [ + { + "request": tool.request_json, + "response": tool.response_json, + "error": tool.error_message, + } + for tool in run.tool_calls + ], + } + text = json.dumps(payload, ensure_ascii=False, default=str) + total += max(0, len(text) // 4) + return total + + @staticmethod + def _is_missing_value(value: Any) -> bool: + text = str(value or "").strip() + return not text or text in {"待补充", "暂无", "无", "未知"} + + @staticmethod + def _decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return Decimal("0") + + @staticmethod + def _format_decimal(value: Any) -> str: + try: + return str(Decimal(str(value or "0")).quantize(Decimal("0.0001")).normalize()) + except Exception: + return "0" diff --git a/server/src/app/services/employee_behavior_profile_response.py b/server/src/app/services/employee_behavior_profile_response.py new file mode 100644 index 0000000..f994d86 --- /dev/null +++ b/server/src/app/services/employee_behavior_profile_response.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any + +from app.algorithem.employee_behavior_profile import build_review_suggestions +from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot + + +def build_profile_payloads( + rows: list[EmployeeBehaviorProfileSnapshot], +) -> list[dict[str, Any]]: + return [ + { + "profile_type": row.profile_type, + "profile_label": row.profile_type, + "score": row.profile_score, + "level": row.profile_level, + "metrics": row.metrics_json or {}, + "top_contributors": row.basis_codes_json or [], + } + for row in sorted(rows, key=lambda item: item.profile_type) + ] + + +def build_latest_review_suggestions( + *, + rows: list[EmployeeBehaviorProfileSnapshot], + expense_score: int, + process_score: int, +) -> list[dict[str, Any]]: + expense_row = next((row for row in rows if row.profile_type == "expense"), None) + metrics = expense_row.metrics_json if expense_row is not None else {} + formula_suggestions = build_review_suggestions( + expense_profile_score=expense_score, + process_quality_score=process_score, + requested_days=metrics.get("requested_days"), + peer_days_p75=metrics.get("peer_days_p75"), + peer_unit_amount_p75=metrics.get("peer_unit_amount_p75"), + ) + merged = [*formula_suggestions, *_merge_review_suggestions(rows)] + seen: set[str] = set() + unique: list[dict[str, Any]] = [] + for item in merged: + key = str(item.get("type") or item.get("message") or "").strip() + if not key or key in seen: + continue + seen.add(key) + unique.append(item) + return unique[:5] + + +def _merge_review_suggestions( + rows: list[EmployeeBehaviorProfileSnapshot], +) -> list[dict[str, Any]]: + merged: list[dict[str, Any]] = [] + seen: set[str] = set() + for row in rows: + for suggestion in (row.metrics_json or {}).get("review_suggestions") or []: + key = str(suggestion.get("type") or suggestion.get("message") or "") + if key and key not in seen: + seen.add(key) + merged.append(suggestion) + return merged[:5] diff --git a/server/src/app/services/employee_behavior_profile_service.py b/server/src/app/services/employee_behavior_profile_service.py new file mode 100644 index 0000000..ab608ab --- /dev/null +++ b/server/src/app/services/employee_behavior_profile_service.py @@ -0,0 +1,816 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +from app.algorithem.employee_behavior_profile import ( + ALGORITHM_VERSION, + LEVEL_LABELS, + PROFILE_LABELS, + ProfileComponent, + build_review_suggestions, + calculate_review_priority_score, + evaluate_weighted_profile, + level_from_score, + normalize_by_peer_percentiles, + percentile, + score_by_bands, +) +from app.algorithem.employee_behavior_profile_tags import build_profile_radar, build_profile_tags +from app.db.base import Base +from app.models.agent_run import AgentRun +from app.models.approval import ApprovalRecord +from app.models.employee import Employee +from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot +from app.models.financial_record import ExpenseClaim +from app.schemas.employee_profile import ( + EmployeeProfileLatestRead, + EmployeeProfilePeerGroupRead, + EmployeeProfileRead, +) +from app.services.employee_behavior_profile_helpers import ( + ENTERTAINMENT_EXPENSE_TYPES, + TRAVEL_EXPENSE_TYPES, + EmployeeBehaviorProfileMetricHelpers, +) +from app.services.employee_behavior_profile_response import ( + build_latest_review_suggestions, + build_profile_payloads, +) + +PROFILE_TYPES_FOR_APPROVAL = {"expense", "process_quality"} +ATTENTION_LEVELS = {"watch", "review", "escalation"} +PENDING_CLAIM_STATUSES = {"submitted", "review", "in_progress", "pending", "pending_review"} +DEFAULT_WINDOWS = (30, 90, 180) + + +class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): + def __init__(self, db: Session) -> None: + self.db = db + + def ensure_storage_ready(self) -> None: + Base.metadata.create_all( + bind=self.db.get_bind(), tables=[EmployeeBehaviorProfileSnapshot.__table__] + ) + + def scan_profiles( + self, + *, + log_id: str | None = None, + window_days: tuple[int, ...] = DEFAULT_WINDOWS, + limit: int = 120, + ) -> dict[str, Any]: + self.ensure_storage_ready() + employee_ids = self._resolve_target_employee_ids(limit=limit) + snapshot_count = 0 + high_attention_count = 0 + + for employee_id in employee_ids: + snapshots = self.refresh_employee_profiles( + employee_id=employee_id, + window_days=window_days, + expense_type_scope="overall", + source_task_type="employee_behavior_profile_scan", + source_task_log_id=log_id, + commit=False, + ) + snapshot_count += len(snapshots) + high_attention_count += int( + any(item.profile_level in ATTENTION_LEVELS for item in snapshots) + ) + + self.db.commit() + return { + "target_employee_count": len(employee_ids), + "snapshot_count": snapshot_count, + "high_attention_employee_count": high_attention_count, + "window_days": list(window_days), + "algorithm_version": ALGORITHM_VERSION, + } + + def refresh_employee_profiles( + self, + *, + employee_id: str, + window_days: tuple[int, ...] = DEFAULT_WINDOWS, + expense_type_scope: str = "overall", + source_task_type: str = "api_on_demand", + source_task_log_id: str | None = None, + claim_id: str | None = None, + commit: bool = True, + ) -> list[EmployeeBehaviorProfileSnapshot]: + self.ensure_storage_ready() + employee = self.db.get(Employee, employee_id) + if employee is None: + return [] + + now = datetime.now(UTC) + snapshots: list[EmployeeBehaviorProfileSnapshot] = [] + for days in window_days: + context = self._build_window_context( + employee=employee, + window_days=days, + expense_type_scope=expense_type_scope, + claim_id=claim_id, + now=now, + ) + for result in ( + self._calculate_expense_profile(context), + self._calculate_process_quality_profile(context), + self._calculate_ai_usage_profile(context), + self._calculate_approval_behavior_profile(context), + ): + snapshot = EmployeeBehaviorProfileSnapshot( + subject_type="employee", + subject_id=employee.id, + subject_name=employee.name, + department_id=employee.organization_unit_id, + department_name=context["department_name"], + position=employee.position, + grade=employee.grade, + profile_type=result.profile_type, + window_days=days, + expense_type_scope=expense_type_scope, + peer_group_key=context["peer_group_key"], + peer_group_fallback_level=context["peer_group_fallback_level"], + profile_score=result.profile_score, + profile_level=result.profile_level, + metrics_json=result.metrics, + basis_codes_json=result.top_contributors(), + source_task_type=source_task_type, + source_task_log_id=source_task_log_id, + algorithm_version=ALGORITHM_VERSION, + calculated_at=now, + ) + self.db.add(snapshot) + snapshots.append(snapshot) + + if commit: + self.db.commit() + return snapshots + + def get_latest_profile( + self, + *, + employee_id: str, + scene: str = "approval", + claim_id: str | None = None, + window_days: int = 90, + expense_type_scope: str = "overall", + ) -> EmployeeProfileLatestRead: + self.ensure_storage_ready() + employee = self.db.get(Employee, employee_id) + if employee is None: + return EmployeeProfileLatestRead( + employee_id=employee_id, + scene=scene, + window_days=window_days, + expense_type_scope=expense_type_scope, + empty_reason="员工不存在或尚未同步。", + ) + + resolved_scope = self._resolve_scope_from_claim(claim_id, expense_type_scope) + rows = self._load_latest_snapshots( + employee_id=employee_id, + window_days=window_days, + expense_type_scope=resolved_scope, + scene=scene, + ) + if not rows and claim_id: + self.refresh_employee_profiles( + employee_id=employee_id, + window_days=(window_days,), + expense_type_scope=resolved_scope, + source_task_type="api_on_demand", + claim_id=claim_id, + ) + rows = self._load_latest_snapshots( + employee_id=employee_id, + window_days=window_days, + expense_type_scope=resolved_scope, + scene=scene, + ) + + return self._serialize_latest_profile( + employee=employee, + rows=rows, + scene=scene, + window_days=window_days, + expense_type_scope=resolved_scope, + ) + + def _build_window_context( + self, + *, + employee: Employee, + window_days: int, + expense_type_scope: str, + claim_id: str | None, + now: datetime, + ) -> dict[str, Any]: + cutoff = now - timedelta(days=window_days) + all_claims = self._fetch_claims_since(cutoff) + scoped_claims = [ + claim for claim in all_claims if self._is_claim_in_scope(claim, expense_type_scope) + ] + employee_claims = [claim for claim in scoped_claims if claim.employee_id == employee.id] + peer_claims, fallback_level = self._resolve_peer_claims( + claims=scoped_claims, + employee=employee, + ) + current_claim = next((claim for claim in all_claims if claim.id == claim_id), None) + + peer_amount_by_employee = self._sum_amount_by_employee(peer_claims) + peer_count_by_employee = self._count_by_employee(peer_claims) + peer_return_count_by_employee = self._return_count_by_employee(peer_claims) + peer_current_amounts = [self._decimal(claim.amount) for claim in peer_claims] + peer_travel_days = [self._claim_travel_days(claim) for claim in peer_claims] + peer_entertainment_units = [ + self._entertainment_unit_amount(claim) + for claim in peer_claims + if self._entertainment_unit_amount(claim) > Decimal("0") + ] + + department_name = employee.organization_unit.name if employee.organization_unit else "" + department_name = department_name or ( + employee_claims[0].department_name if employee_claims else "" + ) + peer_group_key = "|".join( + [ + department_name or "company", + employee.position or "position", + employee.grade or "grade", + expense_type_scope, + str(window_days), + ] + ) + + return { + "employee": employee, + "employee_identifiers": self._employee_identifiers(employee), + "department_name": department_name, + "window_days": window_days, + "expense_type_scope": expense_type_scope, + "cutoff": cutoff, + "now": now, + "employee_claims": employee_claims, + "peer_claims": peer_claims, + "current_claim": current_claim, + "peer_group_key": peer_group_key, + "peer_group_fallback_level": fallback_level, + "peer_sample_size": len({self._claim_employee_key(claim) for claim in peer_claims}), + "peer_amount_p50": percentile(list(peer_amount_by_employee.values()), 50), + "peer_amount_p90": percentile(list(peer_amount_by_employee.values()), 90), + "peer_count_p50": percentile(list(peer_count_by_employee.values()), 50), + "peer_count_p90": percentile(list(peer_count_by_employee.values()), 90), + "peer_return_p50": percentile(list(peer_return_count_by_employee.values()), 50), + "peer_return_p90": percentile(list(peer_return_count_by_employee.values()), 90), + "peer_claim_amount_p50": percentile(peer_current_amounts, 50), + "peer_claim_amount_p90": percentile(peer_current_amounts, 90), + "peer_days_p75": percentile(peer_travel_days, 75), + "peer_unit_amount_p75": percentile(peer_entertainment_units, 75), + "department_amount_total": sum( + (self._decimal(claim.amount) for claim in peer_claims), Decimal("0") + ), + } + + def _calculate_expense_profile(self, context: dict[str, Any]): + claims = context["employee_claims"] + amount_total = sum((self._decimal(claim.amount) for claim in claims), Decimal("0")) + current_claim = context["current_claim"] + current_amount = ( + self._decimal(current_claim.amount) if current_claim is not None else Decimal("0") + ) + current_days = ( + self._claim_travel_days(current_claim) if current_claim is not None else Decimal("0") + ) + department_amount = max(context["department_amount_total"], Decimal("0")) + amount_share = ( + amount_total / department_amount if department_amount > Decimal("0") else Decimal("0") + ) + + frequency_score = normalize_by_peer_percentiles( + len(claims), + context["peer_count_p50"], + context["peer_count_p90"], + ) + budget_score = score_by_bands( + amount_share, + [ + (Decimal("0.05"), 0), + (Decimal("0.15"), 45), + (Decimal("0.30"), 80), + (Decimal("0.45"), 100), + ], + ) + peer_deviation_score = normalize_by_peer_percentiles( + amount_total, + context["peer_amount_p50"], + context["peer_amount_p90"], + ) + adjustment_score = normalize_by_peer_percentiles( + self._return_count(claims), + context["peer_return_p50"], + context["peer_return_p90"], + ) + current_score = max( + normalize_by_peer_percentiles( + current_amount, + context["peer_claim_amount_p50"], + context["peer_claim_amount_p90"], + ), + score_by_bands( + current_days / context["peer_days_p75"] if context["peer_days_p75"] else 0, + [ + (Decimal("1.0"), 0), + (Decimal("1.3"), 40), + (Decimal("1.8"), 80), + (Decimal("2.2"), 100), + ], + ), + ) + + result = evaluate_weighted_profile( + "expense", + [ + ProfileComponent( + "frequency_score", + "费用申请频次", + frequency_score, + len(claims), + "次", + Decimal("0.20"), + ), + ProfileComponent( + "amount_occupancy_score", + "预算占用强度", + budget_score, + amount_share, + "占比", + Decimal("0.25"), + ), + ProfileComponent( + "peer_deviation_score", + "同组金额偏离", + peer_deviation_score, + amount_total, + "元", + Decimal("0.25"), + ), + ProfileComponent( + "adjustment_history_score", + "历史退回调减", + adjustment_score, + self._return_count(claims), + "次", + Decimal("0.15"), + ), + ProfileComponent( + "current_claim_deviation_score", + "当前单据偏离", + current_score, + current_amount, + "元", + Decimal("0.15"), + ), + ], + metrics={ + **self._common_metrics(context), + "claim_count": len(claims), + "amount_total": self._format_decimal(amount_total), + "amount_share": self._format_decimal(amount_share), + "current_claim_amount": self._format_decimal(current_amount), + "requested_days": self._format_decimal(current_days), + "peer_days_p75": self._format_decimal(context["peer_days_p75"]), + "peer_unit_amount_p75": self._format_decimal(context["peer_unit_amount_p75"]), + }, + ) + result.metrics["review_suggestions"] = build_review_suggestions( + expense_profile_score=result.profile_score, + process_quality_score=0, + requested_days=current_days, + peer_days_p75=context["peer_days_p75"], + peer_unit_amount_p75=context["peer_unit_amount_p75"], + ) + return result + + def _calculate_process_quality_profile(self, context: dict[str, Any]): + claims = context["employee_claims"] + missing_attachment_count = sum(self._missing_attachment_count(claim) for claim in claims) + mismatch_count = sum(1 for claim in claims if self._has_amount_mismatch(claim)) + missing_context_count = sum(self._missing_context_count(claim) for claim in claims) + return_count = self._return_count(claims) + resubmit_duration_score = 0 + + return evaluate_weighted_profile( + "process_quality", + [ + ProfileComponent( + "return_count_score", + "退单次数", + score_by_bands(return_count, [(0, 0), (1, 45), (2, 70), (4, 100)]), + return_count, + "次", + Decimal("0.25"), + ), + ProfileComponent( + "missing_attachment_score", + "附件缺失", + score_by_bands(missing_attachment_count, [(0, 0), (1, 35), (3, 75), (5, 100)]), + missing_attachment_count, + "项", + Decimal("0.20"), + ), + ProfileComponent( + "invoice_mismatch_score", + "票据金额不一致", + score_by_bands(mismatch_count, [(0, 0), (1, 60), (2, 85)]), + mismatch_count, + "次", + Decimal("0.20"), + ), + ProfileComponent( + "resubmit_duration_score", + "重提耗时", + resubmit_duration_score, + 0, + "小时", + Decimal("0.15"), + "当前审批事件尚未结构化,暂不计入。", + ), + ProfileComponent( + "missing_business_context_score", + "业务上下文缺失", + score_by_bands(missing_context_count, [(0, 0), (1, 30), (3, 70), (5, 100)]), + missing_context_count, + "项", + Decimal("0.20"), + ), + ], + metrics={ + **self._common_metrics(context), + "return_count": return_count, + "missing_attachment_count": missing_attachment_count, + "invoice_mismatch_count": mismatch_count, + "missing_business_context_count": missing_context_count, + "resubmit_duration_status": "unavailable", + }, + ) + + def _calculate_ai_usage_profile(self, context: dict[str, Any]): + runs = self._fetch_agent_runs(context["employee_identifiers"], context["cutoff"]) + tool_calls = [tool for run in runs for tool in run.tool_calls] + failed_calls = [ + tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"} + ] + estimated_tokens = self._estimate_tokens(runs) + override_score = 0 + + token_mode = "estimated_token_count" if estimated_tokens else "unavailable" + return evaluate_weighted_profile( + "ai_usage", + [ + ProfileComponent( + "ai_call_count_score", + "AI 调用次数", + score_by_bands(len(runs), [(0, 0), (3, 25), (10, 65), (20, 100)]), + len(runs), + "次", + Decimal("0.25"), + ), + ProfileComponent( + "token_cost_score", + "Token 使用强度", + score_by_bands( + estimated_tokens, [(0, 0), (2000, 25), (8000, 65), (20000, 100)] + ), + estimated_tokens, + "tokens", + Decimal("0.25"), + ), + ProfileComponent( + "ai_generated_claim_ratio_score", + "AI 生成申请比例", + score_by_bands(len(runs), [(0, 0), (2, 20), (8, 60), (16, 90)]), + len(runs), + "次", + Decimal("0.20"), + ), + ProfileComponent( + "ai_suggestion_override_score", + "AI 建议覆盖", + override_score, + 0, + "次", + Decimal("0.20"), + "当前缺少结构化采纳字段,暂不计入。", + ), + ProfileComponent( + "failed_ai_call_score", + "AI 调用失败", + score_by_bands(len(failed_calls), [(0, 0), (1, 35), (3, 80)]), + len(failed_calls), + "次", + Decimal("0.10"), + ), + ], + metrics={ + **self._common_metrics(context), + "ai_run_count": len(runs), + "tool_call_count": len(tool_calls), + "failed_tool_call_count": len(failed_calls), + "token_count_mode": token_mode, + "estimated_token_count": estimated_tokens, + "exact_token_count": None, + }, + ) + + def _calculate_approval_behavior_profile(self, context: dict[str, Any]): + records = self._fetch_approval_records(context["employee"].id, context["cutoff"]) + approve_count = sum( + 1 for item in records if str(item.action or "").lower() in {"approve", "approved"} + ) + return_count = sum(1 for item in records if "return" in str(item.action or "").lower()) + direct_approve_ratio = ( + Decimal(approve_count) / Decimal(len(records)) if records else Decimal("0") + ) + + return evaluate_weighted_profile( + "approval", + [ + ProfileComponent( + "avg_review_duration_score", + "平均审核时长", + 0, + 0, + "小时", + Decimal("0.20"), + "当前审批耗时字段尚未结构化。", + ), + ProfileComponent( + "sla_overdue_score", + "SLA 超时", + 0, + 0, + "次", + Decimal("0.20"), + "当前 SLA 字段尚未结构化。", + ), + ProfileComponent( + "direct_approve_ratio_score", + "直接通过率", + score_by_bands( + direct_approve_ratio, + [(Decimal("0.5"), 0), (Decimal("0.8"), 45), (Decimal("0.95"), 80)], + ), + direct_approve_ratio, + "比例", + Decimal("0.20"), + ), + ProfileComponent( + "high_risk_approve_score", + "高风险单据通过", + 0, + 0, + "次", + Decimal("0.20"), + "待与风险画像联动。", + ), + ProfileComponent( + "system_advice_override_score", + "系统建议覆盖", + score_by_bands(return_count, [(0, 0), (2, 25), (5, 70)]), + return_count, + "次", + Decimal("0.20"), + ), + ], + metrics={ + **self._common_metrics(context), + "approval_record_count": len(records), + "approve_count": approve_count, + "return_count": return_count, + "direct_approve_ratio": self._format_decimal(direct_approve_ratio), + }, + ) + + def _load_latest_snapshots( + self, + *, + employee_id: str, + window_days: int, + expense_type_scope: str, + scene: str, + ) -> list[EmployeeBehaviorProfileSnapshot]: + allowed_types = PROFILE_TYPES_FOR_APPROVAL if scene == "approval" else None + rows = self._query_latest_rows( + employee_id=employee_id, + window_days=window_days, + expense_type_scope=expense_type_scope, + allowed_types=allowed_types, + ) + if rows or expense_type_scope == "overall": + return rows + return self._query_latest_rows( + employee_id=employee_id, + window_days=window_days, + expense_type_scope="overall", + allowed_types=allowed_types, + ) + + def _query_latest_rows( + self, + *, + employee_id: str, + window_days: int, + expense_type_scope: str, + allowed_types: set[str] | None, + ) -> list[EmployeeBehaviorProfileSnapshot]: + stmt = select(EmployeeBehaviorProfileSnapshot).where( + EmployeeBehaviorProfileSnapshot.subject_id == employee_id, + EmployeeBehaviorProfileSnapshot.window_days == window_days, + EmployeeBehaviorProfileSnapshot.expense_type_scope == expense_type_scope, + ) + if allowed_types: + stmt = stmt.where(EmployeeBehaviorProfileSnapshot.profile_type.in_(allowed_types)) + + rows = list( + self.db.scalars( + stmt.order_by(EmployeeBehaviorProfileSnapshot.calculated_at.desc()) + ).all() + ) + latest_by_type: dict[str, EmployeeBehaviorProfileSnapshot] = {} + for row in rows: + latest_by_type.setdefault(row.profile_type, row) + return list(latest_by_type.values()) + + def _serialize_latest_profile( + self, + *, + employee: Employee, + rows: list[EmployeeBehaviorProfileSnapshot], + scene: str, + window_days: int, + expense_type_scope: str, + ) -> EmployeeProfileLatestRead: + if not rows: + return EmployeeProfileLatestRead( + employee_id=employee.id, + employee_name=employee.name, + scene=scene, + window_days=window_days, + expense_type_scope=expense_type_scope, + empty_reason="当前员工尚未形成画像快照。", + ) + + rows_by_type = {row.profile_type: row for row in rows} + expense_score = ( + rows_by_type.get("expense").profile_score if rows_by_type.get("expense") else 0 + ) + process_score = ( + rows_by_type.get("process_quality").profile_score + if rows_by_type.get("process_quality") + else 0 + ) + review_score = calculate_review_priority_score( + expense_profile_score=expense_score, + process_quality_score=process_score, + ) + review_level = level_from_score(review_score) + anchor = rows_by_type.get("expense") or rows[0] + suggestions = build_latest_review_suggestions( + rows=rows, + expense_score=expense_score, + process_score=process_score, + ) + profile_payloads = build_profile_payloads(rows) + profile_tags = build_profile_tags(profile_payloads, scene=scene) + radar = build_profile_radar(profile_payloads, profile_tags, scene=scene) + + return EmployeeProfileLatestRead( + employee_id=employee.id, + employee_name=employee.name, + scene=scene, + window_days=window_days, + expense_type_scope=expense_type_scope, + calculated_at=max(row.calculated_at for row in rows if row.calculated_at), + peer_group=EmployeeProfilePeerGroupRead( + key=anchor.peer_group_key, + fallback_level=anchor.peer_group_fallback_level, + sample_size=int((anchor.metrics_json or {}).get("peer_sample_size") or 0), + ), + review_priority_score=review_score, + review_priority_level=review_level, + review_priority_label=LEVEL_LABELS.get(review_level, review_level), + profiles=[ + EmployeeProfileRead( + profile_type=payload["profile_type"], + profile_label=PROFILE_LABELS.get( + payload["profile_type"], payload["profile_type"] + ), + score=payload["score"], + level=payload["level"], + level_label=LEVEL_LABELS.get(payload["level"], payload["level"]), + metrics=payload["metrics"], + top_contributors=payload["top_contributors"], + ) + for payload in profile_payloads + ], + profile_tags=profile_tags, + radar=radar, + review_suggestions=suggestions, + ) + + def _resolve_target_employee_ids(self, *, limit: int) -> list[str]: + cutoff = datetime.now(UTC) - timedelta(days=180) + claim_stmt = select(ExpenseClaim.employee_id).where( + ExpenseClaim.employee_id.is_not(None), + or_( + ExpenseClaim.occurred_at >= cutoff, + ExpenseClaim.status.in_(PENDING_CLAIM_STATUSES), + ), + ) + snapshot_stmt = select(EmployeeBehaviorProfileSnapshot.subject_id).where( + EmployeeBehaviorProfileSnapshot.profile_level.in_(ATTENTION_LEVELS) + ) + ordered: list[str] = [] + for value in [*self.db.scalars(claim_stmt).all(), *self.db.scalars(snapshot_stmt).all()]: + employee_id = str(value or "").strip() + if employee_id and employee_id not in ordered: + ordered.append(employee_id) + if len(ordered) >= limit: + break + return ordered + + def _fetch_claims_since(self, cutoff: datetime) -> list[ExpenseClaim]: + stmt = ( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee)) + .where(ExpenseClaim.occurred_at >= cutoff) + ) + return list(self.db.scalars(stmt).all()) + + def _fetch_agent_runs(self, identifiers: set[str], cutoff: datetime) -> list[AgentRun]: + if not identifiers: + return [] + stmt = ( + select(AgentRun) + .options(selectinload(AgentRun.tool_calls)) + .where(AgentRun.started_at >= cutoff, AgentRun.user_id.in_(identifiers)) + ) + return list(self.db.scalars(stmt).all()) + + def _fetch_approval_records(self, employee_id: str, cutoff: datetime) -> list[ApprovalRecord]: + stmt = select(ApprovalRecord).where( + ApprovalRecord.approver_id == employee_id, + ApprovalRecord.created_at >= cutoff, + ) + return list(self.db.scalars(stmt).all()) + + def _resolve_peer_claims( + self, + *, + claims: list[ExpenseClaim], + employee: Employee, + ) -> tuple[list[ExpenseClaim], int]: + department_name = employee.organization_unit.name if employee.organization_unit else "" + department_claims = [ + claim + for claim in claims + if claim.department_id == employee.organization_unit_id + or (department_name and claim.department_name == department_name) + ] + if len({self._claim_employee_key(claim) for claim in department_claims}) >= 3: + return department_claims, 0 + return claims, 3 + + def _resolve_scope_from_claim(self, claim_id: str | None, expense_type_scope: str) -> str: + normalized = str(expense_type_scope or "overall").strip() or "overall" + if normalized != "overall" or not claim_id: + return normalized + claim = self.db.get(ExpenseClaim, claim_id) + return str(claim.expense_type or "overall").strip() if claim is not None else normalized + + def _is_claim_in_scope(self, claim: ExpenseClaim, expense_type_scope: str) -> bool: + scope = str(expense_type_scope or "overall").strip() + if scope == "overall": + return True + if scope == "entertainment": + return claim.expense_type in ENTERTAINMENT_EXPENSE_TYPES + if scope == "travel": + return claim.expense_type in TRAVEL_EXPENSE_TYPES + return claim.expense_type == scope + + def _common_metrics(self, context: dict[str, Any]) -> dict[str, Any]: + return { + "window_days": context["window_days"], + "expense_type_scope": context["expense_type_scope"], + "peer_group_key": context["peer_group_key"], + "peer_group_fallback_level": context["peer_group_fallback_level"], + "peer_sample_size": context["peer_sample_size"], + "algorithm_version": ALGORITHM_VERSION, + } diff --git a/server/src/app/services/employee_import.py b/server/src/app/services/employee_import.py index 0632736..8bbb7dd 100644 --- a/server/src/app/services/employee_import.py +++ b/server/src/app/services/employee_import.py @@ -22,6 +22,7 @@ from app.services.employee_spreadsheet import ( parse_employee_workbook, ) from app.services.employee_seed import normalize_organization_unit_code +from app.services.employee_bank_info import apply_default_bank_info logger = get_logger("app.services.employee") @@ -72,6 +73,9 @@ class EmployeeImportCoordinator: employee.manager.employee_no if employee.manager else "", employee.finance_owner_name or "", employee.cost_center or "", + employee.bank_account_name or "", + employee.bank_name or "", + employee.bank_account_no or "", employee.employment_status, role_codes, ] @@ -267,9 +271,13 @@ class EmployeeImportCoordinator: employee.grade = row.grade employee.finance_owner_name = row.finance_owner_name employee.cost_center = row.cost_center + employee.bank_account_name = row.bank_account_name + employee.bank_name = row.bank_name + employee.bank_account_no = row.bank_account_no employee.employment_status = row.employment_status employee.sync_state = "已同步" employee.last_sync_at = now + apply_default_bank_info(employee) organization_code = normalize_organization_unit_code(row.organization_unit_code) if organization_code: diff --git a/server/src/app/services/employee_schema.py b/server/src/app/services/employee_schema.py new file mode 100644 index 0000000..ab6d54f --- /dev/null +++ b/server/src/app/services/employee_schema.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from sqlalchemy import inspect, text +from sqlalchemy.orm import Session + +EMPLOYEE_SCHEMA_COLUMNS: dict[str, str] = { + "password_hash": "ALTER TABLE employees ADD COLUMN password_hash VARCHAR(255)", + "compliance_score": "ALTER TABLE employees ADD COLUMN compliance_score INTEGER DEFAULT 100 NOT NULL", + "bank_name": "ALTER TABLE employees ADD COLUMN bank_name VARCHAR(120)", + "bank_account_no": "ALTER TABLE employees ADD COLUMN bank_account_no VARCHAR(80)", + "bank_account_name": "ALTER TABLE employees ADD COLUMN bank_account_name VARCHAR(100)", +} + + +def ensure_employee_schema(db: Session) -> None: + bind = db.get_bind() + inspector = inspect(bind) + if "employees" not in inspector.get_table_names(): + return + + column_names = {column["name"] for column in inspector.get_columns("employees")} + for column_name, ddl in EMPLOYEE_SCHEMA_COLUMNS.items(): + if column_name not in column_names: + db.execute(text(ddl)) + db.flush() diff --git a/server/src/app/services/employee_serialization.py b/server/src/app/services/employee_serialization.py index b6be6a1..bfc1672 100644 --- a/server/src/app/services/employee_serialization.py +++ b/server/src/app/services/employee_serialization.py @@ -62,6 +62,9 @@ def serialize_employee( joinDate=format_date(employee.join_date), location=employee.location, costCenter=employee.cost_center, + bankName=employee.bank_name, + bankAccountNo=employee.bank_account_no, + bankAccountName=employee.bank_account_name, updatedAt=format_datetime(employee.updated_at or employee.created_at), lastSync=format_datetime(employee.last_sync_at), syncState=employee.sync_state, diff --git a/server/src/app/services/employee_spreadsheet.py b/server/src/app/services/employee_spreadsheet.py index f2fd135..f1c3aca 100644 --- a/server/src/app/services/employee_spreadsheet.py +++ b/server/src/app/services/employee_spreadsheet.py @@ -26,6 +26,9 @@ EMPLOYEE_HEADERS: tuple[str, ...] = ( "直属上级工号", "财务归口", "成本中心", + "银行户名", + "开户行", + "银行账号", "在职状态*", "角色编码", ) @@ -45,6 +48,9 @@ HEADER_TO_FIELD: dict[str, str] = { "直属上级工号": "manager_employee_no", "财务归口": "finance_owner_name", "成本中心": "cost_center", + "银行户名": "bank_account_name", + "开户行": "bank_name", + "银行账号": "bank_account_no", "在职状态*": "employment_status", "角色编码": "role_codes", } @@ -72,6 +78,9 @@ class EmployeeImportRow: manager_employee_no: str | None finance_owner_name: str | None cost_center: str | None + bank_account_name: str | None + bank_name: str | None + bank_account_no: str | None employment_status: str role_codes: list[str] @@ -107,6 +116,9 @@ def build_import_template_bytes() -> bytes: ("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"), ("财务归口", "可选。"), ("成本中心", "可选。"), + ("银行户名", "可选,留空时默认使用员工姓名。"), + ("开户行", "可选,留空时使用系统默认演示开户行。"), + ("银行账号", "可选,留空时系统按员工编号生成演示账号。"), ("在职状态*", "必填:在职、试用中、停用。"), ("角色编码", "可选,多个角色用英文逗号分隔,例如 user,finance;留空默认为 user。"), ("导入规则", "全部校验通过后才写入数据库;任一行有错则整批不导入,原有数据保持不变。"), @@ -319,6 +331,9 @@ def _parse_data_row( manager_employee_no=values["manager_employee_no"] or None, finance_owner_name=values["finance_owner_name"] or None, cost_center=values["cost_center"] or None, + bank_account_name=values["bank_account_name"] or None, + bank_name=values["bank_name"] or None, + bank_account_no=values["bank_account_no"] or None, employment_status=employment_status, role_codes=role_codes or list(DEFAULT_ROLE_CODES), ), diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 4eb04e1..0e84013 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -17,6 +17,8 @@ from app.services.expense_claim_workflow_constants import ( BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE, + PAYMENT_PAID_STAGE, + PAYMENT_PENDING_STATUS, ) @@ -29,6 +31,7 @@ BUDGET_MONITOR_APPROVAL_GRADE = "P8" CLAIM_DELETE_ROLE_CODES = {"executive"} ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed") +ARCHIVED_REIMBURSEMENT_STAGES = (ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE, "completed") class ExpenseClaimAccessPolicy: @@ -60,7 +63,7 @@ class ExpenseClaimAccessPolicy: normalized_type.like("%\\_application", escape="\\"), ) return or_( - stage == ARCHIVE_ACCOUNTING_STAGE, + stage.in_(ARCHIVED_REIMBURSEMENT_STAGES), stage == "completed", and_( application_condition, @@ -72,7 +75,7 @@ class ExpenseClaimAccessPolicy: or_( stage == "", stage.is_(None), - stage == ARCHIVE_ACCOUNTING_STAGE, + stage.in_(ARCHIVED_REIMBURSEMENT_STAGES), stage == "completed", ), ), @@ -88,7 +91,7 @@ class ExpenseClaimAccessPolicy: def is_archived_claim(claim: ExpenseClaim) -> bool: normalized_status = str(claim.status or "").strip().lower() stage = str(claim.approval_stage or "").strip() - if stage in {ARCHIVE_ACCOUNTING_STAGE, "completed"}: + if stage in set(ARCHIVED_REIMBURSEMENT_STAGES): return True normalized_type = str(claim.expense_type or "").strip().lower() claim_no = str(claim.claim_no or "").strip().upper() @@ -103,7 +106,7 @@ class ExpenseClaimAccessPolicy: and stage in APPLICATION_ARCHIVED_STAGES ): return True - return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", ARCHIVE_ACCOUNTING_STAGE, "completed"} + return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES} def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: normalized_status = str(claim.status or "").strip().lower() @@ -136,6 +139,15 @@ class ExpenseClaimAccessPolicy: ) return False + def can_mark_claim_paid(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: + if str(claim.status or "").strip().lower() != PAYMENT_PENDING_STATUS: + return False + if self.is_claim_owned_by_current_user(claim, current_user): + return False + if current_user.is_admin: + return True + return bool(self.normalize_role_codes(current_user) & PRIVILEGED_CLAIM_ROLE_CODES) + def is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: role_codes = self.normalize_role_codes(current_user) if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES): diff --git a/server/src/app/services/expense_claim_approval_flow.py b/server/src/app/services/expense_claim_approval_flow.py index 4ff3a7e..133fcec 100644 --- a/server/src/app/services/expense_claim_approval_flow.py +++ b/server/src/app/services/expense_claim_approval_flow.py @@ -7,10 +7,13 @@ from typing import Any from app.api.deps import CurrentUserContext from app.services.expense_claim_workflow_constants import ( APPROVAL_DONE_STAGE, - ARCHIVE_ACCOUNTING_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE, + PAYMENT_PAID_STAGE, + PAYMENT_PAID_STATUS, + PAYMENT_PENDING_STAGE, + PAYMENT_PENDING_STATUS, ) @@ -67,9 +70,9 @@ class ExpenseClaimApprovalFlowMixin: approval_source = "finance_approval" event_type = "expense_claim_finance_approval" label = "财务审核通过" - next_status = "approved" - next_stage = ARCHIVE_ACCOUNTING_STAGE - default_message = "{operator} 已完成财务审核,进入归档入账。" + next_status = PAYMENT_PENDING_STATUS + next_stage = PAYMENT_PENDING_STAGE + default_message = "{operator} 已完成财务审核,进入待付款。" else: raise ValueError("当前节点不支持审批通过。") @@ -160,6 +163,65 @@ class ExpenseClaimApprovalFlowMixin: return claim + def mark_claim_paid( + self, + claim_id: str, + current_user: CurrentUserContext, + ): + claim = self.get_claim(claim_id, current_user) + if claim is None: + return None + + normalized_status = str(claim.status or "").strip().lower() + if normalized_status == PAYMENT_PAID_STATUS: + raise ValueError("该报销单已付款,无需重复确认。") + if normalized_status != PAYMENT_PENDING_STATUS: + raise ValueError("只有待付款状态的报销单可以确认已付款。") + if not self._access_policy.can_mark_claim_paid(current_user, claim): + raise ValueError("只有财务人员或高级财务人员可以确认付款,且不能处理本人单据。") + + before_json = self._serialize_claim(claim) + operator = self._access_policy.resolve_current_user_display_name(current_user) + previous_stage = str(claim.approval_stage or "").strip() + payment_flag = { + "source": "payment", + "event_type": "expense_claim_payment_completed", + "payment_event_id": str(uuid.uuid4()), + "severity": "info", + "label": "付款完成", + "message": f"{operator} 已确认付款,报销单进入已付款。", + "operator": operator, + "operator_username": current_user.username, + "operator_role_codes": [ + str(item).strip().lower() + for item in current_user.role_codes + if str(item).strip() + ], + "previous_status": str(claim.status or "").strip(), + "previous_approval_stage": previous_stage, + "next_status": PAYMENT_PAID_STATUS, + "next_approval_stage": PAYMENT_PAID_STAGE, + "created_at": datetime.now(UTC).isoformat(), + } + + claim.status = PAYMENT_PAID_STATUS + claim.approval_stage = PAYMENT_PAID_STAGE + claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag] + + self.db.commit() + self.db.refresh(claim) + + self.audit_service.log_action( + actor=operator, + action="expense_claim.mark_paid", + resource_type="expense_claim", + resource_id=claim.id, + before_json=before_json, + after_json=self._serialize_claim(claim), + ) + + return claim + @staticmethod def _resolve_latest_approval_opinion(claim, *, source: str) -> str: for flag in reversed(list(claim.risk_flags_json or [])): diff --git a/server/src/app/services/expense_claim_workflow_constants.py b/server/src/app/services/expense_claim_workflow_constants.py index 74d3ad7..47a8f66 100644 --- a/server/src/app/services/expense_claim_workflow_constants.py +++ b/server/src/app/services/expense_claim_workflow_constants.py @@ -3,4 +3,7 @@ BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批" FINANCE_APPROVAL_STAGE = "财务审批" APPROVAL_DONE_STAGE = "审批完成" ARCHIVE_ACCOUNTING_STAGE = "归档入账" - +PAYMENT_PENDING_STATUS = "pending_payment" +PAYMENT_PAID_STATUS = "paid" +PAYMENT_PENDING_STAGE = "待付款" +PAYMENT_PAID_STAGE = "已付款" diff --git a/server/src/app/services/hermes_employee_profile_scanner.py b/server/src/app/services/hermes_employee_profile_scanner.py new file mode 100644 index 0000000..e3bfb93 --- /dev/null +++ b/server/src/app/services/hermes_employee_profile_scanner.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import json + +from sqlalchemy.orm import Session + +from app.core.logging import get_logger +from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService + +logger = get_logger("app.services.hermes_employee_profile_scanner") + + +class HermesEmployeeProfileScannerService: + def __init__(self, db: Session) -> None: + self.db = db + + def scan_employee_profiles(self, log_id: str | None = None) -> dict: + logger.info("Starting Hermes employee behavior profile scan...") + summary = EmployeeBehaviorProfileService(self.db).scan_profiles(log_id=log_id) + logger.info( + "Hermes employee profile scan completed: %s", + json.dumps(summary, ensure_ascii=False), + ) + return summary diff --git a/server/src/app/services/hermes_scheduler.py b/server/src/app/services/hermes_scheduler.py index bc70c96..fa0e497 100644 --- a/server/src/app/services/hermes_scheduler.py +++ b/server/src/app/services/hermes_scheduler.py @@ -1,8 +1,6 @@ -import logging import threading -import time -from datetime import datetime, timezone import traceback +from datetime import UTC, datetime, timedelta from sqlalchemy import select from sqlalchemy.orm import Session @@ -10,8 +8,9 @@ from sqlalchemy.orm import Session from app.core.logging import get_logger from app.db.session import get_session_factory from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog -from app.services.hermes_risk_scanner import HermesRiskScannerService +from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService from app.services.hermes_expense_report import HermesExpenseReportService +from app.services.hermes_risk_scanner import HermesRiskScannerService logger = get_logger("app.services.hermes_scheduler") @@ -52,7 +51,7 @@ class HermesScheduler: self._check_and_run_tasks() except Exception as e: logger.error(f"Error in Hermes run loop: {e}", exc_info=True) - + # 睡眠一分钟,每分钟轮询一次 if self._stop_event.wait(60.0): break @@ -61,50 +60,95 @@ class HermesScheduler: db = self.session_factory() try: # 获取所有启用的任务配置 - stmt = select(HermesTaskConfig).where(HermesTaskConfig.is_enabled == True) + stmt = select(HermesTaskConfig).where(HermesTaskConfig.is_enabled) configs = db.scalars(stmt).all() - + for config in configs: if self._should_run_now(db, config): self._execute_task(db, config) - + finally: db.close() - + def _should_run_now(self, db: Session, config: HermesTaskConfig) -> bool: - # 简单策略:检查是否在过去24小时内运行过。 - # 如果没有 croniter 库,我们暂时采用按天执行的简化逻辑 - stmt = select(HermesTaskExecutionLog).where( - HermesTaskExecutionLog.config_id == config.id, - HermesTaskExecutionLog.status.in_(["success", "running"]) - ).order_by(HermesTaskExecutionLog.started_at.desc()).limit(1) - + scheduled_at = self._resolve_last_scheduled_at(config.cron_expression) + stmt = ( + select(HermesTaskExecutionLog) + .where( + HermesTaskExecutionLog.config_id == config.id, + HermesTaskExecutionLog.status.in_(["success", "running"]), + ) + .order_by(HermesTaskExecutionLog.started_at.desc()) + .limit(1) + ) + last_log = db.scalars(stmt).first() - + if not last_log: - return True # 从未执行过,立即执行 - - now = datetime.now(timezone.utc) - elapsed_hours = (now - last_log.started_at).total_seconds() / 3600 - - # 简化:只要距离上次成功执行超过了 23.5 小时,就认为该跑了(模拟每天跑一次) - if elapsed_hours >= 23.5: return True - - return False + + return last_log.started_at < scheduled_at + + def _resolve_last_scheduled_at(self, cron_expression: str | None) -> datetime: + now = datetime.now(UTC) + parsed = self._parse_simple_cron(cron_expression) + if parsed is None: + return now - timedelta(hours=23.5) + + minute, hour, weekday = parsed + scheduled_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if weekday is None: + if scheduled_at > now: + scheduled_at -= timedelta(days=1) + return scheduled_at + + days_back = (now.weekday() - weekday) % 7 + scheduled_at = (now - timedelta(days=days_back)).replace( + hour=hour, + minute=minute, + second=0, + microsecond=0, + ) + if scheduled_at > now: + scheduled_at -= timedelta(days=7) + return scheduled_at + + def _parse_simple_cron(self, cron_expression: str | None) -> tuple[int, int, int | None] | None: + parts = str(cron_expression or "").strip().split() + if len(parts) < 5: + return None + minute = self._parse_cron_number(parts[0], minimum=0, maximum=59) + hour = self._parse_cron_number(parts[1], minimum=0, maximum=23) + if minute is None or hour is None: + return None + + weekday: int | None = None + if parts[4] != "*": + raw_weekday = self._parse_cron_number(parts[4], minimum=0, maximum=7) + if raw_weekday is None: + return None + weekday = 6 if raw_weekday in {0, 7} else raw_weekday - 1 + return minute, hour, weekday + + @staticmethod + def _parse_cron_number(value: str, *, minimum: int, maximum: int) -> int | None: + try: + parsed = int(str(value).strip()) + except ValueError: + return None + if parsed < minimum or parsed > maximum: + return None + return parsed def _execute_task(self, db: Session, config: HermesTaskConfig) -> None: logger.info(f"Triggering Hermes task: {config.task_type} (Config ID: {config.id})") - + # 创建执行日志,标记为 running - log_record = HermesTaskExecutionLog( - config_id=config.id, - status="running" - ) + log_record = HermesTaskExecutionLog(config_id=config.id, status="running") db.add(log_record) db.commit() db.refresh(log_record) - + try: if config.task_type == "global_risk_scan": scanner = HermesRiskScannerService(db) @@ -112,17 +156,26 @@ class HermesScheduler: elif config.task_type == "weekly_expense_report": reporter = HermesExpenseReportService(db) reporter.generate_weekly_report(log_id=log_record.id) - + elif config.task_type == "employee_behavior_profile_scan": + scanner = HermesEmployeeProfileScannerService(db) + summary = scanner.scan_employee_profiles(log_id=log_record.id) + log_record.result_summary = ( + f"员工画像巡检完成:目标 {summary.get('target_employee_count', 0)} 人," + f"生成 {summary.get('snapshot_count', 0)} 条快照," + f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。" + ) + log_record.status = "success" - log_record.completed_at = datetime.now(timezone.utc) - log_record.result_summary = "Task executed successfully." - + log_record.completed_at = datetime.now(UTC) + if not log_record.result_summary: + log_record.result_summary = "Task executed successfully." + except Exception as e: logger.error(f"Failed to execute Hermes task {config.task_type}: {e}") log_record.status = "failed" - log_record.completed_at = datetime.now(timezone.utc) + log_record.completed_at = datetime.now(UTC) log_record.error_trace = traceback.format_exc() - + finally: db.commit() diff --git a/server/src/app/services/hermes_sync.py b/server/src/app/services/hermes_sync.py index 5cf8809..5e830f6 100644 --- a/server/src/app/services/hermes_sync.py +++ b/server/src/app/services/hermes_sync.py @@ -9,7 +9,7 @@ from typing import Any import yaml -from app.core.config import ROOT_DIR +from app.core.config import ROOT_DIR, SERVER_DIR @dataclass(frozen=True, slots=True) @@ -43,18 +43,28 @@ def sync_repository_hermes_skills( source_root: Path | None = None, target_root: Path | None = None, ) -> Path: - source = source_root or ROOT_DIR / "hermes" / "skills" target = target_root or get_hermes_home() / "skills" - if not source.exists(): + sources = ( + (source_root,) + if source_root is not None + else ( + SERVER_DIR / "src" / "app" / "skills", + ROOT_DIR / "hermes" / "skills", + ) + ) + + existing_sources = [source for source in sources if source and source.exists()] + if not existing_sources: return target target.mkdir(parents=True, exist_ok=True) - for item in source.iterdir(): - destination = target / item.name - if item.is_dir(): - shutil.copytree(item, destination, dirs_exist_ok=True) - elif item.is_file(): - shutil.copy2(item, destination) + for source in existing_sources: + for item in source.iterdir(): + destination = target / item.name + if item.is_dir(): + shutil.copytree(item, destination, dirs_exist_ok=True) + elif item.is_file(): + shutil.copy2(item, destination) return target diff --git a/server/src/app/services/knowledge_sync.py b/server/src/app/services/knowledge_sync.py index 2c94cf2..2b1b7ee 100644 --- a/server/src/app/services/knowledge_sync.py +++ b/server/src/app/services/knowledge_sync.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunSource, AgentRunStatus from app.models.agent_asset import AgentAsset +from app.services.agent_foundation_constants import DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE from app.services.agent_runs import AgentRunService from app.services.knowledge import ( KNOWLEDGE_INGEST_STATUS_FAILED, @@ -109,7 +110,7 @@ class KnowledgeSyncDispatchService: ) task_asset = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == "task.hermes.knowledge_index_sync") + select(AgentAsset).where(AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE) ) run = self.run_service.create_run( agent=AgentName.HERMES.value, diff --git a/server/src/app/services/orchestrator_expense_query.py b/server/src/app/services/orchestrator_expense_query.py index bfc30cf..f4f5579 100644 --- a/server/src/app/services/orchestrator_expense_query.py +++ b/server/src/app/services/orchestrator_expense_query.py @@ -27,9 +27,11 @@ EXPENSE_STATUS_LABELS = { "review": "审核中", "approved": "已通过", "rejected": "已驳回", + "pending_payment": "待付款", "paid": "归档", } EXPENSE_QUERY_STATUS_KEYWORDS = ( + (("待付款", "待支付", "待打款"), ("pending_payment",)), (("归档", "已归档", "入账", "已入账", "已付款"), ("archived",)), (("审批通过", "审核通过", "已通过", "已审核"), ("approved",)), (("审批中", "审核中", "进行中", "流程中"), ("submitted", "review")), @@ -48,6 +50,9 @@ EXPENSE_STATUS_ALIASES = { "审批通过": "approved", "审核通过": "approved", "已审核": "approved", + "待付款": "pending_payment", + "待支付": "pending_payment", + "待打款": "pending_payment", "审批中": "review", "审核中": "review", "进行中": "review", @@ -65,10 +70,11 @@ EXPENSE_STATUS_ALIASES = { EXPENSE_STATUS_GROUP_LABELS = { "draft": "草稿", "in_progress": "审批中", + "pending_payment": "待付款", "completed": "审批完成", "other": "其他状态", } -EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other") +EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "pending_payment", "completed", "other") EXPENSE_RISK_LEVEL_LABELS = { "high": "高风险", "medium": "中风险", @@ -348,6 +354,8 @@ class OrchestratorDatabaseQueryBuilder: return "draft", EXPENSE_STATUS_GROUP_LABELS["draft"] if normalized in {"submitted", "review"}: return "in_progress", EXPENSE_STATUS_GROUP_LABELS["in_progress"] + if normalized == "pending_payment": + return "pending_payment", EXPENSE_STATUS_GROUP_LABELS["pending_payment"] if normalized in {"approved", "paid"}: return "completed", EXPENSE_STATUS_GROUP_LABELS["completed"] return "other", EXPENSE_STATUS_GROUP_LABELS["other"] diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index 33b4d7d..10c3e98 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -707,10 +707,10 @@ class SettingsService: parts = time_str.split(":") if len(parts) == 2: # 简单映射:把时分放进去,后面保留为 * * * (或者保留旧的后半段) - # 这里偷个懒,风险扫描每天跑,周报每周一跑 + # 这里偷个懒,风险扫描每天跑,周报和员工画像默认每周一跑 if task_type == "global_risk_scan": config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *" - elif task_type == "weekly_expense_report": + elif task_type in {"weekly_expense_report", "employee_behavior_profile_scan"}: config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * 1" else: config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *" diff --git a/server/src/app/skills/domain/finance-policy-knowledge-organizer/SKILL.md b/server/src/app/skills/domain/finance-policy-knowledge-organizer/SKILL.md new file mode 100644 index 0000000..01248e1 --- /dev/null +++ b/server/src/app/skills/domain/finance-policy-knowledge-organizer/SKILL.md @@ -0,0 +1,45 @@ +--- +name: finance-policy-knowledge-organizer +description: 用于整理公司财务知识制度,把制度文件、报销口径、审批要求和知识库更新沉淀为可检索、可引用、可复核的结构化知识。 +--- + +# 整理公司财务知识制度 + +## 使用场景 + +当任务要求整理公司财务制度、报销政策、审批口径、票据要求、预算规范或知识库资料时,使用该能力。 + +## 工作目标 + +- 读取指定范围内的财务制度、知识库文档和变更材料。 +- 按制度主题、费用类型、审批阶段、票据要求和风险口径进行归类。 +- 抽取可引用的条款、适用范围、例外条件、执行口径和待确认问题。 +- 保留原始来源,不覆盖制度原文,不直接发布线上规则。 +- 输出管理员可复核的知识整理结果。 + +## 处理步骤 + +1. 确认整理范围,包括文件夹、文档、变更时间和是否只处理增量内容。 +2. 建立目录结构,优先按费用类型、审批场景和制度主题归档。 +3. 提取制度条款,保留条款标题、正文摘要、适用对象、触发条件和来源位置。 +4. 标记冲突、缺失或需要人工确认的口径,避免自行补写制度结论。 +5. 生成结构化结果,包含知识条目、来源索引、归类标签和复核建议。 + +## 输出要求 + +输出应包含: + +- `summary`:本次整理概况。 +- `categories`:制度主题和费用类型分类。 +- `knowledge_items`:可复核的知识条目。 +- `source_refs`:来源文件、章节或页码。 +- `open_questions`:需要管理员确认的问题。 +- `next_actions`:后续维护建议。 + +## 执行约束 + +- 不凭空编造制度内容。 +- 不把未确认内容写成正式规则。 +- 不直接修改原始制度文件。 +- 对金额、期限、城市档位和审批权限等高风险字段必须保留来源。 +- 没有明确来源时,只能标记为待确认。 diff --git a/server/tests/test_employee_behavior_profile_algorithm.py b/server/tests/test_employee_behavior_profile_algorithm.py new file mode 100644 index 0000000..59d1141 --- /dev/null +++ b/server/tests/test_employee_behavior_profile_algorithm.py @@ -0,0 +1,177 @@ +from decimal import Decimal + +from app.algorithem.employee_behavior_profile import ( + ProfileComponent, + build_review_suggestions, + calculate_review_priority_score, + evaluate_weighted_profile, + level_from_score, + normalize_by_peer_percentiles, + percentile, +) +from app.algorithem.employee_behavior_profile_tags import build_profile_radar, build_profile_tags + + +def test_peer_percentile_normalization_and_level_mapping() -> None: + assert percentile([10, 20, 30, 40, 50], 90) == Decimal("46.0") + assert normalize_by_peer_percentiles(35, 20, 50) == 50 + assert normalize_by_peer_percentiles(10, 20, 50) == 0 + assert level_from_score(82) == "escalation" + + +def test_weighted_profile_uses_component_weights() -> None: + result = evaluate_weighted_profile( + "expense", + [ + ProfileComponent("frequency_score", "申请频次", 80, weight=Decimal("0.20")), + ProfileComponent("amount_occupancy_score", "预算占用", 60, weight=Decimal("0.30")), + ProfileComponent("peer_deviation_score", "同组偏离", 40, weight=Decimal("0.50")), + ], + ) + + assert result.profile_score == 54 + assert result.profile_level == "watch" + assert result.top_contributors(1)[0]["code"] == "peer_deviation_score" + + +def test_review_priority_excludes_ai_usage_score() -> None: + assert ( + calculate_review_priority_score( + expense_profile_score=80, + process_quality_score=20, + ) + == 62 + ) + assert calculate_review_priority_score( + expense_profile_score=80, + process_quality_score=20, + ) == calculate_review_priority_score( + expense_profile_score=80, + process_quality_score=20, + ) + + +def test_review_suggestions_generate_caps_without_auto_penalty() -> None: + suggestions = build_review_suggestions( + expense_profile_score=72, + process_quality_score=65, + requested_days=Decimal("5"), + peer_days_p75=Decimal("3"), + policy_limit=Decimal("800"), + peer_unit_amount_p75=Decimal("600"), + ) + + types = {item["type"] for item in suggestions} + assert "review_travel_days" in types + assert "review_entertainment_unit_amount" in types + assert any(item["recommended_upper"] == "3" for item in suggestions) + + +def test_profile_tags_and_approval_radar_use_quantified_evidence() -> None: + profiles = [ + { + "profile_type": "expense", + "score": 82, + "level": "escalation", + "metrics": { + "window_days": 90, + "expense_type_scope": "travel", + "peer_sample_size": 20, + "amount_total": "128000", + "amount_share": "0.34", + "claim_count": 6, + "current_claim_amount": "56000", + "requested_days": "5", + "peer_days_p75": "3", + }, + "top_contributors": [ + {"code": "amount_occupancy_score", "score": 90}, + {"code": "peer_deviation_score", "score": 88}, + {"code": "current_claim_deviation_score", "score": 86}, + {"code": "frequency_score", "score": 84}, + ], + }, + { + "profile_type": "process_quality", + "score": 68, + "level": "review", + "metrics": { + "peer_sample_size": 20, + "return_count": 2, + "missing_attachment_count": 3, + "invoice_mismatch_count": 1, + "missing_business_context_count": 2, + }, + "top_contributors": [ + {"code": "return_count_score", "score": 70}, + {"code": "missing_attachment_score", "score": 75}, + {"code": "invoice_mismatch_score", "score": 60}, + ], + }, + ] + + tags = build_profile_tags(profiles, scene="approval") + tag_codes = {item["code"] for item in tags} + assert {"expense_king", "large_amount_deviation", "return_frequent"} <= tag_codes + assert all(item["evidence"] for item in tags) + + radar = build_profile_radar(profiles, tags, scene="approval") + dimensions = {item["code"]: item for item in radar["dimensions"]} + assert set(dimensions) == { + "expense_intensity", + "application_rhythm", + "travel_entertainment", + "material_completeness", + "process_pressure", + } + assert dimensions["expense_intensity"]["score"] >= 70 + assert "expense_king" in dimensions["expense_intensity"]["top_tags"] + + +def test_profile_tags_include_ai_and_approval_traits_outside_approval_scene() -> None: + profiles = [ + { + "profile_type": "ai_usage", + "score": 72, + "level": "review", + "metrics": { + "peer_sample_size": 15, + "ai_run_count": 14, + "tool_call_count": 10, + "failed_tool_call_count": 3, + "estimated_token_count": 22000, + "token_count_mode": "estimated_token_count", + }, + "top_contributors": [ + {"code": "ai_call_count_score", "score": 75}, + {"code": "token_cost_score", "score": 70}, + {"code": "failed_ai_call_score", "score": 80}, + ], + }, + { + "profile_type": "approval", + "score": 64, + "level": "review", + "metrics": { + "peer_sample_size": 12, + "approval_record_count": 6, + "direct_approve_ratio": "0.5", + "return_count": 3, + "sla_overdue_rate": "0.4", + }, + "top_contributors": [ + {"code": "system_advice_override_score", "score": 70}, + ], + }, + ] + + tags = build_profile_tags(profiles, scene="operations") + tag_codes = {item["code"] for item in tags} + assert {"ai_heavy", "token_high", "ai_failure_cluster", "cautious_reviewer"} <= tag_codes + + radar = build_profile_radar(profiles, tags, scene="operations") + assert len(radar["dimensions"]) == 8 + assert any( + item["code"] == "ai_collaboration" and item["score"] > 0 + for item in radar["dimensions"] + ) diff --git a/server/tests/test_employee_behavior_profile_service.py b/server/tests/test_employee_behavior_profile_service.py new file mode 100644 index 0000000..b928c2d --- /dev/null +++ b/server/tests/test_employee_behavior_profile_service.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_db +from app.db.base import Base +from app.main import create_app +from app.models.agent_run import AgentRun, AgentToolCall +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.employee_behavior_profile_service import EmployeeBehaviorProfileService +from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService +from app.services.hermes_scheduler import HermesScheduler + + +def build_session_factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def seed_profile_data(db: Session) -> None: + org = OrganizationUnit( + id="dept-sales", + unit_code="SALES", + name="市场部", + unit_type="department", + ) + employee = Employee( + id="emp-main", + employee_no="E1001", + name="张三", + email="zhangsan@example.com", + position="客户经理", + grade="P5", + organization_unit=org, + ) + peer_a = Employee( + id="emp-peer-a", + employee_no="E1002", + name="李四", + email="lisi@example.com", + position="客户经理", + grade="P5", + organization_unit=org, + ) + peer_b = Employee( + id="emp-peer-b", + employee_no="E1003", + name="王五", + email="wangwu@example.com", + position="客户经理", + grade="P5", + organization_unit=org, + ) + db.add_all([org, employee, peer_a, peer_b]) + now = datetime.now(UTC) + + claims = [ + _build_claim( + "claim-main-1", + employee.id, + employee.name, + Decimal("5000"), + now - timedelta(days=5), + missing_attachment=True, + ), + _build_claim( + "claim-main-2", + employee.id, + employee.name, + Decimal("4200"), + now - timedelta(days=15), + returned=True, + ), + _build_claim( + "claim-main-3", employee.id, employee.name, Decimal("3800"), now - timedelta(days=30) + ), + _build_claim( + "claim-peer-a", peer_a.id, peer_a.name, Decimal("1200"), now - timedelta(days=12) + ), + _build_claim( + "claim-peer-b", peer_b.id, peer_b.name, Decimal("1500"), now - timedelta(days=18) + ), + ] + db.add_all(claims) + db.add( + AgentRun( + run_id="run-main-1", + agent="hermes", + source="user_message", + user_id=employee.email, + status="success", + result_summary="AI 已辅助生成报销说明。", + started_at=now - timedelta(days=2), + tool_calls=[ + AgentToolCall( + run_id="run-main-1", + tool_type="expense", + tool_name="claim_draft", + request_json={"message": "出差报销"}, + response_json={"ok": True}, + status="success", + duration_ms=120, + ) + ], + ) + ) + db.commit() + + +def _build_claim( + claim_id: str, + employee_id: str, + employee_name: str, + amount: Decimal, + occurred_at: datetime, + *, + missing_attachment: bool = False, + returned: bool = False, +) -> ExpenseClaim: + invoice_id = None if missing_attachment else f"invoice-{claim_id}" + return ExpenseClaim( + id=claim_id, + claim_no=f"EXP-{claim_id}", + employee_id=employee_id, + employee_name=employee_name, + department_id="dept-sales", + department_name="市场部", + project_code="PRJ-001", + expense_type="travel", + reason="客户拜访出差", + location="北京", + amount=amount, + currency="CNY", + invoice_count=0 if missing_attachment else 1, + occurred_at=occurred_at, + submitted_at=occurred_at, + status="returned" if returned else "submitted", + approval_stage="直属领导审批", + risk_flags_json=[{"source": "manual_return", "message": "补充票据"}] if returned else [], + items=[ + ExpenseClaimItem( + id=f"item-{claim_id}", + claim_id=claim_id, + item_date=date.today(), + item_type="travel", + item_reason="客户拜访出差", + item_location="北京", + item_amount=amount, + invoice_id=invoice_id, + ) + ], + ) + + +def test_service_scans_snapshots_and_filters_approval_scene() -> None: + session_factory = build_session_factory() + with session_factory() as db: + seed_profile_data(db) + summary = HermesEmployeeProfileScannerService(db).scan_employee_profiles(log_id=None) + + assert summary["target_employee_count"] >= 1 + assert db.query(EmployeeBehaviorProfileSnapshot).count() >= 4 + + latest = EmployeeBehaviorProfileService(db).get_latest_profile( + employee_id="emp-main", + scene="approval", + claim_id="claim-main-1", + window_days=90, + expense_type_scope="travel", + ) + + assert latest.employee_id == "emp-main" + assert {item.profile_type for item in latest.profiles} == {"expense", "process_quality"} + assert latest.review_priority_score > 0 + assert latest.peer_group.sample_size >= 3 + assert latest.profile_tags + assert latest.radar.dimensions + + +def test_latest_profile_endpoint_returns_approval_payload() -> None: + session_factory = build_session_factory() + with session_factory() as db: + seed_profile_data(db) + + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + client = TestClient(app) + response = client.get( + "/api/v1/employee-profiles/emp-main/latest", + params={ + "scene": "approval", + "claim_id": "claim-main-1", + "window_days": 90, + "expense_type_scope": "travel", + }, + headers={"x-auth-username": "auditor", "x-auth-name": "auditor"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["employee_id"] == "emp-main" + assert {item["profile_type"] for item in payload["profiles"]} == {"expense", "process_quality"} + assert payload["review_priority_score"] >= 0 + assert payload["profile_tags"] + assert {item["code"] for item in payload["radar"]["dimensions"]} == { + "expense_intensity", + "application_rhythm", + "travel_entertainment", + "material_completeness", + "process_pressure", + } + + +def test_hermes_scheduler_parses_weekly_profile_cron() -> None: + scheduler = HermesScheduler() + + assert scheduler._parse_simple_cron("30 8 * * 1") == (30, 8, 0) + assert scheduler._parse_simple_cron("0 9 * * *") == (0, 9, None) + assert scheduler._parse_simple_cron("bad cron") is None diff --git a/server/tests/test_employee_service.py b/server/tests/test_employee_service.py index 882ccbe..15fd7a3 100644 --- a/server/tests/test_employee_service.py +++ b/server/tests/test_employee_service.py @@ -44,6 +44,7 @@ def test_employee_directory_seeds_rich_employee_data() -> None: assert any("审批负责人" in item.roles for item in employees) assert any(item.permissions for item in employees) assert any(item.history for item in employees) + assert all(item.bankName and item.bankAccountNo and item.bankAccountName for item in employees) role_count = db.scalar(select(func.count()).select_from(Role)) org_count = db.scalar(select(func.count()).select_from(OrganizationUnit)) @@ -84,6 +85,9 @@ def test_update_employee_persists_changes_and_hashes_password() -> None: grade="P6", finance_owner_name="共享财务中心", cost_center="CC-TEST-01", + bank_account_name="测试员工A", + bank_name="招商银行上海分行", + bank_account_no="622588000000000001", role_codes=["finance", "user"], password="12345", ), @@ -98,6 +102,9 @@ def test_update_employee_persists_changes_and_hashes_password() -> None: assert updated.grade == "P6" assert updated.financeOwner == "共享财务中心" assert updated.costCenter == "CC-TEST-01" + assert updated.bankAccountName == "测试员工A" + assert updated.bankName == "招商银行上海分行" + assert updated.bankAccountNo == "622588000000000001" assert updated.roleCodes == ["finance", "user"] assert persisted is not None assert persisted.password_hash is not None diff --git a/server/tests/test_employee_spreadsheet_import.py b/server/tests/test_employee_spreadsheet_import.py index cb6fcbe..3432ab8 100644 --- a/server/tests/test_employee_spreadsheet_import.py +++ b/server/tests/test_employee_spreadsheet_import.py @@ -59,6 +59,9 @@ def test_import_employees_rejects_invalid_row_without_writing() -> None: "", "", "", + "", + "", + "", "在职", "user", ] @@ -98,6 +101,9 @@ def test_import_employees_updates_existing_employee() -> None: "", "华东财务组", "CC-TEST", + "导入户名", + "招商银行上海分行", + "622588000000000002", "在职", "user", ] @@ -112,6 +118,9 @@ def test_import_employees_updates_existing_employee() -> None: assert updated is not None assert updated.name == new_name assert updated.phone == "13900000001" + assert updated.bankAccountName == "导入户名" + assert updated.bankName == "招商银行上海分行" + assert updated.bankAccountNo == "622588000000000002" def test_import_employees_creates_new_employee() -> None: @@ -136,6 +145,9 @@ def test_import_employees_creates_new_employee() -> None: "E10234", "华东财务组", "CC-9001", + "", + "", + "", "在职", "user", ] @@ -151,3 +163,6 @@ def test_import_employees_creates_new_employee() -> None: ).scalar_one() assert imported.name == "导入新员工" assert imported.email == "import.new.user@xfinance.com" + assert imported.bank_account_name == "导入新员工" + assert imported.bank_name + assert imported.bank_account_no diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 2515ef2..df3b4df 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -2680,6 +2680,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() -> approval_stage="财务审批", risk_flags_json=[], ), + ExpenseClaim( + claim_no="EXP-ARCH-PAID", + employee_name="丙", + department_name="C部", + project_code="PRJ-C", + expense_type="office", + reason="C 报销", + location="深圳", + amount=Decimal("180.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC), + status="paid", + approval_stage="已付款", + risk_flags_json=[], + ), ExpenseClaim( claim_no="AP-20260525120000-ABCDEFGH", employee_name="丙", @@ -2722,6 +2739,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() -> assert {claim.claim_no for claim in claims} == { "EXP-ARCH-101", + "EXP-ARCH-PAID", "AP-20260525120000-ABCDEFGH", } @@ -3894,7 +3912,7 @@ def test_finance_cannot_operate_own_claim_in_finance_stage() -> None: assert claim.risk_flags_json == [] -def test_finance_can_approve_claim_to_archive_stage() -> None: +def test_finance_can_approve_claim_to_pending_payment_stage() -> None: current_user = CurrentUserContext( username="finance-approve@example.com", name="财务复核", @@ -3931,19 +3949,65 @@ def test_finance_can_approve_claim_to_archive_stage() -> None: ) assert approved is not None - assert approved.status == "approved" - assert approved.approval_stage == "归档入账" + assert approved.status == "pending_payment" + assert approved.approval_stage == "待付款" assert any( isinstance(flag, dict) and flag.get("source") == "finance_approval" and flag.get("event_type") == "expense_claim_finance_approval" and flag.get("opinion") == "票据与明细一致,同意入账。" and flag.get("previous_approval_stage") == "财务审批" - and flag.get("next_approval_stage") == "归档入账" + and flag.get("next_status") == "pending_payment" + and flag.get("next_approval_stage") == "待付款" for flag in approved.risk_flags_json ) +def test_finance_can_mark_pending_payment_claim_as_paid() -> None: + current_user = CurrentUserContext( + username="finance-pay@example.com", + name="财务付款", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + claim = ExpenseClaim( + claim_no="EXP-FIN-PAY-201", + employee_name="张三", + department_name="市场部", + project_code="PRJ-A", + expense_type="transport", + reason="交通报销", + location="上海", + amount=Decimal("66.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC), + status="pending_payment", + approval_stage="待付款", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + paid = ExpenseClaimService(db).mark_claim_paid(claim.id, current_user) + + assert paid is not None + assert paid.status == "paid" + assert paid.approval_stage == "已付款" + assert any( + isinstance(flag, dict) + and flag.get("source") == "payment" + and flag.get("event_type") == "expense_claim_payment_completed" + and flag.get("previous_status") == "pending_payment" + and flag.get("next_status") == "paid" + and flag.get("next_approval_stage") == "已付款" + for flag in paid.risk_flags_json + ) + + def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None: current_user = CurrentUserContext( username="finance-returned@example.com", diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 399e2cf..12bab8f 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -364,7 +364,7 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() assert "manager-approve-api@example.com" not in approval_events[0]["message"] -def test_approve_application_endpoint_completes_after_direct_manager_review() -> None: +def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None: client, session_factory = build_client() with session_factory() as db: manager = Employee( @@ -415,15 +415,15 @@ def test_approve_application_endpoint_completes_after_direct_manager_review() -> assert response.status_code == 200 payload = response.json() - assert payload["status"] == "approved" - assert payload["approval_stage"] == "审批完成" + assert payload["status"] == "submitted" + assert payload["approval_stage"] == "预算管理者审批" assert any( item["source"] == "manual_approval" and item["event_type"] == "expense_application_approval" and item["opinion"] == "业务必要,同意申请。" and item["operator"] == "李经理" - and item["next_status"] == "approved" - and item["next_approval_stage"] == "审批完成" + and item["next_status"] == "submitted" + and item["next_approval_stage"] == "预算管理者审批" for item in payload["risk_flags_json"] ) diff --git a/web/UI/topbar.png b/web/UI/topbar.png new file mode 100644 index 0000000..d9b6cc7 Binary files /dev/null and b/web/UI/topbar.png differ diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 4635684..771b684 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -166,7 +166,8 @@ padding: 20px 24px; } .workarea.workbench-workarea { - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; padding: 12px 14px 14px; } .workarea.settings-workarea { @@ -191,18 +192,63 @@ } .app-sidebar { - width: var(--sidebar-collapsed-width); - flex: 0 0 var(--sidebar-collapsed-width); - transition: none; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 1000; + width: min(320px, 80vw); + max-width: none; + transform: translateX(-100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .app.mobile-sidebar-open .app-sidebar { + transform: translateX(0); } .app > .main { - flex: 1 1 auto; - width: calc(100vw - var(--sidebar-collapsed-width)); + flex: 1 1 100%; + width: 100vw; } .workarea { padding: 18px 16px 28px; } .workarea.workbench-workarea { overflow: auto; padding: 14px; } + + .mobile-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 999; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + backdrop-filter: blur(2px); + } + + .app.mobile-sidebar-open .mobile-overlay { + opacity: 1; + pointer-events: auto; + } + + .mobile-hamburger-btn { + position: fixed; + top: 14px; + right: 16px; + z-index: 90; + width: 40px; + height: 40px; + border-radius: 8px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-primary); + font-size: 24px; + cursor: pointer; + } } @media (prefers-reduced-motion: reduce) { diff --git a/web/src/assets/styles/components/personal-workbench-insights.css b/web/src/assets/styles/components/personal-workbench-insights.css index bd87e19..acb30e7 100644 --- a/web/src/assets/styles/components/personal-workbench-insights.css +++ b/web/src/assets/styles/components/personal-workbench-insights.css @@ -7,8 +7,8 @@ } .side-panel { - padding: 10px 12px; - gap: 6px; + padding: 12px; + gap: 8px; } .side-panel .section-head { @@ -29,17 +29,24 @@ align-items: center; justify-content: center; gap: 4px; - min-height: 24px; - padding: 0; - border: 0; + min-height: 26px; + padding: 0 6px; + border: 1px solid transparent; + border-radius: 4px; background: transparent; color: var(--workbench-muted); font-size: 13px; font-weight: 800; white-space: nowrap; + transition: + border-color 180ms var(--ease), + background-color 180ms var(--ease), + color 180ms var(--ease); } .detail-action:hover { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18); + background: var(--workbench-primary-soft); color: var(--workbench-primary-active); } @@ -58,6 +65,7 @@ .insight-profile-list { min-height: 0; display: grid; + gap: 6px; grid-auto-rows: minmax(0, 1fr); overflow: hidden; } @@ -69,13 +77,20 @@ align-items: center; gap: 10px; min-height: 0; - padding: 6px 0; - border-top: 1px solid var(--workbench-line-soft); + padding: 7px 9px; + border: 1px solid var(--workbench-line-soft); + border-left: 2px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3); + border-radius: 4px; + background: #ffffff; + transition: + border-color 180ms var(--ease), + background-color 180ms var(--ease); } -.insight-metric-row:first-child, -.insight-profile-card:first-child { - border-top: 0; +.insight-metric-row:hover, +.insight-profile-card:hover { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22); + background: #fbfdff; } .insight-metric-label, diff --git a/web/src/assets/styles/components/personal-workbench-responsive.css b/web/src/assets/styles/components/personal-workbench-responsive.css index 6936af0..bc81d0d 100644 --- a/web/src/assets/styles/components/personal-workbench-responsive.css +++ b/web/src/assets/styles/components/personal-workbench-responsive.css @@ -1,10 +1,11 @@ -/* 1080p / 小高度屏:进一步压缩 AI 助手卡片高度 */ -@media (max-height: 980px) { +/* 1080p / 小高度屏:进一步压缩 AI 助手卡片高度 (排除手机端) */ +@media (max-height: 980px) and (min-width: 761px) { .workbench { --hero-padding-top: 20px; --hero-padding-bottom: 12px; --hero-title-size: 28px; --hero-copy-gap: 5px; + --hero-title-bottom-gap: 14px; --composer-min-height: 108px; --composer-textarea-height: 48px; --composer-padding-block: 10px; @@ -71,6 +72,14 @@ font-size: 33px; } + .capability-grid--privileged { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .capability-grid--standard { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .capability-card { padding: 17px 12px 17px 22px; } @@ -95,6 +104,13 @@ } @media (max-width: 1180px) { + .workbench { + height: auto; + min-height: 100%; + grid-template-rows: auto auto auto; + gap: 12px; + } + .assistant-hero { --assistant-art-width: min(540px, 50vw); --assistant-art-x: 36px; @@ -108,10 +124,14 @@ width: min(820px, 92%); } - .capability-grid { + .capability-grid--privileged { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .capability-grid--standard { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .workbench-content-grid { grid-template-columns: 1fr; } @@ -169,6 +189,8 @@ } .capability-grid, + .capability-grid--privileged, + .capability-grid--standard, .side-column { grid-template-columns: 1fr; } @@ -196,3 +218,225 @@ width: 100%; } } + +/* 针对低高度视口(如低于 840px,包含大部分笔记本 768px 高度),解除 height: 100% 限制,让内容流式高度,防止纵向元素被过度压扁 (排除手机端) */ +@media (max-height: 840px) and (min-width: 761px) { + .workbench { + height: auto; + min-height: 100%; + grid-template-rows: auto var(--capability-row-height) auto; + } +} + +/* 手机端/窄屏自适应优化 (560px 以下) */ +@media (max-width: 560px) { + /* 常用提问横向滑动展示,避免折行过多撑爆高度 */ + .quick-prompts { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + width: 100%; + gap: 8px; + padding-bottom: 2px; + } + + .quick-prompts span { + display: none; /* 隐藏“常用提问:”前缀,以最大化利用横向空间 */ + } + + .quick-prompts button { + flex-shrink: 0; + padding: 0 10px; + min-height: 26px; + font-size: 12px; + } + + /* 隐藏常用提问横滑条的原生滚动条,保持精致视觉 */ + .quick-prompts::-webkit-scrollbar { + display: none; + } + + .assistant-hero { + --assistant-art-width: min(280px, 70vw); + padding: 20px 14px 16px; + } +} + +/* 手机端/窄屏自适应优化 (480px 以下) */ +@media (max-width: 480px) { + /* 输入框更小巧 */ + .assistant-composer { + padding: 10px 12px; + min-height: 94px; + } + + .assistant-composer textarea { + font-size: 14px; + height: 42px; + min-height: 42px; + } + + .composer-toolbar { + gap: 6px; + } + + .composer-icon-button, + .composer-related-button, + .composer-send-button { + height: 30px; + font-size: 13px; + } + + .composer-icon-button { + width: 30px; + } + + .composer-related-button { + padding: 0 10px; + gap: 4px; + } + + .composer-send-button { + width: 46px; + } + + /* 限制上传的附件文件芯片的最大宽度,防止溢出 */ + .assistant-file-chip { + max-width: 110px; + } + + /* AI 财务助手卡片尺寸更精致 */ + .capability-card { + padding: 12px 10px 12px 14px; + gap: 8px; + } + + .capability-icon { + width: 34px; + height: 34px; + font-size: 20px; + } + + .capability-copy { + padding-left: 6px; + gap: 2px; + } + + .capability-copy strong { + font-size: 13px; + } + + .capability-copy small { + 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; + grid-template-columns: 1fr 1fr; + grid-template-areas: + "identity result" + "steps steps"; + gap: 8px; + padding: 10px 0; + align-items: center; + } + + .progress-identity { + grid-area: identity; + width: 100%; + } + + .progress-identity strong { + font-size: 12.5px; + } + + .progress-identity small { + font-size: 11px; + } + + .progress-result { + grid-area: result; + width: 100%; + justify-items: end; /* 金额和状态右对齐 */ + gap: 2px; + } + + .progress-result strong { + font-size: 12.5px; + } + + .progress-status { + font-size: 11px; + min-height: 18px; + padding: 0 5px; + } + + .progress-steps { + grid-area: steps; + width: 100%; + margin-top: 4px; + } + + /* 缩小步骤图图标与连线 */ + .progress-step i { + width: 14px; + height: 14px; + font-size: 10px; + } + + .progress-step small { + font-size: 9px; + } + + .progress-step::before { + top: 7px; + } + + /* 侧边分析栏优化 */ + .side-panel { + padding: 8px 10px; + gap: 4px; + } + + .insight-metric-row, + .insight-profile-card { + padding: 6px 8px; + gap: 6px; + } + + .insight-metric-label, + .insight-profile-label { + font-size: 11.5px; + } + + .insight-metric-value, + .insight-profile-value { + font-size: 13.5px; + } +} diff --git a/web/src/assets/styles/components/personal-workbench.css b/web/src/assets/styles/components/personal-workbench.css index 609b9d6..7a975f8 100644 --- a/web/src/assets/styles/components/personal-workbench.css +++ b/web/src/assets/styles/components/personal-workbench.css @@ -3,6 +3,7 @@ --hero-padding-bottom: 14px; --hero-title-size: 30px; --hero-copy-gap: 6px; + --hero-title-bottom-gap: 18px; --composer-min-height: 122px; --composer-textarea-height: 54px; --composer-padding-block: 12px; @@ -61,11 +62,11 @@ overflow: visible; padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px; border: 1px solid color-mix(in srgb, var(--workbench-primary) 14%, var(--workbench-line)); - border-radius: 12px; + border-radius: 4px; background: linear-gradient(90deg, rgba(255, 255, 255, 0.97) 0%, rgba(255, 255, 255, 0.9) 44%, rgba(255, 255, 255, 0.16) 66%, rgba(255, 255, 255, 0.02) 100%), linear-gradient(135deg, #ffffff 0%, color-mix(in srgb, var(--workbench-primary-soft) 56%, #ffffff) 62%, color-mix(in srgb, var(--workbench-secondary) 8%, #ffffff) 100%); - box-shadow: 0 8px 20px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); isolation: isolate; } @@ -87,8 +88,7 @@ inset: 0; border-radius: inherit; background: - linear-gradient(90deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 58%), - radial-gradient(circle at 84% 62%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 36%); + linear-gradient(90deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 58%); pointer-events: none; z-index: 1; } @@ -102,7 +102,7 @@ } .assistant-copy h1 { - margin: 0; + margin: 0 0 var(--hero-title-bottom-gap); color: var(--workbench-ink); font-size: var(--hero-title-size); line-height: 1.18; @@ -130,11 +130,9 @@ 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); - border-radius: 9px; + border-radius: 4px; background: rgba(255, 255, 255, 0.96); - box-shadow: - 0 8px 18px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), - inset 0 1px 0 rgba(255, 255, 255, 0.96); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96); backdrop-filter: blur(4px); } @@ -173,7 +171,7 @@ align-items: center; justify-content: center; height: 36px; - border-radius: 7px; + border-radius: 4px; white-space: nowrap; } @@ -204,10 +202,10 @@ .composer-send-button { width: 56px; - background: var(--theme-gradient-primary); + background: var(--workbench-primary-active); color: #fff; font-size: 18px; - box-shadow: 0 10px 20px var(--theme-primary-shadow); + box-shadow: 0 6px 14px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16); } .assistant-file-strip { @@ -224,7 +222,7 @@ max-width: 220px; min-height: 28px; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; font-size: 12px; font-weight: 750; } @@ -265,7 +263,7 @@ min-height: 28px; padding: 0 14px; border: 1px solid var(--workbench-line); - border-radius: 6px; + border-radius: 4px; background: rgba(255, 255, 255, 0.86); color: var(--workbench-text); font-size: 13px; @@ -286,11 +284,18 @@ position: relative; z-index: 1; display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 16px; min-height: 0; } +.capability-grid--privileged { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.capability-grid--standard { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .capability-card { position: relative; isolation: isolate; @@ -302,12 +307,11 @@ padding: 17px 12px 17px 26px; overflow: hidden; border: 1px solid var(--workbench-line); - border-radius: 8px; + border-left: 3px solid color-mix(in srgb, var(--capability-color) 54%, var(--workbench-line)); + border-radius: 4px; background: var(--workbench-surface); text-align: left; - box-shadow: - 0 1px 0 rgba(255, 255, 255, 0.98) inset, - 0 6px 16px rgba(15, 23, 42, 0.035); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035); } .capability-card::after { @@ -319,12 +323,12 @@ height: 40px; display: grid; place-items: center; - border-radius: 9px; + border-radius: 4px; border: 1px solid color-mix(in srgb, var(--capability-color) 20%, #ffffff); background: var(--capability-soft); color: var(--capability-color); font-size: 24px; - box-shadow: 0 6px 14px color-mix(in srgb, var(--capability-color) 10%, transparent); + box-shadow: none; } .capability-copy { @@ -364,23 +368,23 @@ } .capability-card--blue { - --capability-color: var(--workbench-chart-blue); - --capability-soft: color-mix(in srgb, var(--workbench-chart-blue) 10%, #ffffff); + --capability-color: var(--workbench-primary); + --capability-soft: var(--workbench-primary-soft); } .capability-card--emerald { - --capability-color: var(--success); - --capability-soft: var(--success-soft); + --capability-color: var(--workbench-primary); + --capability-soft: var(--workbench-primary-soft); } .capability-card--violet { - --capability-color: var(--workbench-chart-purple); - --capability-soft: color-mix(in srgb, var(--workbench-chart-purple) 10%, #ffffff); + --capability-color: var(--workbench-primary); + --capability-soft: var(--workbench-primary-soft); } .capability-card--cyan { - --capability-color: var(--workbench-secondary); - --capability-soft: color-mix(in srgb, var(--workbench-secondary) 10%, #ffffff); + --capability-color: var(--workbench-primary); + --capability-soft: var(--workbench-primary-soft); } .capability-card--amber { @@ -403,9 +407,9 @@ overflow: hidden; padding: 12px 14px; border: 1px solid var(--workbench-line); - border-radius: 8px; + border-radius: 4px; background: var(--workbench-surface); - box-shadow: 0 6px 16px rgba(15, 23, 42, 0.035); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035); } .todo-panel, @@ -446,7 +450,7 @@ align-items: center; justify-content: center; padding: 0 7px; - border-radius: 999px; + border-radius: 4px; background: var(--workbench-primary-soft); color: var(--workbench-primary-active); font-size: 13px; @@ -498,7 +502,7 @@ } .todo-row :deep(.workbench-list-icon__panel) { - border-radius: 12px; + border-radius: 4px; } .todo-row :deep(.workbench-list-icon__art), diff --git a/web/src/assets/styles/components/sidebar-rail.css b/web/src/assets/styles/components/sidebar-rail.css index 90dfbd1..5004b53 100644 --- a/web/src/assets/styles/components/sidebar-rail.css +++ b/web/src/assets/styles/components/sidebar-rail.css @@ -555,13 +555,41 @@ transition-delay: 0ms; } -@media (max-width: 980px) { +@media (max-width: 980px) and (min-width: 761px) { .rail { position: relative; height: auto; } } +@media (max-width: 760px) { + .rail { + position: fixed; + top: 0; + left: 0; + bottom: 0; + height: 100dvh; + min-height: 100dvh; + box-shadow: 4px 0 24px rgba(15, 23, 42, 0.08); + } + + .rail-collapse-btn { + display: none !important; + } + + .rail-nav { + padding: 12px; + } + + .nav-btn { + min-height: 52px; + } + + .nav-label { + font-size: 15px; + } +} + @media (prefers-reduced-motion: reduce) { .rail *, .rail *::before, diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index 601bb99..d24338f 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -288,6 +288,98 @@ color: #dc2626; } +.topbar-toolset { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 18px; + min-width: 0; +} + +.topbar-icon-btn { + position: relative; + width: 34px; + height: 34px; + display: inline-grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: #111827; + font-size: 23px; + line-height: 1; + transition: + color 160ms var(--ease), + background 160ms var(--ease); +} + +.topbar-icon-btn:hover { + background: var(--theme-primary-light-9); + color: var(--theme-primary-active); +} + +.notification-badge { + position: absolute; + top: 2px; + right: 1px; + min-width: 13px; + height: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 3px; + border: 2px solid #fff; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 8px; + font-weight: 850; + line-height: 1; + box-shadow: 0 5px 10px rgba(239, 68, 68, .22); +} + +.company-switcher { + max-width: min(220px, 28vw); + height: 38px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 14px 0 16px; + border: 1px solid #edf1f5; + border-radius: 999px; + background: #fff; + color: #111827; + box-shadow: + 0 1px 2px rgba(15, 23, 42, .04), + inset 0 1px 0 rgba(255, 255, 255, .92); + font-size: 13px; + font-weight: 700; + white-space: nowrap; + transition: + border-color 160ms var(--ease), + box-shadow 160ms var(--ease), + color 160ms var(--ease); +} + +.company-switcher:hover { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), .26); + color: var(--theme-primary-active); + box-shadow: 0 6px 16px rgba(var(--theme-primary-rgb, 58, 124, 165), .08); +} + +.company-switcher span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.company-switcher .mdi { + flex: 0 0 auto; + color: #64748b; + font-size: 16px; +} + .kpi-chip { display: grid; grid-template-columns: auto auto; @@ -370,6 +462,7 @@ .top-actions, .search-wrap, .search-wrap.wide, + .topbar-toolset, .detail-alert-strip, .month-chip, .qa-filter, @@ -381,6 +474,10 @@ justify-content: stretch; } + .topbar-toolset { + justify-content: flex-end; + } + .range-shell { flex: 1; } @@ -400,6 +497,12 @@ } } +@media (max-width: 760px) { + .title-group { + padding-right: 56px; + } +} + @media (max-width: 640px) { .topbar { gap: 14px; @@ -427,6 +530,16 @@ white-space: nowrap; } + .topbar-toolset { + gap: 10px; + } + + .company-switcher { + flex: 1 1 auto; + max-width: none; + justify-content: space-between; + } + .range-combo { display: grid; gap: 8px; diff --git a/web/src/assets/styles/views/login-view.css b/web/src/assets/styles/views/login-view.css index ea33124..6fec840 100644 --- a/web/src/assets/styles/views/login-view.css +++ b/web/src/assets/styles/views/login-view.css @@ -433,7 +433,8 @@ font-size: 19px; } -.field input { +.field input, +.field select { width: 100%; height: 52px; padding: 0 50px 0 48px; @@ -445,17 +446,35 @@ transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; } +.field select { + appearance: none; + cursor: pointer; +} + .field input::placeholder { color: #94a3b8; } -.field input:focus { +.field input:focus, +.field select:focus { border-color: var(--theme-primary); background: #fff; box-shadow: 0 0 0 3px var(--theme-focus-ring, rgba(58, 124, 165, .14)); outline: none; } +.field-select-chevron { + position: absolute; + right: 12px; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 8px; + color: #64748b; + pointer-events: none; +} + .field-icon-btn { position: absolute; right: 12px; diff --git a/web/src/components/business/ExpenseProfileDetailModal.vue b/web/src/components/business/ExpenseProfileDetailModal.vue new file mode 100644 index 0000000..c250420 --- /dev/null +++ b/web/src/components/business/ExpenseProfileDetailModal.vue @@ -0,0 +1,590 @@ + + + + + diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index 86d51dd..84e73df 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -9,8 +9,7 @@
-

你的专属 AI 财务助手

-

智能理解财务业务,提供数据洞察与方案建议,高效处理日常事务

+

嗨,{{ displayUserName }},我是您的 AI 费用助手

-
+
+ + + + diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue index ea6f265..675abbe 100644 --- a/web/src/components/layout/SidebarRail.vue +++ b/web/src/components/layout/SidebarRail.vue @@ -152,7 +152,7 @@ const { } = useDocumentCenterInbox() const sidebarMeta = { - overview: { label: '财务总览' }, + overview: { label: '分析看板' }, workbench: { label: '个人工作台' }, documents: { label: '单据中心' }, budget: { label: '预算中心' }, @@ -185,7 +185,7 @@ const displayUser = computed(() => ({ avatar: props.currentUser?.avatar || '管' })) -const displayCompanyName = computed(() => props.companyName || 'X-Financial') +const displayCompanyName = computed(() => props.companyName || '易财费控') const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏')) const userTooltipContent = computed(() => [displayUser.value.name, displayUser.value.role].filter(Boolean).join(' · ')) diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue index a45ab82..fb3c2d0 100644 --- a/web/src/components/layout/TopBar.vue +++ b/web/src/components/layout/TopBar.vue @@ -98,14 +98,20 @@ @@ -206,9 +212,9 @@ const props = defineProps({ type: Object, default: () => null }, - workbenchSummary: { - type: Object, - default: () => null + companyName: { + type: String, + default: '' }, detailMode: { type: Boolean, @@ -247,48 +253,11 @@ const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMod const isApproval = computed(() => props.activeView === 'approval') const isPolicies = computed(() => props.activeView === 'policies') const isEmployees = computed(() => props.activeView === 'employees') - -const workbenchKpis = computed(() => { - const summary = props.workbenchSummary ?? {} - const monthlyCount = Number(summary.monthlyCount ?? 0) - const returnCount = Number(summary.returnCount ?? 0) - const highRiskCount = Number(summary.highRiskCount ?? 0) - const monthlyAmountLabel = String(summary.monthlyAmountLabel || '¥0') - - return [ - { - label: '本月报销笔数', - value: monthlyCount, - unit: '笔', - meta: '本月累计', - trend: monthlyCount > 0 ? 'up' : 'down', - color: 'var(--theme-primary)' - }, - { - label: '本月报销总金额', - value: monthlyAmountLabel, - unit: '', - meta: '本月累计', - trend: monthlyCount > 0 ? 'up' : 'down', - color: '#3b82f6' - }, - { - label: '退单次数', - value: returnCount, - unit: '次', - meta: '累计退回', - trend: returnCount > 0 ? 'down' : 'up', - color: '#f59e0b' - }, - { - label: '高危风险次数', - value: highRiskCount, - unit: '次', - meta: highRiskCount > 0 ? '本月需关注' : '本月无高危', - trend: highRiskCount > 0 ? 'down' : 'up', - color: '#ef4444' - } - ] +const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司') +const topbarNotificationCount = computed(() => { + const summary = props.documentSummary ?? {} + const count = Number(summary.toProcess ?? summary.toSubmit ?? 8) + return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0 }) const requestKpis = computed(() => { diff --git a/web/src/components/travel/EmployeeProfileRiskCard.vue b/web/src/components/travel/EmployeeProfileRiskCard.vue new file mode 100644 index 0000000..a76b92e --- /dev/null +++ b/web/src/components/travel/EmployeeProfileRiskCard.vue @@ -0,0 +1,622 @@ + + + + + diff --git a/web/src/composables/useLoginView.js b/web/src/composables/useLoginView.js index 6b76f9b..dc48e9c 100644 --- a/web/src/composables/useLoginView.js +++ b/web/src/composables/useLoginView.js @@ -3,7 +3,7 @@ import { h, ref } from 'vue' export function useLoginView() { const username = ref('') const password = ref('') - const tenant = ref('') + const tenant = ref('远光软件股份有限公司') const remember = ref(true) const showPassword = ref(false) diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js index 18f2105..4f2604f 100644 --- a/web/src/composables/useNavigation.js +++ b/web/src/composables/useNavigation.js @@ -4,11 +4,11 @@ import { useRoute, useRouter } from 'vue-router' import { icons } from '../data/icons.js' export const appViews = [ - 'overview', 'workbench', 'documents', 'budget', 'audit', + 'overview', 'digitalEmployees', 'employees', 'policies', @@ -17,14 +17,6 @@ export const appViews = [ ] export const navItems = [ - { - id: 'overview', - label: '总览', - navHint: '查看系统总览与关键指标', - icon: icons.dashboard, - title: '财务运营总览', - desc: '聚合差旅申请、审批效率、风险信号与 SLA 表现。' - }, { id: 'workbench', label: '个人工作台', @@ -57,6 +49,14 @@ export const navItems = [ title: '规则中心', desc: '集中管理财务规则、风险规则与外部 MCP 服务。' }, + { + id: 'overview', + label: '分析看板', + navHint: '查看系统总览与关键指标', + icon: icons.dashboard, + title: '分析看板', + desc: '聚合差旅申请、审批效率、风险信号与 SLA 表现。' + }, { id: 'digitalEmployees', label: '数字员工', diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index 152edca..73ba560 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -51,7 +51,8 @@ const REIMBURSEMENT_PROGRESS_LABELS = [ 'AI预审', '直属领导审批', '财务审批', - '归档入账' + '待付款', + '已付款' ] const APPLICATION_PROGRESS_LABELS = [ @@ -264,6 +265,14 @@ function resolveApprovalMeta(status) { return { key: 'supplement', label: '待补充', tone: 'warning' } } + if (normalized === 'pending_payment') { + return { key: 'pending_payment', label: '待付款', tone: 'warning' } + } + + if (normalized === 'paid') { + return { key: 'completed', label: '已付款', tone: 'success' } + } + if (['approved', 'completed', 'paid'].includes(normalized)) { return { key: 'completed', label: '已完成', tone: 'success' } } @@ -296,8 +305,13 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) return '待提交' } + if (approvalMeta.key === 'pending_payment') { + return '待付款' + } + if (approvalMeta.key === 'completed') { - return isApplicationDocument ? '审批完成' : '归档入账' + const normalizedStatus = String(claim?.status || '').trim().toLowerCase() + return isApplicationDocument ? '审批完成' : normalizedStatus === 'paid' ? '已付款' : '归档入账' } return isApplicationDocument ? '直属领导审批' : 'AI预审' @@ -352,12 +366,22 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) { const normalizedNode = String(workflowNode || '').trim() if (approvalMeta.key === 'completed') { + return 6 + } + + if (approvalMeta.key === 'pending_payment') { return 5 } - if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) { + if (normalizedNode.includes('已付款')) { + return 6 + } + if (normalizedNode.includes('待付款')) { return 5 } + if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) { + return 6 + } if (normalizedNode.includes('财务')) { return 4 } @@ -529,6 +553,7 @@ function findApprovalEventForStep(claim, label) { if (stepLabel === '财务审批') { return ( previousStage.includes('财务') + || nextStage.includes('待付款') || nextStage.includes('归档') || nextStage.includes('入账') || nextStage.includes('完成') @@ -551,6 +576,19 @@ function findLatestReturnEvent(claim) { ) } +function findLatestPaymentEvent(claim) { + return getLatestEvent( + getRiskFlags(claim).filter((flag) => ( + flag + && typeof flag === 'object' + && ( + normalizeText(flag.source) === 'payment' + || normalizeText(flag.event_type || flag.eventType) === 'expense_claim_payment_completed' + ) + )) + ) +} + function findLatestApplicationReturnEvent(claim) { return getLatestEvent( getRiskFlags(claim).filter((flag) => { @@ -639,6 +677,18 @@ function buildCompletedStepMeta(claim, label) { } } + if (stepLabel === '待付款') { + const approvalEvent = findApprovalEventForStep(claim, '财务审批') + const pendingAt = formatDateTime(approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at) + return buildProgressStepMeta('待付款', pendingAt) + } + + if (stepLabel === '已付款') { + const paymentEvent = findLatestPaymentEvent(claim) + const paidAt = formatDateTime(paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at) + return buildProgressStepMeta('已付款', paidAt) + } + if (stepLabel === '归档入账') { const archivedAt = formatDateTime(claim?.updated_at) return buildProgressStepMeta('归档入账', archivedAt) @@ -675,6 +725,14 @@ function resolveCurrentStepStartedAt(claim, label) { const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批') return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at } + if (stepLabel === '待付款') { + const approvalEvent = findApprovalEventForStep(claim, '财务审批') + return approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at || claim?.submitted_at + } + if (stepLabel === '已付款') { + const paymentEvent = findLatestPaymentEvent(claim) + return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at + } if (stepLabel === '归档入账' || stepLabel === '审批完成') { return claim?.updated_at || claim?.submitted_at } @@ -703,6 +761,8 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {} const currentTime = approvalMeta.key === 'completed' ? '已完成' + : approvalMeta.key === 'pending_payment' + ? '待付款' : approvalMeta.key === 'supplement' ? '待补充' : approvalMeta.key === 'rejected' diff --git a/web/src/data/personalWorkbench.js b/web/src/data/personalWorkbench.js index 3ee5397..60616fe 100644 --- a/web/src/data/personalWorkbench.js +++ b/web/src/data/personalWorkbench.js @@ -7,7 +7,8 @@ export const quickPromptItems = [ export const assistantCapabilities = [ { - title: '费用申请助手', + key: 'expense-application', + title: '费用申请', primary: '智能识别规划', secondary: '快速创建申请', icon: 'mdi mdi-file-document-plus-outline', @@ -15,15 +16,17 @@ export const assistantCapabilities = [ prompt: '帮我新建一笔费用申请,并检查需要补充哪些信息。' }, { - title: '报销助手', - primary: '票据 OCR 归票', - secondary: '自动价格分摊', + key: 'quick-reimbursement', + title: '快速报销', + primary: '智能报销流程', + secondary: '财务流程简化', icon: 'mdi mdi-wallet-outline', tone: 'blue', prompt: '帮我整理票据并生成一笔可提交的报销单。' }, { - title: '预算控制助手', + key: 'budget-planning', + title: '预算编制', primary: '预算查询分析', secondary: '超标预警提醒', icon: 'mdi mdi-chart-pie', @@ -31,7 +34,8 @@ export const assistantCapabilities = [ prompt: '帮我查询本月预算使用情况,并提醒可能超标的费用。' }, { - title: '审批问答助手', + key: 'quick-approval', + title: '快速审批', primary: '审批凭证说明', secondary: '进度实时查询', icon: 'mdi mdi-clipboard-check-outline', @@ -39,15 +43,17 @@ export const assistantCapabilities = [ prompt: '帮我查询报销审批进度,并说明当前卡在哪个节点。' }, { - title: '差旅助手', - primary: '行程预订建议', - secondary: '标准合规校验', - icon: 'mdi mdi-airplane', + key: 'finance-analysis', + title: '财务分析', + primary: '费用结构洞察', + secondary: '趋势对比分析', + icon: 'mdi mdi-chart-line', tone: 'cyan', - prompt: '帮我检查差旅安排是否符合公司差旅标准。' + prompt: '帮我分析近期费用结构与支出趋势,并给出优化建议。' }, { - title: '制度知识助手', + key: 'company-policy', + title: '公司制度', primary: '制度检索解读', secondary: '政策实时更新', icon: 'mdi mdi-book-open-page-variant-outline', @@ -258,3 +264,103 @@ export const usageProfileMetrics = [ tone: 'emerald' } ] + +export const expenseProfileTags = [ + { + code: 'ai-heavy-user', + label: 'AI 重度用户', + displayLabel: 'AI 协作活跃', + tone: 'behavior', + score: 86, + reason: '近 30 天 AI 使用 86 次,覆盖报销助手、制度问答和票据整理。' + }, + { + code: 'fast-submitter', + label: '极速提单人', + displayLabel: '提单效率高', + tone: 'positive', + score: 82, + reason: '平均提单耗时 12 分钟,明显低于常规人工录入节奏。' + }, + { + code: 'clean-precheck', + label: '预审稳定', + displayLabel: '预审通过稳定', + tone: 'positive', + score: 92, + reason: '智能预审通过率 92%,近期补材压力较低。' + }, + { + code: 'review-wait', + label: '审核等待偏长', + displayLabel: '审核响应需关注', + tone: 'risk', + score: 64, + reason: '平均审核时长 18.6 小时,仍有压缩空间。' + }, + { + code: 'active-operator', + label: '高频操作人', + displayLabel: '操作频率高', + tone: 'behavior', + score: 78, + reason: '近 30 天在申请、查询、补材、AI 问答之间切换频繁。' + } +] + +export const expenseProfileRadarDimensions = [ + { code: 'ai_collaboration', label: 'AI 协作', score: 86 }, + { code: 'submit_efficiency', label: '提单效率', score: 82 }, + { code: 'precheck_quality', label: '预审质量', score: 92 }, + { code: 'review_response', label: '审核响应', score: 64 }, + { code: 'material_completeness', label: '材料完整', score: 76 }, + { code: 'expense_rhythm', label: '费用节奏', score: 68 } +] + +export const expenseProfileOperations = [ + { + id: 'OP-0528-01', + time: '今天 09:42', + action: '上传票据并触发智能识别', + target: '北京市市场调研差旅费', + channel: '报销助手', + status: '识别完成', + tone: 'success' + }, + { + id: 'OP-0528-02', + time: '今天 09:51', + action: '发起 AI 预审', + target: 'BX-202605-0031', + channel: '智能预审', + status: '通过', + tone: 'success' + }, + { + id: 'OP-0527-03', + time: '昨天 17:18', + action: '查询审批进度', + target: '5月市场活动费用申请', + channel: '个人工作台', + status: '审批中', + tone: 'warning' + }, + { + id: 'OP-0527-04', + time: '昨天 14:06', + action: '补充业务事由', + target: '办公用品采购报销单', + channel: '单据详情', + status: '已保存', + tone: 'info' + }, + { + id: 'OP-0526-05', + time: '05-26 11:33', + action: '制度问答检索', + target: '差旅住宿标准', + channel: '制度问答', + status: '已回复', + tone: 'muted' + } +] diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index c11c94c..81b86a6 100644 --- a/web/src/services/reimbursements.js +++ b/web/src/services/reimbursements.js @@ -20,6 +20,18 @@ export function fetchExpenseClaimBudgetAnalysis(claimId) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/budget-analysis`) } +export function fetchEmployeeLatestProfile(employeeId, params = {}) { + const query = new URLSearchParams() + Object.entries(params || {}).forEach(([key, value]) => { + const normalized = String(value ?? '').trim() + if (normalized) { + query.set(key, normalized) + } + }) + const suffix = query.toString() ? `?${query.toString()}` : '' + return apiRequest(`/employee-profiles/${encodeURIComponent(String(employeeId || '').trim())}/latest${suffix}`) +} + export function updateExpenseClaim(claimId, payload = {}) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, { method: 'PATCH', @@ -127,6 +139,13 @@ export function approveExpenseClaim(claimId, payload = {}) { }) } +export function payExpenseClaim(claimId) { + return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pay`, { + method: 'POST', + body: JSON.stringify({}) + }) +} + export function deleteExpenseClaim(claimId) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, { method: 'DELETE' diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index faee983..dcbf157 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -1,239 +1,239 @@ -export const DEFAULT_APP_VIEW_ORDER = [ - 'overview', - 'workbench', - 'documents', - 'budget', - 'policies', - 'audit', - 'digitalEmployees', - 'logs', +export const DEFAULT_APP_VIEW_ORDER = [ + 'workbench', + 'documents', + 'budget', + 'audit', + 'overview', + 'policies', + 'digitalEmployees', + 'logs', 'employees', 'settings' ] -const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies']) -const VIEW_ROLE_RULES = { - overview: ['finance', 'executive'], - budget: ['budget_monitor', 'executive'], - audit: ['finance'], - digitalEmployees: ['finance'], - logs: ['manager'], - employees: ['manager'], - settings: ['manager'] -} -const CLAIM_MANAGER_ROLE_CODES = new Set(['executive']) -const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor']) -const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver']) -const CLAIM_BUDGET_APPROVAL_GRADE = 'P8' +const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies']) +const VIEW_ROLE_RULES = { + overview: ['finance', 'executive'], + budget: ['budget_monitor', 'executive'], + audit: ['finance'], + digitalEmployees: ['finance'], + logs: ['manager'], + employees: ['manager'], + settings: ['manager'] +} +const CLAIM_MANAGER_ROLE_CODES = new Set(['executive']) +const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor']) +const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver']) +const CLAIM_BUDGET_APPROVAL_GRADE = 'P8' -function normalizedRoleCodes(user) { - if (!user) { - return [] - } - - return Array.isArray(user.roleCodes) - ? user.roleCodes - .map((item) => normalizeRoleCode(item)) - .filter(Boolean) - : [] -} - -function normalizeRoleCode(value) { - const roleCode = String(value || '').trim().toLowerCase() - return roleCode === 'auditor' ? 'budget_monitor' : roleCode -} - -function normalizeComparableText(value) { - return String(value || '').trim() -} - -function collectIdentityNames(...values) { - return values - .map((value) => normalizeComparableText(value)) - .filter(Boolean) -} - -function identityIntersects(leftValues, rightValues) { - const rightSet = new Set(rightValues) - return leftValues.some((item) => rightSet.has(item)) -} - -function normalizedGrade(user) { - return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase() -} - -function departmentIntersects(request, user) { - const requestDepartments = collectIdentityNames( - request?.dept, - request?.departmentName, - request?.department_name - ) - const currentDepartments = collectIdentityNames( - user?.department, - user?.departmentName, - user?.department_name - ) - - return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments) -} - -function hasPlatformAdminIdentity(user) { - if (!user) { - return false - } - - const username = String(user.username || user.account || '').trim().toLowerCase() - const role = String(user.role || '').trim().toLowerCase() - const roleCodes = normalizedRoleCodes(user) - - return ( - Boolean(user.isAdmin) - || username === 'admin' - || role === 'admin' - || role === '管理员' - || role === '系统管理员' - || roleCodes.includes('admin') - ) -} - -export function isManagerUser(user) { - return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager') -} - -export function isPlatformAdminUser(user) { - return hasPlatformAdminIdentity(user) -} - -export function isFinanceUser(user) { - return normalizedRoleCodes(user).includes('finance') -} +function normalizedRoleCodes(user) { + if (!user) { + return [] + } -export function isExecutiveUser(user) { - return normalizedRoleCodes(user).includes('executive') -} - -export function isBudgetMonitorUser(user) { - return normalizedRoleCodes(user).includes('budget_monitor') -} - -export function canEditBudgetCenter(user) { - return isPlatformAdminUser(user) || isExecutiveUser(user) -} - -export function canSwitchBudgetDepartments(user) { - return isPlatformAdminUser(user) || isExecutiveUser(user) -} - -export function canManageExpenseClaims(user) { - if (isPlatformAdminUser(user)) { - return true - } - - return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode)) -} - -export function canDeleteArchivedExpenseClaims(user) { - return isPlatformAdminUser(user) -} - -export function canReturnExpenseClaims(user) { - if (isPlatformAdminUser(user)) { - return true - } + return Array.isArray(user.roleCodes) + ? user.roleCodes + .map((item) => normalizeRoleCode(item)) + .filter(Boolean) + : [] +} + +function normalizeRoleCode(value) { + const roleCode = String(value || '').trim().toLowerCase() + return roleCode === 'auditor' ? 'budget_monitor' : roleCode +} + +function normalizeComparableText(value) { + return String(value || '').trim() +} + +function collectIdentityNames(...values) { + return values + .map((value) => normalizeComparableText(value)) + .filter(Boolean) +} + +function identityIntersects(leftValues, rightValues) { + const rightSet = new Set(rightValues) + return leftValues.some((item) => rightSet.has(item)) +} + +function normalizedGrade(user) { + return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase() +} + +function departmentIntersects(request, user) { + const requestDepartments = collectIdentityNames( + request?.dept, + request?.departmentName, + request?.department_name + ) + const currentDepartments = collectIdentityNames( + user?.department, + user?.departmentName, + user?.department_name + ) + + return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments) +} + +function hasPlatformAdminIdentity(user) { + if (!user) { + return false + } + + const username = String(user.username || user.account || '').trim().toLowerCase() + const role = String(user.role || '').trim().toLowerCase() + const roleCodes = normalizedRoleCodes(user) + + return ( + Boolean(user.isAdmin) + || username === 'admin' + || role === 'admin' + || role === '管理员' + || role === '系统管理员' + || roleCodes.includes('admin') + ) +} + +export function isManagerUser(user) { + return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager') +} + +export function isPlatformAdminUser(user) { + return hasPlatformAdminIdentity(user) +} + +export function isFinanceUser(user) { + return normalizedRoleCodes(user).includes('finance') +} + +export function isExecutiveUser(user) { + return normalizedRoleCodes(user).includes('executive') +} + +export function isBudgetMonitorUser(user) { + return normalizedRoleCodes(user).includes('budget_monitor') +} + +export function canEditBudgetCenter(user) { + return isPlatformAdminUser(user) || isExecutiveUser(user) +} + +export function canSwitchBudgetDepartments(user) { + return isPlatformAdminUser(user) || isExecutiveUser(user) +} + +export function canManageExpenseClaims(user) { + if (isPlatformAdminUser(user)) { + return true + } + + return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode)) +} + +export function canDeleteArchivedExpenseClaims(user) { + return isPlatformAdminUser(user) +} + +export function canReturnExpenseClaims(user) { + if (isPlatformAdminUser(user)) { + return true + } return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode)) } -export function canApproveLeaderExpenseClaims(user) { - if (isPlatformAdminUser(user)) { - return true - } - - return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) -} - -export function canApproveBudgetExpenseApplications(user, request = null) { - if (isPlatformAdminUser(user)) { - return true - } - - const roleCodes = normalizedRoleCodes(user) - if (roleCodes.includes('executive')) { - return true - } - if (!roleCodes.includes('budget_monitor')) { - return false - } - if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) { - return false - } - - return request ? departmentIntersects(request, user) : true -} - -export function isCurrentRequestApplicant(request, user) { - const applicantNames = collectIdentityNames( - request?.person, - request?.employeeName, - request?.employee_name, - request?.profileName, - request?.applicant - ) - const currentNames = collectIdentityNames( - user?.name, - user?.username, - user?.email, - user?.employeeNo, - user?.employee_no - ) - - return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames) -} - -export function isCurrentDirectManagerForRequest(request, user) { - if (isCurrentRequestApplicant(request, user)) { - return false - } - - const managerNames = collectIdentityNames( - request?.profileManager, - request?.managerName, - request?.manager_name, - request?.directManagerName, - request?.direct_manager_name, - request?.manager - ) - const currentNames = collectIdentityNames( - user?.name, - user?.username, - user?.email, - user?.employeeNo, - user?.employee_no - ) - - return managerNames.length > 0 && identityIntersects(managerNames, currentNames) -} - -export function canAccessAppView(user, viewId) { - if (!viewId || !user) { - return false - } - - if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) { - return false - } - - if (viewId === 'budget') { - if (isPlatformAdminUser(user)) { - return true - } - const roleCodes = normalizedRoleCodes(user) - return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode)) - } - - if (isManagerUser(user)) { - return true - } +export function canApproveLeaderExpenseClaims(user) { + if (isPlatformAdminUser(user)) { + return true + } + + return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) +} + +export function canApproveBudgetExpenseApplications(user, request = null) { + if (isPlatformAdminUser(user)) { + return true + } + + const roleCodes = normalizedRoleCodes(user) + if (roleCodes.includes('executive')) { + return true + } + if (!roleCodes.includes('budget_monitor')) { + return false + } + if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) { + return false + } + + return request ? departmentIntersects(request, user) : true +} + +export function isCurrentRequestApplicant(request, user) { + const applicantNames = collectIdentityNames( + request?.person, + request?.employeeName, + request?.employee_name, + request?.profileName, + request?.applicant + ) + const currentNames = collectIdentityNames( + user?.name, + user?.username, + user?.email, + user?.employeeNo, + user?.employee_no + ) + + return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames) +} + +export function isCurrentDirectManagerForRequest(request, user) { + if (isCurrentRequestApplicant(request, user)) { + return false + } + + const managerNames = collectIdentityNames( + request?.profileManager, + request?.managerName, + request?.manager_name, + request?.directManagerName, + request?.direct_manager_name, + request?.manager + ) + const currentNames = collectIdentityNames( + user?.name, + user?.username, + user?.email, + user?.employeeNo, + user?.employee_no + ) + + return managerNames.length > 0 && identityIntersects(managerNames, currentNames) +} + +export function canAccessAppView(user, viewId) { + if (!viewId || !user) { + return false + } + + if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) { + return false + } + + if (viewId === 'budget') { + if (isPlatformAdminUser(user)) { + return true + } + const roleCodes = normalizedRoleCodes(user) + return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode)) + } + + if (isManagerUser(user)) { + return true + } if (ALWAYS_VISIBLE_VIEWS.has(viewId)) { return true diff --git a/web/src/utils/documentCenterRows.js b/web/src/utils/documentCenterRows.js index 44202ec..7d770c4 100644 --- a/web/src/utils/documentCenterRows.js +++ b/web/src/utils/documentCenterRows.js @@ -10,7 +10,7 @@ function isArchivedRequestPayload(request) { const normalizedStatus = String(request.status || '').trim().toLowerCase() const stage = String(request.approval_stage || request.approvalStage || '').trim() - if (stage === '归档入账' || stage === 'completed') { + if (stage === '归档入账' || stage === '已付款' || stage === 'completed') { return true } @@ -27,7 +27,7 @@ function isArchivedRequestPayload(request) { } return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus) - && (stage === '' || stage === '归档入账' || stage === 'completed') + && (stage === '' || stage === '归档入账' || stage === '已付款' || stage === 'completed') } export function isArchivedDocumentRow(row) { diff --git a/web/src/utils/expenseClaimArchive.js b/web/src/utils/expenseClaimArchive.js index 5542c0d..b004c79 100644 --- a/web/src/utils/expenseClaimArchive.js +++ b/web/src/utils/expenseClaimArchive.js @@ -4,7 +4,7 @@ export function isArchivedExpenseClaim(claim) { const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim() const status = String(claim?.status || '').trim().toLowerCase() - if (stage === '归档入账' || stage === 'completed' || stage.includes('归档')) { + if (stage === '归档入账' || stage === '已付款' || stage === 'completed' || stage.includes('归档')) { return true } @@ -16,5 +16,5 @@ export function isArchivedExpenseClaim(claim) { return true } - return !stage || stage === '归档入账' || stage === 'completed' + return !stage || stage === '归档入账' || stage === '已付款' || stage === 'completed' } diff --git a/web/src/utils/hermesEmployeeSettingsModel.js b/web/src/utils/hermesEmployeeSettingsModel.js index 339e451..e0098d8 100644 --- a/web/src/utils/hermesEmployeeSettingsModel.js +++ b/web/src/utils/hermesEmployeeSettingsModel.js @@ -14,13 +14,22 @@ export const HERMES_SIMPLE_TASKS = [ frequency: 'weekly', frequencyLabel: '每周一', weekday: 1 + }, + { + id: 'employee_behavior_profile_scan', + label: '员工画像巡检', + hint: '沉淀费用、流程质量与 AI 协作画像快照', + frequency: 'weekly', + frequencyLabel: '每周一', + weekday: 1 } ] function buildDefaultSchedules() { const defaults = { global_risk_scan: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 }, - weekly_expense_report: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 } + weekly_expense_report: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 }, + employee_behavior_profile_scan: { enabled: false, frequency: 'weekly', time: '08:30', weekday: 1, monthDay: 1, month: 1 } } for (const task of HERMES_SIMPLE_TASKS) { @@ -49,7 +58,8 @@ export function buildDefaultHermesEmployeeForm() { notifyOnFailure: true, capabilities: { global_risk_scan: true, - weekly_expense_report: false + weekly_expense_report: false, + employee_behavior_profile_scan: false }, schedules: buildDefaultSchedules() } diff --git a/web/src/utils/requestViewModel.js b/web/src/utils/requestViewModel.js index 447d8c0..4c5fdbd 100644 --- a/web/src/utils/requestViewModel.js +++ b/web/src/utils/requestViewModel.js @@ -115,6 +115,7 @@ const APPROVAL_META = { draft: { label: '草稿', tone: 'draft' }, in_progress: { label: '审批中', tone: 'info' }, supplement: { label: '待补充', tone: 'warning' }, + pending_payment: { label: '待付款', tone: 'warning' }, completed: { label: '已完成', tone: 'success' }, rejected: { label: '已退回', tone: 'danger' } } @@ -126,8 +127,9 @@ const BACKEND_STATUS_META = { reviewing: { key: 'in_progress', label: '审批中', tone: 'info' }, in_review: { key: 'in_progress', label: '审批中', tone: 'info' }, in_progress: { key: 'in_progress', label: '审批中', tone: 'info' }, + pending_payment: { key: 'pending_payment', label: '待付款', tone: 'warning' }, approved: { key: 'completed', label: '已完成', tone: 'success' }, - paid: { key: 'completed', label: '已完成', tone: 'success' }, + paid: { key: 'completed', label: '已付款', tone: 'success' }, completed: { key: 'completed', label: '已完成', tone: 'success' }, supplement: { key: 'supplement', label: '待补充', tone: 'warning' }, returned: { key: 'supplement', label: '待提交', tone: 'warning' }, @@ -259,7 +261,7 @@ export function isArchivedRequestView(request) { const displayStage = String(request?.workflowNode || request?.node || '').trim() const stage = rawStage || displayStage - if (stage === '归档入账' || stage === 'completed' || stage.includes('归档') || stage.includes('入账')) { + if (stage === '归档入账' || stage === '已付款' || stage === 'completed' || stage.includes('归档') || stage.includes('入账')) { return true } if ( @@ -270,7 +272,7 @@ export function isArchivedRequestView(request) { return true } if (['approved', 'completed', 'paid'].includes(status)) { - return rawStage === '' || rawStage === '归档入账' || rawStage === 'completed' + return rawStage === '' || rawStage === '归档入账' || rawStage === '已付款' || rawStage === 'completed' } return approvalKey === 'completed' } diff --git a/web/src/utils/riskFlags.js b/web/src/utils/riskFlags.js index fc1317c..498c14e 100644 --- a/web/src/utils/riskFlags.js +++ b/web/src/utils/riskFlags.js @@ -5,11 +5,13 @@ const NON_RISK_SOURCES = new Set([ 'approval', 'approval_log', 'expense_claim_approval', - 'expense_claim_finance_approval' + 'expense_claim_finance_approval', + 'payment' ]) const NON_RISK_EVENTS = new Set([ 'expense_claim_approval', - 'expense_claim_finance_approval' + 'expense_claim_finance_approval', + 'expense_claim_payment_completed' ]) const NON_RISK_TONES = new Set(['info', 'pass', 'success', 'approved', 'ok', 'none']) const RISK_SOURCES = new Set([ @@ -39,6 +41,8 @@ function isApprovalOnlyText(value) { /^(同意|通过|审批通过|审核通过|已同意|无意见)$/.test(text) || /已审批通过/.test(text) || /已完成财务审核/.test(text) + || /进入待付款/.test(text) + || /已确认付款/.test(text) || /进入归档入账/.test(text) || /流转至/.test(text) ) diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 2c15578..1cfc2fe 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -1,17 +1,18 @@