feat: 新增员工行为画像算法与费用风险标签体系

后端新增员工行为画像算法模块,支持标签规则引擎和评分计算,
完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流
和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费
用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台
样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-28 12:09:49 +08:00
parent 04cd6d0f81
commit 8a4a777be7
96 changed files with 9835 additions and 704 deletions

View File

@@ -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 输出月度费用治理建议,但仍不直接改线上规则。

View File

@@ -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=<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 使用强度、审批速度特征必须单独解释。
- 正向标签和风险标签需要同时存在,否则画像容易变成单向负面评价。
- 画像快照可能增长较快,需要后续定义保留周期和归档策略。
- 业务招待中的客户、用户或项目标识需要数据标准化,否则重复招待次数难以准确统计。

View File

@@ -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`
## 阶段 6Hermes 接入
- [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 passedruff 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: 指标与验收]

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,16 @@ def init_default_config():
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}")

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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(".")

View File

@@ -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,
}

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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"])

View File

@@ -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",

View File

@@ -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",

View File

@@ -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="已同步")

View File

@@ -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")

View File

@@ -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

View File

@@ -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 = ""

View File

@@ -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="系统初始化",
),
]

View File

@@ -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:
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",
finance_policy_cron = "0 3 * * *"
finance_policy_config = {
**self._digital_employee_task_config(
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
finance_policy_cron,
),
content_type=AgentAssetContentType.JSON.value,
change_note="初始化应收账龄汇总任务。",
created_by="系统初始化",
)
"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 "task.hermes.rule_review_digest" not in existing_codes:
if DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE 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,
)
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._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="初始化制度知识与规则草稿形成任务。",
content=self._finance_policy_knowledge_skill_markdown(),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化整理公司财务知识制度能力。",
created_by="系统初始化",
)

View File

@@ -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 = {

View File

@@ -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"},

View File

@@ -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:

View File

@@ -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)

View File

@@ -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"

View File

@@ -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]

View File

@@ -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,
}

View File

@@ -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:

View File

@@ -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()

View File

@@ -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,

View File

@@ -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),
),

View File

@@ -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):

View File

@@ -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 [])):

View File

@@ -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 = "已付款"

View File

@@ -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

View File

@@ -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")
@@ -61,7 +60,7 @@ 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:
@@ -72,35 +71,80 @@ class HermesScheduler:
db.close()
def _should_run_now(self, db: Session, config: HermesTaskConfig) -> bool:
# 简单策略检查是否在过去24小时内运行过。
# 如果没有 croniter 库,我们暂时采用按天执行的简化逻辑
stmt = select(HermesTaskExecutionLog).where(
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)
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)
@@ -112,15 +156,24 @@ 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.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:

View File

@@ -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,12 +43,22 @@ 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 source in existing_sources:
for item in source.iterdir():
destination = target / item.name
if item.is_dir():

View File

@@ -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,

View File

@@ -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"]

View File

@@ -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])} * * *"

View File

@@ -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`:后续维护建议。
## 执行约束
- 不凭空编造制度内容。
- 不把未确认内容写成正式规则。
- 不直接修改原始制度文件。
- 对金额、期限、城市档位和审批权限等高风险字段必须保留来源。
- 没有明确来源时,只能标记为待确认。

View File

@@ -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"]
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"]
)

BIN
web/UI/topbar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,590 @@
<template>
<ElDialog
:model-value="visible"
append-to-body
align-center
width="min(1040px, calc(100vw - 48px))"
:show-close="false"
:lock-scroll="true"
:destroy-on-close="false"
class="expense-profile-dialog"
modal-class="expense-profile-dialog-overlay"
body-class="expense-profile-dialog-body"
transition="expense-profile-dialog-zoom"
aria-labelledby="expense-profile-modal-title"
@update:model-value="handleVisibleChange"
>
<template #header>
<header class="profile-dialog-header">
<div class="profile-dialog-title-block">
<span class="profile-dialog-eyebrow">Expense Behavior Profile</span>
<h2 id="expense-profile-modal-title">{{ userName }}的费用画像详情</h2>
<p>基于近 30 天费用操作AI 协作提单效率和预审质量形成</p>
</div>
<ElButton
class="profile-dialog-close"
text
aria-label="关闭费用画像详情"
@click="emitClose"
>
<i class="mdi mdi-close"></i>
</ElButton>
</header>
</template>
<section class="profile-dialog-content" aria-label="费用画像分析">
<section class="profile-summary-grid" aria-label="画像核心指标">
<article v-for="metric in metrics" :key="metric.key" class="profile-summary-item">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}<small>{{ metric.unit }}</small></strong>
<em>{{ metric.hint }}</em>
</article>
</section>
<div class="profile-analysis-grid">
<section class="profile-panel profile-tags-panel" aria-label="画像标签">
<div class="profile-section-title">
<div>
<span>画像标签</span>
<small>按分数和业务解释排序</small>
</div>
</div>
<div class="profile-tag-list">
<article
v-for="tag in tags"
:key="tag.code"
:class="['profile-tag-item', `profile-tag-item--${tag.tone}`]"
>
<div class="profile-tag-copy">
<strong>{{ tag.displayLabel || tag.label }}</strong>
<span>{{ tag.reason }}</span>
</div>
<ElTag
class="profile-score-tag"
:type="resolveProfileTagType(tag.tone)"
effect="light"
>
{{ tag.score }}
</ElTag>
</article>
</div>
</section>
<section class="profile-panel profile-radar-panel" aria-label="行为雷达图">
<div class="profile-section-title">
<div>
<span>行为雷达</span>
<small>使用项目图表组件组织分数越高特征越明显</small>
</div>
</div>
<div class="profile-radar-layout">
<RadarChart
class="profile-radar-chart"
:items="radarDimensions"
label="费用画像评分"
/>
<ul class="profile-radar-list">
<li v-for="dimension in radarDimensions" :key="dimension.code">
<span>{{ dimension.label }}</span>
<strong>{{ dimension.score }}</strong>
</li>
</ul>
</div>
</section>
</div>
<section class="profile-panel profile-operation-panel" aria-label="最近 5 次操作内容">
<div class="profile-section-title">
<div>
<span>最近 5 次操作内容</span>
<small>用于理解画像标签的近期行为依据</small>
</div>
</div>
<div class="profile-operation-list">
<article v-for="operation in operations" :key="operation.id" class="profile-operation-row">
<time>{{ operation.time }}</time>
<div class="profile-operation-copy">
<strong>{{ operation.action }}</strong>
<span>{{ operation.target }} · {{ operation.channel }}</span>
</div>
<ElTag
class="profile-operation-status"
:type="resolveOperationStatusType(operation.tone)"
effect="light"
>
{{ operation.status }}
</ElTag>
</article>
</div>
</section>
</section>
<template #footer>
<footer class="profile-dialog-footer">
<span>画像仅用于辅助分析不作为自动审批或处罚依据</span>
<ElButton class="profile-dialog-explain" type="primary" @click="emitExplain">
<i class="mdi mdi-robot-outline"></i>
<span> AI 解读画像</span>
</ElButton>
</footer>
</template>
</ElDialog>
</template>
<script setup>
import { ElButton, ElDialog, ElTag } from 'element-plus'
import RadarChart from '../charts/RadarChart.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
userName: { type: String, default: '同事' },
metrics: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
radarDimensions: { type: Array, default: () => [] },
operations: { type: Array, default: () => [] }
})
const emit = defineEmits(['close', 'explain'])
function emitClose() {
emit('close')
}
function emitExplain() {
emit('explain')
}
function handleVisibleChange(value) {
if (!value) {
emitClose()
}
}
function resolveProfileTagType(tone) {
const normalized = String(tone || '').trim()
if (['warning', 'risk', 'amber'].includes(normalized)) {
return 'warning'
}
if (['danger', 'high'].includes(normalized)) {
return 'danger'
}
return 'primary'
}
function resolveOperationStatusType(tone) {
const normalized = String(tone || '').trim()
if (['success', 'positive', 'emerald'].includes(normalized)) {
return 'success'
}
if (['warning', 'risk', 'amber'].includes(normalized)) {
return 'warning'
}
if (['danger', 'high'].includes(normalized)) {
return 'danger'
}
return 'info'
}
</script>
<style scoped>
:global(.expense-profile-dialog-overlay) {
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.34), rgba(15, 23, 42, 0.4)),
rgba(15, 23, 42, 0.36);
}
:global(.expense-profile-dialog.el-dialog) {
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 4px;
background: #ffffff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.2);
}
:global(.expense-profile-dialog .el-dialog__header),
:global(.expense-profile-dialog .expense-profile-dialog-body),
:global(.expense-profile-dialog .el-dialog__footer) {
padding: 0;
margin: 0;
}
:global(.expense-profile-dialog-zoom-enter-active),
:global(.expense-profile-dialog-zoom-leave-active) {
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1);
}
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
transform-origin: center center;
will-change: transform, opacity;
}
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog) {
animation: expenseProfileDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both;
}
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
animation: expenseProfileDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
:global(.expense-profile-dialog-zoom-enter-from),
:global(.expense-profile-dialog-zoom-leave-to) {
opacity: 0;
}
.profile-dialog-header,
.profile-dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
background: #ffffff;
}
.profile-dialog-header {
border-bottom: 1px solid #e2e8f0;
}
.profile-dialog-footer {
border-top: 1px solid #e2e8f0;
}
.profile-dialog-title-block {
min-width: 0;
}
.profile-dialog-eyebrow,
.profile-section-title small {
color: #64748b;
font-size: 10px;
font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
.profile-dialog-header h2 {
margin: 3px 0 4px;
color: #0f172a;
font-size: 19px;
line-height: 1.25;
font-weight: 850;
}
.profile-dialog-header p,
.profile-dialog-footer span {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
font-weight: 650;
}
.profile-dialog-close {
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
border-radius: 4px;
color: #334155;
font-size: 18px;
}
.profile-dialog-close:hover {
background: #eef4fb;
color: var(--theme-primary-active);
}
.profile-dialog-content {
max-height: min(660px, calc(100vh - 190px));
min-height: 0;
display: grid;
gap: 12px;
padding: 14px;
overflow: auto;
background: #f8fafc;
}
.profile-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.profile-summary-item,
.profile-panel,
.profile-tag-item {
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #ffffff;
}
.profile-summary-item {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 12px;
}
.profile-summary-item span,
.profile-operation-copy span,
.profile-operation-row time {
color: #64748b;
font-size: 11.5px;
font-weight: 650;
}
.profile-summary-item strong {
color: #0f172a;
font-size: 18px;
line-height: 1.15;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
.profile-summary-item small {
margin-left: 2px;
color: #64748b;
font-size: 11px;
font-weight: 650;
}
.profile-summary-item em {
overflow: hidden;
color: #94a3b8;
font-size: 11px;
font-style: normal;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-analysis-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.85fr);
gap: 12px;
}
.profile-panel {
min-width: 0;
display: grid;
gap: 10px;
padding: 12px;
}
.profile-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.profile-section-title > div {
min-width: 0;
display: grid;
gap: 2px;
}
.profile-section-title span {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.profile-tag-list,
.profile-operation-list {
display: grid;
gap: 8px;
}
.profile-tag-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 10px;
transition:
border-color 180ms var(--ease),
background-color 180ms var(--ease);
}
.profile-tag-item:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
background: #fbfdff;
}
.profile-tag-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.profile-tag-copy strong,
.profile-operation-copy strong {
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-tag-copy span {
overflow: hidden;
color: #64748b;
font-size: 11.5px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-score-tag,
.profile-operation-status {
border-radius: 4px;
font-weight: 800;
}
.profile-radar-layout {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(120px, 0.72fr);
align-items: center;
gap: 14px;
}
.profile-radar-chart {
height: 260px;
}
.profile-radar-list {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.profile-radar-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.profile-radar-list strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.profile-operation-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid #e8eef5;
}
.profile-operation-row:first-child {
border-top: 0;
padding-top: 0;
}
.profile-operation-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.profile-operation-status {
justify-self: end;
}
.profile-dialog-explain {
min-height: 32px;
border-radius: 4px;
font-weight: 800;
}
.profile-dialog-explain i {
margin-right: 6px;
}
@keyframes expenseProfileDialogIn {
0% {
opacity: 0;
transform: scale3d(0.94, 0.94, 1);
}
100% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
@keyframes expenseProfileDialogOut {
0% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
100% {
opacity: 0;
transform: scale3d(0.96, 0.96, 1);
}
}
@media (max-width: 860px) {
:global(.expense-profile-dialog.el-dialog) {
width: calc(100vw - 24px) !important;
}
.profile-summary-grid,
.profile-analysis-grid,
.profile-radar-layout {
grid-template-columns: 1fr;
}
.profile-dialog-content {
max-height: calc(100vh - 170px);
}
}
@media (max-width: 560px) {
.profile-dialog-header,
.profile-dialog-footer {
align-items: flex-start;
}
.profile-dialog-footer {
flex-direction: column;
}
.profile-operation-row {
grid-template-columns: 1fr;
align-items: start;
}
.profile-operation-status {
justify-self: start;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
animation-duration: 1ms !important;
}
}
</style>

View File

@@ -9,8 +9,7 @@
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${homepageBackground})` }">
<div class="assistant-copy">
<h1>你的专属 <span>AI 财务助手</span></h1>
<p>智能理解财务业务提供数据洞察与方案建议高效处理日常事务</p>
<h1>{{ displayUserName }}我是您的 <span>AI 费用助手</span></h1>
<input
ref="fileInputRef"
@@ -92,10 +91,10 @@
</div>
</article>
<div class="capability-grid" aria-label="AI 财务助手能力">
<div :class="['capability-grid', capabilityGridClass]" aria-label="AI 财务助手能力">
<button
v-for="item in assistantCapabilities"
:key="item.title"
v-for="item in visibleAssistantCapabilities"
:key="item.key"
type="button"
class="capability-card panel"
:class="`capability-card--${item.tone}`"
@@ -226,7 +225,9 @@
<button
type="button"
class="detail-action"
@click="openPromptAssistant('查看我的费用画像详情,并总结 AI 使用、提单效率和预审通过率。')"
aria-haspopup="dialog"
:aria-expanded="expenseProfileModalOpen"
@click="openExpenseProfileModal"
>
<span>查看详情</span>
<i class="mdi mdi-chevron-right"></i>
@@ -255,12 +256,24 @@
</article>
</aside>
</div>
<ExpenseProfileDetailModal
:visible="expenseProfileModalOpen"
:user-name="displayUserName"
:metrics="expenseProfileModalMetrics"
:tags="expenseProfileTags"
:radar-dimensions="expenseProfileRadarDimensions"
:operations="expenseProfileOperations"
@close="closeExpenseProfileModal"
@explain="explainExpenseProfile"
/>
</section>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import homepageBackground from '../../assets/homepage_backgraound.png'
import { useSystemState } from '../../composables/useSystemState.js'
@@ -268,6 +281,9 @@ import { useToast } from '../../composables/useToast.js'
import {
assistantCapabilities,
buildExpenseStatItems,
expenseProfileOperations,
expenseProfileRadarDimensions,
expenseProfileTags,
progressItems,
progressSteps,
quickPromptItems,
@@ -296,15 +312,45 @@ const selectedFiles = ref([])
const pendingAction = ref('')
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const expenseProfileModalOpen = ref(false)
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const FINANCIAL_CAPABILITY_KEYS = new Set(['budget-planning', 'finance-analysis'])
const FINANCIAL_CAPABILITY_ROLE_CODES = new Set(['budget_monitor', 'executive', 'admin'])
const FINANCIAL_CAPABILITY_ROLE_LABELS = new Set(['预算监控员', '高级财务人员', '管理员'])
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|| hasLocalExpenseSnapshot.value
)
const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const currentRoleCodes = computed(() => {
const user = currentUser.value || {}
const rawCodes = Array.isArray(user.roleCodes)
? user.roleCodes
: Array.isArray(user.role_codes)
? user.role_codes
: []
return new Set(rawCodes.map((code) => String(code || '').trim().toLowerCase()).filter(Boolean))
})
const canViewFinancialCapabilities = computed(() => {
const user = currentUser.value || {}
const roleLabel = String(user.role || '').trim()
return Boolean(user.isAdmin)
|| FINANCIAL_CAPABILITY_ROLE_LABELS.has(roleLabel)
|| Array.from(currentRoleCodes.value).some((code) => FINANCIAL_CAPABILITY_ROLE_CODES.has(code))
})
const visibleAssistantCapabilities = computed(() =>
assistantCapabilities.filter((item) => canViewFinancialCapabilities.value || !FINANCIAL_CAPABILITY_KEYS.has(item.key))
)
const capabilityGridClass = computed(() =>
canViewFinancialCapabilities.value ? 'capability-grid--privileged' : 'capability-grid--standard'
)
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
const visibleExpenseStatItems = computed(() => {
const preferredKeys = ['monthly-amount', 'monthly-count', 'in-review', 'pending-payment']
@@ -318,6 +364,12 @@ const visibleUsageProfileMetrics = computed(() => {
.map((key) => usageProfileMetrics.find((item) => item.key === key))
.filter(Boolean)
})
const expenseProfileModalMetrics = computed(() => {
const preferredKeys = ['stay-duration', 'ai-usage', 'auto-pass-rate', 'audit-duration']
return preferredKeys
.map((key) => usageProfileMetrics.find((item) => item.key === key))
.filter(Boolean)
})
const visibleTodoItems = computed(() => todoItems.slice(0, 5))
const visibleProgressItems = computed(() => progressItems.slice(0, 5))
const todoAlertCount = computed(() => visibleTodoItems.value.length)
@@ -417,6 +469,19 @@ function openPromptAssistant(prompt) {
})
}
function openExpenseProfileModal() {
expenseProfileModalOpen.value = true
}
function closeExpenseProfileModal() {
expenseProfileModalOpen.value = false
}
function explainExpenseProfile() {
closeExpenseProfileModal()
openPromptAssistant('请根据我的费用画像标签、行为雷达和最近 5 次操作,解释我的费用使用特点和可以优化的地方。')
}
function handleWorkbenchEnter(event) {
if (event.isComposing) {
return

View File

@@ -0,0 +1,154 @@
<template>
<div class="radar-chart">
<Radar :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Radar } from 'vue-chartjs'
import {
Chart as ChartJS,
Filler,
Legend,
LineElement,
PointElement,
RadialLinearScale,
Tooltip
} from 'chart.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
ChartJS.register(RadialLinearScale, PointElement, LineElement, Filler, Tooltip, Legend)
const props = defineProps({
items: { type: Array, required: true },
label: { type: String, default: '行为评分' },
max: { type: Number, default: 100 }
})
const themeColors = useThemeColors()
const normalizedItems = computed(() =>
props.items.map((item) => ({
code: String(item.code || item.label || '').trim(),
label: String(item.label || item.code || '').trim(),
score: clampScore(item.score)
}))
)
const chartData = computed(() => {
const primary = themeColors.value.chartPrimary
return {
labels: normalizedItems.value.map((item) => item.label),
datasets: [
{
label: props.label,
data: normalizedItems.value.map((item) => item.score),
borderColor: primary,
backgroundColor: toRgba(primary, 0.16),
pointBackgroundColor: '#ffffff',
pointBorderColor: primary,
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 4,
borderWidth: 2,
fill: true,
tension: 0.18
}
]
}
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 760,
easing: 'easeOutQuart'
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.98)',
titleColor: '#0f172a',
bodyColor: '#475569',
borderColor: 'rgba(148, 163, 184, 0.28)',
borderWidth: 1,
cornerRadius: 4,
padding: 10,
displayColors: false,
callbacks: {
label: (context) => `${context.dataset.label}: ${context.parsed.r}`
}
}
},
scales: {
r: {
min: 0,
max: props.max,
beginAtZero: true,
ticks: {
display: false,
stepSize: 25
},
grid: {
color: 'rgba(148, 163, 184, 0.22)',
circular: false
},
angleLines: {
color: 'rgba(148, 163, 184, 0.18)'
},
pointLabels: {
color: '#475569',
padding: 8,
font: {
size: 11,
weight: '700'
}
}
}
}
}))
function clampScore(value) {
const score = Number(value || 0)
if (!Number.isFinite(score)) {
return 0
}
return Math.max(0, Math.min(props.max, score))
}
function toRgba(color, alpha) {
const normalized = String(color || '').trim()
const hex = normalized.replace('#', '')
if (/^[\da-f]{3}$/i.test(hex)) {
const [r, g, b] = hex.split('').map((part) => parseInt(part + part, 16))
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
if (/^[\da-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
if (normalized.startsWith('rgb(')) {
return normalized.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
}
return `rgba(58, 124, 165, ${alpha})`
}
</script>
<style scoped>
.radar-chart {
position: relative;
width: 100%;
min-width: 0;
height: 260px;
}
</style>

View File

@@ -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(' · '))

View File

@@ -98,14 +98,20 @@
</template>
<template v-else-if="isWorkbench">
<div class="kpi-chips">
<div v-for="kpi in workbenchKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">
{{ kpi.value }}<small v-if="kpi.unit">{{ kpi.unit }}</small>
</span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
<div class="topbar-toolset" aria-label="工作台快捷工具">
<button class="topbar-icon-btn notification-btn" type="button" aria-label="通知">
<i class="mdi mdi-bell-outline"></i>
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
</button>
<button class="topbar-icon-btn" type="button" aria-label="帮助">
<i class="mdi mdi-help-circle-outline"></i>
</button>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</template>
@@ -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(() => {

View File

@@ -0,0 +1,622 @@
<template>
<article class="detail-card panel employee-risk-profile-card">
<div class="employee-risk-head">
<div>
<h3 class="detail-card-title-with-icon">
<i class="mdi mdi-account-search-outline"></i>
<span>风险审核画像</span>
</h3>
<p>{{ subtitle }}</p>
</div>
<span v-if="!loading && !error" :class="['profile-level-pill', levelTone]">
{{ levelLabel }}
</span>
</div>
<div v-if="loading" class="employee-risk-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在读取画像快照</span>
</div>
<div v-else-if="error" class="employee-risk-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ error }}</span>
</div>
<div v-else-if="emptyReason" class="employee-risk-state">
<i class="mdi mdi-database-search-outline"></i>
<span>{{ emptyReason }}</span>
</div>
<div v-else class="employee-risk-body">
<div class="employee-risk-summary">
<div>
<span>审核优先级</span>
<strong>{{ reviewScore }}</strong>
</div>
<div>
<span>计算窗口</span>
<strong>{{ profile?.window_days || 90 }} </strong>
</div>
<div>
<span>同组样本</span>
<strong>{{ profile?.peer_group?.sample_size || 0 }} </strong>
</div>
<div>
<span>更新时间</span>
<strong>{{ calculatedAtText }}</strong>
</div>
</div>
<div v-if="tags.length" class="employee-risk-tags">
<span>特征标签</span>
<div>
<span
v-for="tag in tags"
:key="tag.code"
:class="['employee-risk-tag', tagTone(tag)]"
:title="tag.reason"
>
{{ tag.display_label || tag.label }}
<strong>{{ tag.score }}</strong>
</span>
</div>
</div>
<div v-if="radarDimensions.length" class="employee-risk-radar">
<div class="employee-risk-radar-head">
<span>行为雷达</span>
<small>分数越高表示该行为特征越明显</small>
</div>
<div class="employee-risk-radar-layout">
<svg class="employee-risk-radar-chart" viewBox="0 0 104 104" aria-hidden="true">
<polygon
v-for="ring in radarRings"
:key="ring.scale"
class="employee-risk-radar-ring"
:points="ring.points"
/>
<line
v-for="axis in radarAxes"
:key="axis.key"
class="employee-risk-radar-axis"
x1="52"
y1="52"
:x2="axis.x"
:y2="axis.y"
/>
<polygon class="employee-risk-radar-area" :points="radarPolygonPoints" />
<circle
v-for="point in radarValuePoints"
:key="point.key"
class="employee-risk-radar-point"
:cx="point.x"
:cy="point.y"
r="2"
/>
</svg>
<ul class="employee-risk-radar-list">
<li v-for="item in radarDimensions" :key="item.code">
<span>{{ item.label }}</span>
<strong>{{ item.score }}</strong>
</li>
</ul>
</div>
</div>
<div class="employee-risk-profile-list">
<section v-for="item in profiles" :key="item.profile_type" class="employee-risk-profile">
<div class="employee-risk-profile-title">
<span>{{ item.profile_label }}</span>
<strong :class="profileLevelClass(item.level)">{{ item.score }}</strong>
</div>
<ul v-if="item.top_contributors?.length" class="employee-risk-evidence-list">
<li v-for="basis in item.top_contributors.slice(0, 3)" :key="basis.code">
<span>{{ basis.label }}</span>
<strong>{{ formatBasisValue(basis) }}</strong>
</li>
</ul>
<p v-else class="employee-risk-muted">暂无显著贡献项</p>
</section>
</div>
<div v-if="suggestions.length" class="employee-risk-suggestions">
<span>审核建议</span>
<ul>
<li v-for="item in suggestions" :key="item.type || item.message">
{{ item.message }}
<strong v-if="item.recommended_upper">建议上限 {{ item.recommended_upper }}{{ item.unit || '' }}</strong>
</li>
</ul>
</div>
</div>
</article>
</template>
<script>
import { computed } from 'vue'
export default {
name: 'EmployeeProfileRiskCard',
props: {
profile: {
type: Object,
default: null
},
loading: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
}
},
setup(props) {
const profiles = computed(() => Array.isArray(props.profile?.profiles) ? props.profile.profiles : [])
const tags = computed(() => Array.isArray(props.profile?.profile_tags) ? props.profile.profile_tags.slice(0, 6) : [])
const radarDimensions = computed(() => Array.isArray(props.profile?.radar?.dimensions) ? props.profile.radar.dimensions : [])
const suggestions = computed(() => Array.isArray(props.profile?.review_suggestions) ? props.profile.review_suggestions : [])
const emptyReason = computed(() => String(props.profile?.empty_reason || '').trim())
const reviewScore = computed(() => Number(props.profile?.review_priority_score || 0))
const level = computed(() => String(props.profile?.review_priority_level || 'normal').trim())
const levelLabel = computed(() => String(props.profile?.review_priority_label || '正常').trim())
const levelTone = computed(() => profileLevelClass(level.value))
const subtitle = computed(() => {
if (props.loading) {
return '读取员工近期费用和流程质量画像。'
}
if (props.error || emptyReason.value) {
return '当前画像不可用,审批时按单据事实继续核对。'
}
const windowDays = props.profile?.window_days || 90
const sampleSize = props.profile?.peer_group?.sample_size || 0
return `${windowDays} 天窗口,同组样本 ${sampleSize} 人,用于辅助复核费用节奏和材料质量。`
})
const calculatedAtText = computed(() => formatDateTime(props.profile?.calculated_at))
const radarRings = computed(() => [0.25, 0.5, 0.75, 1].map((scale) => ({
scale,
points: radarDimensions.value.map((_, index) => radarPoint(index, radarDimensions.value.length, scale)).join(' ')
})))
const radarAxes = computed(() => radarDimensions.value.map((item, index) => ({
key: item.code,
...radarPointObject(index, radarDimensions.value.length, 1)
})))
const radarValuePoints = computed(() => radarDimensions.value.map((item, index) => ({
key: item.code,
...radarPointObject(index, radarDimensions.value.length, Number(item.score || 0) / 100)
})))
const radarPolygonPoints = computed(() => radarValuePoints.value.map((point) => `${point.x},${point.y}`).join(' '))
function formatDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return '暂无'
}
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) {
return normalized.slice(0, 16)
}
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
function profileLevelClass(value) {
const normalized = String(value || '').trim()
if (normalized === 'escalation') {
return 'high'
}
if (normalized === 'review') {
return 'medium'
}
if (normalized === 'watch') {
return 'watch'
}
return 'normal'
}
function radarPoint(index, total, scale) {
const point = radarPointObject(index, total, scale)
return `${point.x},${point.y}`
}
function radarPointObject(index, total, scale) {
if (!total) {
return { x: 52, y: 52 }
}
const angle = (-90 + (360 / total) * index) * (Math.PI / 180)
const radius = 42 * Math.max(0, Math.min(1, scale))
return {
x: Number((52 + Math.cos(angle) * radius).toFixed(2)),
y: Number((52 + Math.sin(angle) * radius).toFixed(2))
}
}
function tagTone(tag) {
const polarity = String(tag?.polarity || '').trim()
if (polarity === 'positive') {
return 'positive'
}
if (Number(tag?.score || 0) >= 80 || polarity === 'risk') {
return 'risk'
}
return 'behavior'
}
function formatBasisValue(basis) {
const value = basis?.value
const unit = String(basis?.unit || '').trim()
if (value == null || value === '') {
return basis?.score != null ? `${basis.score}` : ''
}
if (unit === '占比') {
const ratio = Number(value)
return Number.isFinite(ratio) ? `${Math.round(ratio * 100)}%` : String(value)
}
return `${value}${unit && unit !== '比例' ? unit : ''}`
}
return {
calculatedAtText,
emptyReason,
formatBasisValue,
levelLabel,
levelTone,
profileLevelClass,
profiles,
radarAxes,
radarDimensions,
radarPolygonPoints,
radarRings,
radarValuePoints,
reviewScore,
subtitle,
suggestions,
tags,
tagTone
}
}
}
</script>
<style scoped>
.employee-risk-profile-card {
display: grid;
gap: 14px;
}
.employee-risk-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.employee-risk-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.profile-level-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.profile-level-pill.normal,
.employee-risk-profile-title strong.normal {
background: #ecfdf5;
color: #047857;
}
.profile-level-pill.watch,
.employee-risk-profile-title strong.watch {
background: #eff6ff;
color: #2563eb;
}
.profile-level-pill.medium,
.employee-risk-profile-title strong.medium {
background: #fff7ed;
color: #c2410c;
}
.profile-level-pill.high,
.employee-risk-profile-title strong.high {
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-state {
min-height: 78px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b;
font-size: 13px;
font-weight: 750;
}
.employee-risk-state.error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-body {
display: grid;
gap: 12px;
}
.employee-risk-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.employee-risk-summary > div {
min-width: 0;
display: grid;
gap: 4px;
padding: 9px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.employee-risk-summary span,
.employee-risk-suggestions > span {
color: #64748b;
font-size: 10px;
font-weight: 850;
}
.employee-risk-summary strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
overflow-wrap: anywhere;
}
.employee-risk-tags {
display: grid;
gap: 8px;
}
.employee-risk-tags > span,
.employee-risk-radar-head span {
color: #64748b;
font-size: 10px;
font-weight: 850;
}
.employee-risk-tags > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.employee-risk-tag {
min-height: 24px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 11px;
font-weight: 850;
}
.employee-risk-tag strong {
color: inherit;
font-size: 10px;
}
.employee-risk-tag.risk {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-tag.behavior {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.employee-risk-tag.positive {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.employee-risk-radar {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.employee-risk-radar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.employee-risk-radar-head small {
color: #94a3b8;
font-size: 10px;
font-weight: 700;
}
.employee-risk-radar-layout {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.employee-risk-radar-chart {
width: 112px;
height: 112px;
}
.employee-risk-radar-ring {
fill: none;
stroke: #e2e8f0;
stroke-width: 0.75;
}
.employee-risk-radar-axis {
stroke: #e2e8f0;
stroke-width: 0.75;
}
.employee-risk-radar-area {
fill: rgba(37, 99, 235, 0.16);
stroke: #2563eb;
stroke-width: 1.8;
}
.employee-risk-radar-point {
fill: #2563eb;
}
.employee-risk-radar-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
margin: 0;
padding: 0;
list-style: none;
}
.employee-risk-radar-list li {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #475569;
font-size: 11px;
font-weight: 750;
}
.employee-risk-radar-list strong {
color: #0f172a;
font-size: 12px;
font-weight: 900;
}
.employee-risk-profile-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.employee-risk-profile {
min-width: 0;
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.employee-risk-profile-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.employee-risk-profile-title strong {
min-width: 36px;
height: 22px;
display: inline-grid;
place-items: center;
border-radius: 999px;
font-size: 12px;
font-weight: 900;
}
.employee-risk-evidence-list,
.employee-risk-suggestions ul {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.employee-risk-evidence-list li,
.employee-risk-suggestions li {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.employee-risk-evidence-list strong,
.employee-risk-suggestions strong {
color: #0f172a;
white-space: nowrap;
}
.employee-risk-muted {
margin: 0;
color: #94a3b8;
font-size: 12px;
}
.employee-risk-suggestions {
display: grid;
gap: 7px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
@media (max-width: 960px) {
.employee-risk-summary,
.employee-risk-profile-list {
grid-template-columns: 1fr;
}
.employee-risk-radar-layout {
grid-template-columns: 1fr;
}
.employee-risk-radar-chart {
justify-self: center;
}
}
</style>

View File

@@ -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)

View File

@@ -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: '数字员工',

View File

@@ -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'

View File

@@ -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'
}
]

View File

@@ -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'

View File

@@ -1,10 +1,10 @@
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'documents',
'budget',
'policies',
'audit',
'overview',
'policies',
'digitalEmployees',
'logs',
'employees',

View File

@@ -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) {

View File

@@ -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'
}

View File

@@ -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()
}

View File

@@ -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'
}

View File

@@ -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)
)

View File

@@ -1,17 +1,18 @@
<template>
<div class="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<div class="app" :class="{ 'sidebar-collapsed': sidebarCollapsed, 'mobile-sidebar-open': mobileSidebarOpen }">
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
<div class="app-sidebar">
<SidebarRail
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="companyProfile.name"
:company-name="PRODUCT_DISPLAY_NAME"
:company-logo="companyProfile.logo"
:current-user="currentUser"
:collapsed="sidebarCollapsed"
@navigate="handleNavigate"
@open-chat="openSmartEntry"
@logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
/>
</div>
@@ -43,7 +44,7 @@
:logs-summary="logsSummary"
:request-summary="requestSummary"
:document-summary="documentSummary"
:workbench-summary="workbenchSummary"
:company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="detailMode"
:log-detail-mode="logDetailMode"
:detail-alerts="detailAlerts"
@@ -176,11 +177,17 @@ const logsSummary = ref(null)
const documentSummary = ref(null)
const auditDetailOpen = ref(false)
const sidebarCollapsed = ref(false)
const mobileSidebarOpen = ref(false)
function toggleSidebarCollapsed() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function handleNavigateWithMobileClose(viewId) {
handleNavigate(viewId)
mobileSidebarOpen.value = false
}
const {
activeRange,
activeView,
@@ -222,6 +229,8 @@ const {
} = useAppShell()
const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
function handleLogout() {

View File

@@ -61,7 +61,7 @@
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 归档中心保存公司已归档入账的报销数据点击单据行查看详情</p>
<p class="hint"><i class="mdi mdi-information-outline"></i> 归档中心保存公司已付款或已归档的报销数据点击单据行查看详情</p>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">

View File

@@ -262,7 +262,7 @@ const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
const DOCUMENT_SCOPE_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: {
searchPlaceholder: '搜索单号、事项、费用场景...',
@@ -301,7 +301,7 @@ const FILTER_CONFIG_BY_SCOPE = {
sceneFallbackLabel: '归档场景',
dateLabel: '归档时间',
statusTitle: '归档状态',
statusTabs: ['全部', '已完成'],
statusTabs: ['全部', '已付款', '已完成'],
showDocumentType: false
}
}
@@ -522,9 +522,19 @@ function resolveArchivedDocumentNode(normalized, documentTypeCode) {
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
return '申请归档'
}
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款') {
return '已付款'
}
return normalized.node || normalized.workflowNode || '财务归档'
}
function resolveArchivedStatusLabel(normalized) {
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款' || normalized.node === '已付款') {
return '已付款'
}
return '已归档'
}
function buildDocumentRow(request, options = {}) {
const normalized = normalizeRequestForUi(request)
if (!normalized) {
@@ -533,7 +543,7 @@ function buildDocumentRow(request, options = {}) {
const archived = Boolean(options.archived)
const statusGroup = resolveStatusGroup(normalized, archived)
const statusLabel = archived ? '已归档' : resolveStatusLabel(normalized, statusGroup)
const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
@@ -581,6 +591,7 @@ function resolveStatusGroup(row, archived) {
if (row.approvalKey === 'draft') return 'draft'
if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
if (row.approvalKey === 'supplement') return 'supplement'
if (row.approvalKey === 'pending_payment') return 'pending_payment'
if (row.approvalKey === 'in_progress') return 'in_progress'
if (row.approvalKey === 'completed') return 'completed'
return 'other'
@@ -588,6 +599,7 @@ function resolveStatusGroup(row, archived) {
function resolveStatusLabel(row, statusGroup) {
if (statusGroup === 'pending_submit') return '待提交'
if (statusGroup === 'pending_payment') return '待付款'
return row.approval || row.approvalStatus || '处理中'
}
@@ -606,6 +618,8 @@ function matchesStatusTab(row, tab) {
if (tab === '待提交') return row.statusGroup === 'pending_submit'
if (tab === '审批中') return row.statusGroup === 'in_progress'
if (tab === '待补充') return row.statusGroup === 'supplement'
if (tab === '待付款') return row.statusGroup === 'pending_payment'
if (tab === '已付款') return row.statusLabel === '已付款' || row.node === '已付款'
if (tab === '已完成') return row.statusGroup === 'completed'
return true
}

View File

@@ -231,6 +231,30 @@
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>银行信息</h3>
<p>维护员工付款打款所需的户名开户行与银行账号</p>
</div>
</div>
<div class="form-grid">
<label class="field">
<span>户名</span>
<input v-model="employeeForm.bankAccountName" />
</label>
<label class="field">
<span>开户行</span>
<input v-model="employeeForm.bankName" />
</label>
<label class="field">
<span>银行账号</span>
<input v-model="employeeForm.bankAccountNo" inputmode="numeric" />
</label>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>

View File

@@ -1,6 +1,6 @@
<template>
<LoginView
:company-name="companyProfile.name"
:company-name="LOGIN_BRAND_NAME"
:submitting="loginSubmitting"
:error-message="loginError"
@login="submitLogin"
@@ -18,7 +18,6 @@ import LoginView from './LoginView.vue'
const route = useRoute()
const router = useRouter()
const {
companyProfile,
handleLogin,
handleRecoverPassword,
handleSsoLogin,
@@ -27,6 +26,8 @@ const {
resolveEntryRoute
} = useSystemState()
const LOGIN_BRAND_NAME = '易财费控'
async function submitLogin(credentials) {
const passed = await handleLogin(credentials)

View File

@@ -104,10 +104,12 @@
<label class="field">
<span class="sr-only">企业或租户</span>
<i class="mdi mdi-office-building"></i>
<input v-model="tenant" type="text" placeholder="请输入企业 / 租户(选填)" />
<button class="field-icon-btn" type="button" aria-label="展开企业列表">
<select v-model="tenant" class="tenant-select" aria-label="请选择企业或租户">
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
</select>
<span class="field-select-chevron" aria-hidden="true">
<i class="mdi mdi-chevron-down"></i>
</button>
</span>
</label>
<div class="form-meta">
@@ -162,7 +164,7 @@ const props = defineProps({
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
const displayCompanyName = computed(() => props.companyName || '易财费控')
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
</script>

View File

@@ -485,6 +485,13 @@
</div>
</article>
<EmployeeProfileRiskCard
v-if="showEmployeeRiskProfile"
:profile="employeeRiskProfile"
:loading="employeeRiskProfileLoading"
:error="employeeRiskProfileError"
/>
</section>
</div>
</div>
@@ -504,7 +511,7 @@
{{ submitBusy ? '提交中' : '提交审批' }}
</button>
</div>
<div v-else-if="canReturnRequest || canApproveRequest || canDeleteRequest" class="approval-action-group" aria-label="单据管理操作">
<div v-else-if="canReturnRequest || canApproveRequest || canPayRequest || canDeleteRequest" class="approval-action-group" aria-label="单据管理操作">
<button
v-if="canReturnRequest"
class="return-action"
@@ -525,6 +532,16 @@
<i class="mdi mdi-check-circle-outline"></i>
{{ approveBusy ? approveBusyLabel : approveActionLabel }}
</button>
<button
v-if="canPayRequest"
class="approve-action"
type="button"
:disabled="actionBusy"
@click="handlePayRequest"
>
<i class="mdi mdi-cash-check"></i>
{{ payBusy ? '付款中' : '确认付款' }}
</button>
<button
v-if="canDeleteRequest"
class="reject-action"
@@ -774,6 +791,22 @@
@confirm="confirmApproveRequest"
/>
<ConfirmDialog
:open="payConfirmDialogOpen"
badge="付款确认"
badge-tone="warning"
:title="`确认 ${request.id} 已付款吗?`"
description="确认后该报销单会进入已付款状态,并汇总到归档视图。"
cancel-text="返回核对"
confirm-text="确认已付款"
busy-text="付款中..."
confirm-tone="primary"
confirm-icon="mdi mdi-cash-check"
:busy="payBusy"
@close="closePayConfirmDialog"
@confirm="confirmPayRequest"
/>
<TravelRequestReturnDialog
:open="returnDialogOpen"
:title="`确认退回 ${request.id} 吗?`"

View File

@@ -74,7 +74,7 @@ function buildArchiveRow(request) {
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
archiveType: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION : ARCHIVE_TYPE_REIMBURSEMENT,
archiveTypeCode: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION_CODE : ARCHIVE_TYPE_REIMBURSEMENT_CODE,
node: isApplicationDocument ? '申请归档' : (normalized.workflowNode || '归档入账'),
node: isApplicationDocument ? '申请归档' : (normalized.workflowNode || '已付款'),
hasRisk,
riskCount,
risk: formatArchiveRiskCountLabel(riskCount),
@@ -168,13 +168,13 @@ export default {
return {
eyebrow: '归档中心',
title: '当前还没有已归档单据',
desc: '财务终审通过并进入「归档入账」节点的报销单会自动汇总到这里,形成公司级财务归档库。',
desc: '财务确认付款后进入「已付款」状态的报销单会自动汇总到这里,形成公司级财务归档库。',
icon: 'mdi mdi-archive-check-outline',
actionLabel: null,
actionIcon: null,
tone: 'slate',
artLabel: 'ARCHIVE',
tips: ['仅展示已归档入账的单据', '申请人仍可在报销中心查看自己的归档记录']
tips: ['仅展示已付款或已归档的单据', '申请人仍可在报销中心查看自己的归档记录']
}
}

View File

@@ -16,6 +16,12 @@ import {
importEmployees,
updateEmployee
} from '../../services/employees.js'
import {
appendEmployeeBankUpdatePayload,
createEmployeeBankFormFields,
getEmployeeBankSearchFields,
mapEmployeeBankFormFields
} from './employeeBankFields.js'
const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
const FALLBACK_ROLE_OPTIONS = [
@@ -76,6 +82,7 @@ function createEmployeeForm() {
managerEmployeeNo: '',
financeOwner: '',
costCenter: '',
...createEmployeeBankFormFields(),
roleCodes: [],
password: ''
}
@@ -197,6 +204,7 @@ function buildEmployeeForm(employee, roster = []) {
managerEmployeeNo,
financeOwner: employee.financeOwner || '',
costCenter: employee.costCenter || '',
...mapEmployeeBankFormFields(employee),
roleCodes: [...(employee.roleCodes || [])],
password: ''
}
@@ -380,6 +388,7 @@ function matchKeyword(employee, keyword) {
employee.email,
employee.manager,
employee.financeOwner,
...getEmployeeBankSearchFields(employee),
employee.syncState
]
@@ -1053,6 +1062,8 @@ export default {
payload.cost_center = nextCostCenter
}
appendEmployeeBankUpdatePayload(payload, form, current, normalizeNullableText)
const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo)
const currentManagerEmployeeNo =
normalizeNullableText(current.managerEmployeeNo) ||

View File

@@ -15,6 +15,9 @@ export default {
knowledgeAggregation: { icon: 'mdi-sync', color: 'indigo' },
ruleReviewDigest: { icon: 'mdi-bell-ring-outline', color: 'warning' },
riskSummary: { icon: 'mdi-shield-search', color: 'danger' },
employee_behavior_profile_scan: { icon: 'mdi-account-search-outline', color: 'primary' },
global_risk_scan: { icon: 'mdi-shield-search', color: 'danger' },
weekly_expense_report: { icon: 'mdi-chart-box-outline', color: 'success' },
archiveDigest: { icon: 'mdi-archive-outline', color: 'info' },
dailyStats: { icon: 'mdi-chart-line', color: 'success' },
monthlyStats: { icon: 'mdi-chart-bar', color: 'primary' },
@@ -56,4 +59,3 @@ export default {
}
}

View File

@@ -26,7 +26,7 @@ export default {
emits: ['ask', 'approve', 'reject', 'create-request', 'reload'],
setup(props, { emit }) {
const activeTab = ref('全部')
const tabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
const tabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
const filters = ['报销状态', '报销类型', '所属主体']
const listKeyword = ref('')
@@ -102,6 +102,7 @@ export default {
|| (activeTab.value === '待提交' && row.approvalKey === 'supplement' && row.status === 'returned')
|| (activeTab.value === '审批中' && row.approvalKey === 'in_progress')
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement' && row.status !== 'returned')
|| (activeTab.value === '待付款' && row.approvalKey === 'pending_payment')
|| (activeTab.value === '已完成' && row.approvalKey === 'completed')
return matchesKeyword && matchesDateRange && matchesTab

View File

@@ -7,6 +7,7 @@ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import EmployeeProfileRiskCard from '../../components/travel/EmployeeProfileRiskCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
approveExpenseClaim,
@@ -14,6 +15,7 @@ import {
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
deleteExpenseClaim,
fetchEmployeeLatestProfile,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
returnExpenseClaim,
@@ -72,6 +74,7 @@ import {
resolveExpenseReasonPlaceholder,
resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
/*
* 以下片段仅用于兼容现有源码正则测试。
@@ -370,6 +373,7 @@ export default {
components: {
ConfirmDialog,
EnterpriseSelect,
EmployeeProfileRiskCard,
TravelRequestApprovalDialog,
TravelRequestBudgetAnalysis,
TravelRequestDeleteDialog,
@@ -432,6 +436,10 @@ export default {
})
const detailNoteEditor = ref('')
const savingDetailNote = ref(false)
const employeeRiskProfile = ref(null)
const employeeRiskProfileLoading = ref(false)
const employeeRiskProfileError = ref('')
let employeeRiskProfileLoadSeq = 0
const request = computed(() => {
const normalized = normalizeRequestForUi(props.request)
@@ -520,6 +528,29 @@ export default {
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value
))
const employeeProfileId = computed(() =>
String(
request.value.employeeId
|| request.value.employee_id
|| request.value.profileEmployeeId
|| ''
).trim()
)
const employeeRiskProfileScope = computed(() => {
const typeCode = String(request.value.typeCode || request.value.expense_type || '').trim()
if (typeCode === 'meal' || typeCode === 'entertainment') {
return 'entertainment'
}
if (typeCode === 'travel' || isTravelRequest.value) {
return 'travel'
}
return typeCode || 'overall'
})
const showEmployeeRiskProfile = computed(() =>
Boolean(employeeProfileId.value)
&& Boolean(request.value.claimId)
&& !isDraftRequest.value
)
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
@@ -545,6 +576,21 @@ export default {
|| canProcessBudgetApprovalStage.value
)
)
const {
canPayRequest,
closePayConfirmDialog,
confirmPayRequest,
handlePayRequest,
payBusy,
payConfirmDialogOpen
} = useTravelRequestPaymentFlow({
request,
currentUser,
isApplicationDocument,
isCurrentApplicant,
toast,
emit
})
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
@@ -572,7 +618,7 @@ export default {
})
const approvalOpinionHint = computed(() => {
if (isFinanceApprovalStage.value) {
return '审核通过后将进入归档入账。'
return '审核通过后将进入待付款。'
}
if (isBudgetApprovalStage.value) {
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
@@ -587,7 +633,7 @@ export default {
})
const approvalConfirmDescription = computed(() => {
if (isFinanceApprovalStage.value) {
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
return '确认后该报销单会完成财务终审并进入待付款,请确认票据、金额与财务意见无误。'
}
if (isApplicationDocument.value) {
return isBudgetApprovalStage.value
@@ -610,7 +656,7 @@ export default {
))
const approvalSuccessToast = computed(() => {
if (isFinanceApprovalStage.value) {
return `${request.value.id} 已完成财务终审,进入归档入账`
return `${request.value.id} 已完成财务终审,进入待付款`
}
return isApplicationDocument.value
? isBudgetApprovalStage.value
@@ -632,6 +678,7 @@ export default {
|| deleteBusy.value
|| returnBusy.value
|| approveBusy.value
|| payBusy.value
|| creatingExpense.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
@@ -672,6 +719,19 @@ export default {
{ immediate: true }
)
watch(
() => [
employeeProfileId.value,
request.value.claimId,
employeeRiskProfileScope.value,
showEmployeeRiskProfile.value
],
() => {
void loadEmployeeRiskProfile()
},
{ immediate: true }
)
const heroFactItems = computed(() => [
{
key: 'document',
@@ -887,6 +947,38 @@ export default {
return payload
}
async function loadEmployeeRiskProfile() {
const sequence = ++employeeRiskProfileLoadSeq
employeeRiskProfileError.value = ''
if (!showEmployeeRiskProfile.value) {
employeeRiskProfile.value = null
employeeRiskProfileLoading.value = false
return
}
employeeRiskProfileLoading.value = true
try {
const payload = await fetchEmployeeLatestProfile(employeeProfileId.value, {
scene: 'approval',
claim_id: request.value.claimId,
window_days: 90,
expense_type_scope: employeeRiskProfileScope.value
})
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = payload
}
} catch (error) {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = error?.message || '画像读取失败,请稍后重试。'
}
} finally {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfileLoading.value = false
}
}
}
function canPreviewAttachment(item) {
if (!item?.invoiceId) {
return false
@@ -1831,24 +1923,26 @@ export default {
applicationDetailFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry, canApproveRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closeSubmitConfirmDialog,
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmRiskOverrideReasons,
confirmPayRequest, confirmRiskOverrideReasons,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
employeeRiskProfile, employeeRiskProfileError, employeeRiskProfileLoading,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
handlePayRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
@@ -1857,7 +1951,7 @@ export default {
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis,
showBudgetAnalysis, showEmployeeRiskProfile,
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit

View File

@@ -8,6 +8,7 @@ const TASK_TYPE_LABELS = {
weekly_expense_report: '周度费用洞察',
rule_review_digest: '规则待审摘要',
knowledge_index_sync: '知识库归集',
finance_policy_knowledge_organize: '整理公司财务知识制度',
llm_wiki_rule_formation: '知识库归集',
x_financial_callback: '任务回调上报'
}
@@ -19,6 +20,7 @@ const TASK_TYPE_SKILL_CATEGORIES = {
weekly_expense_report: '整理',
rule_review_digest: '升级',
knowledge_index_sync: '积累',
finance_policy_knowledge_organize: '整理',
llm_wiki_rule_formation: '积累',
x_financial_callback: '升级'
}

View File

@@ -0,0 +1,40 @@
export function createEmployeeBankFormFields() {
return {
bankAccountName: '',
bankName: '',
bankAccountNo: ''
}
}
export function mapEmployeeBankFormFields(employee) {
return {
bankAccountName: employee?.bankAccountName || '',
bankName: employee?.bankName || '',
bankAccountNo: employee?.bankAccountNo || ''
}
}
export function getEmployeeBankSearchFields(employee) {
return [
employee?.bankAccountName,
employee?.bankName,
employee?.bankAccountNo
]
}
export function appendEmployeeBankUpdatePayload(payload, form, current, normalizeNullableText) {
const nextBankAccountName = normalizeNullableText(form.bankAccountName)
if (nextBankAccountName !== (current.bankAccountName || null)) {
payload.bank_account_name = nextBankAccountName
}
const nextBankName = normalizeNullableText(form.bankName)
if (nextBankName !== (current.bankName || null)) {
payload.bank_name = nextBankName
}
const nextBankAccountNo = normalizeNullableText(form.bankAccountNo)
if (nextBankAccountNo !== (current.bankAccountNo || null)) {
payload.bank_account_no = nextBankAccountNo
}
}

View File

@@ -20,7 +20,8 @@ const STATUS_LABELS = {
approved: '已审批',
completed: '已完成',
archived: '已归档',
paid: '已入账'
pending_payment: '待付款',
paid: '已付款'
}
const EXPENSE_TYPE_LABELS = {
@@ -291,4 +292,3 @@ export function buildRequiredApplicationMissingText(expenseType) {
'请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。'
].join('\n')
}

View File

@@ -13,7 +13,8 @@ const EXPENSE_STATUS_LABELS = {
submitted: '已提交',
review: '审批中',
approved: '已审核',
paid: '已入账'
pending_payment: '待付款',
paid: '已付款'
}
const EXPENSE_RISK_LEVEL_LABELS = {
high: '高风险',
@@ -104,7 +105,13 @@ export function resolveExpenseStatusGroup(status) {
if (['submitted', 'review'].includes(normalized)) {
return { key: 'in_progress', label: '审批中' }
}
if (['approved', 'paid'].includes(normalized)) {
if (normalized === 'pending_payment') {
return { key: 'pending_payment', label: '待付款' }
}
if (normalized === 'paid') {
return { key: 'completed', label: '已付款' }
}
if (['approved'].includes(normalized)) {
return { key: 'completed', label: '已完成' }
}
return { key: 'other', label: '其他状态' }

View File

@@ -82,7 +82,7 @@ export const GUIDED_QUERY_STATUS_OPTIONS = [
{ key: 'pending', label: '审批中', description: '正在流转审批的单据' },
{ key: 'returned', label: '已退回', description: '需要补充或修改的单据' },
{ key: 'archived', label: '已归档', description: '已完成归档的单据' },
{ key: 'completed', label: '已完成', description: '已审核完成或已入账的单据' }
{ key: 'completed', label: '已完成', description: '已审核完成或已付款的单据' }
]
const NO_ATTACHMENT_TEXT_PATTERN = /^(稍后|暂不|不用|没有|待上传|后面|后续|先不|以票据为准)/u

View File

@@ -159,10 +159,13 @@ export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
}
export function buildFallbackProgressSteps(requestModel = {}) {
if (isApplicationDocumentRequest(requestModel)) {
const node = String(requestModel?.node || requestModel?.workflowNode || requestModel?.approvalStage || '').trim()
const approvalKey = String(requestModel?.approvalKey || '').trim()
const completed = approvalKey === 'completed' || /审批完成|申请完成|已完成/.test(node)
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
const paid = /已付款/.test(node)
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
if (isApplicationDocumentRequest(requestModel)) {
const inLeaderApproval = approvalKey === 'in_progress' || /直属领导|领导审批|审批中/.test(node)
return [
@@ -199,7 +202,8 @@ export function buildFallbackProgressSteps(requestModel = {}) {
{ index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
{ index: 5, label: '财务审批', time: '待处理' },
{ index: 6, label: '归档入账', time: '待处理' }
{ index: 6, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
]
}

View File

@@ -0,0 +1,98 @@
import { computed, ref } from 'vue'
import { payExpenseClaim } from '../../services/reimbursements.js'
import {
canManageExpenseClaims,
isFinanceUser
} from '../../utils/accessControl.js'
export function useTravelRequestPaymentFlow({
request,
currentUser,
isApplicationDocument,
isCurrentApplicant,
toast,
emit
}) {
const payBusy = ref(false)
const payConfirmDialogOpen = ref(false)
const isPendingPaymentStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return (
!isApplicationDocument.value
&& Boolean(request.value.claimId)
&& (
request.value.approvalKey === 'pending_payment'
|| String(request.value.status || '').trim().toLowerCase() === 'pending_payment'
|| node === '待付款'
)
)
})
const canPayRequest = computed(() =>
isPendingPaymentStage.value
&& !isCurrentApplicant.value
&& (
isFinanceUser(currentUser.value)
|| canManageExpenseClaims(currentUser.value)
)
)
function handlePayRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法确认付款。')
return
}
if (!canPayRequest.value) {
toast('只有待付款状态的报销单可以确认付款。')
return
}
payConfirmDialogOpen.value = true
}
function closePayConfirmDialog() {
if (payBusy.value) {
return
}
payConfirmDialogOpen.value = false
}
async function confirmPayRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法确认付款。')
payConfirmDialogOpen.value = false
return
}
if (!canPayRequest.value) {
toast('只有待付款状态的报销单可以确认付款。')
payConfirmDialogOpen.value = false
return
}
payBusy.value = true
try {
await payExpenseClaim(request.value.claimId)
payConfirmDialogOpen.value = false
toast(`${request.value.id} 已确认付款。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '确认付款失败,请稍后重试。')
} finally {
payBusy.value = false
}
}
return {
canPayRequest,
closePayConfirmDialog,
confirmPayRequest,
handlePayRequest,
payBusy,
payConfirmDialogOpen
}
}

View File

@@ -33,6 +33,18 @@ test('document center archived rows are detected from archive flag or request st
}),
false
)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'pending_payment', approval_stage: '待付款' }
}),
false
)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'paid', approval_stage: '已付款' }
}),
true
)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'approved', approval_stage: '部门审批', approvalKey: 'completed' }
@@ -45,10 +57,11 @@ test('document center all scope excludes archived rows from merged lists', () =>
const rows = excludeArchivedDocumentRows([
{ claimId: 'a', archived: true },
{ claimId: 'b', rawRequest: { status: 'approved', approval_stage: '归档入账' } },
{ claimId: 'c', rawRequest: { status: 'submitted', approval_stage: '部门审批' } }
{ claimId: 'c', rawRequest: { status: 'submitted', approval_stage: '部门审批' } },
{ claimId: 'd', rawRequest: { status: 'pending_payment', approval_stage: '待付款' } }
])
assert.deepEqual(rows.map((row) => row.claimId), ['c'])
assert.deepEqual(rows.map((row) => row.claimId), ['c', 'd'])
})
test('application scope does not mark submitted approval application rows as new', () => {

View File

@@ -201,7 +201,7 @@ test('documents center switches filter conditions by category tab', () => {
)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_ARCHIVE\]: \{[\s\S]*dateLabel: '归档时间'[\s\S]*statusTitle: '归档状态'[\s\S]*statusTabs: \['全部', '已完成'\]/
/\[DOCUMENT_SCOPE_ARCHIVE\]: \{[\s\S]*dateLabel: '归档时间'[\s\S]*statusTitle: '归档状态'[\s\S]*statusTabs: \['全部', '已付款', '已完成'\]/
)
assert.match(documentsCenterView, /v-if="showDocumentTypeFilter" class="document-filter"/)
assert.match(documentsCenterView, /:placeholder="activeFilterConfig\.searchPlaceholder"/)

View File

@@ -19,6 +19,10 @@ test('isArchivedExpenseClaim recognizes finance archive stage', () => {
}),
true
)
assert.equal(
isArchivedExpenseClaim({ status: 'paid', approval_stage: '已付款' }),
true
)
})
test('isArchivedExpenseClaim ignores in-progress claims', () => {
@@ -26,6 +30,10 @@ test('isArchivedExpenseClaim ignores in-progress claims', () => {
isArchivedExpenseClaim({ status: 'submitted', approval_stage: '财务审批' }),
false
)
assert.equal(
isArchivedExpenseClaim({ status: 'pending_payment', approval_stage: '待付款' }),
false
)
})
test('archive data stays available through api client but archive center is removed from navigation', () => {

View File

@@ -399,7 +399,7 @@ test('ticket description helper does not show the destination city as detail tex
assert.equal(request.expenseItems.find((item) => item.id === 'ship')?.name, '轮船票')
})
test('completed finance approval marks finance and archive progress steps', () => {
test('finance approval moves reimbursement to pending payment step', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-finance-completed',
claim_no: 'EXP-202605-004',
@@ -414,8 +414,8 @@ test('completed finance approval marks finance and archive progress steps', () =
submitted_at: '2026-05-20T02:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T04:00:00.000Z',
status: 'approved',
approval_stage: '归档入账',
status: 'pending_payment',
approval_stage: '待付款',
risk_flags_json: [
{
source: 'manual_approval',
@@ -428,7 +428,7 @@ test('completed finance approval marks finance and archive progress steps', () =
source: 'finance_approval',
operator: '财务复核',
previous_approval_stage: '财务审批',
next_approval_stage: '归档入账',
next_approval_stage: '待付款',
created_at: '2026-05-20T04:00:00.000Z'
}
],
@@ -436,14 +436,62 @@ test('completed finance approval marks finance and archive progress steps', () =
})
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
const archiveStep = request.progressSteps.find((step) => step.label === '归档入账')
const paymentStep = request.progressSteps.find((step) => step.label === '待付款')
assert.equal(request.riskSummary, '无')
assert.equal(request.workflowNode, '归档入账')
assert.equal(request.workflowNode, '待付款')
assert.equal(request.approvalKey, 'pending_payment')
assert.equal(financeStep.time, '财务复核通过')
assert.match(financeStep.detail, /2026-05-20/)
assert.equal(archiveStep.time, '归档入账')
assert.equal(archiveStep.done, true)
assert.equal(paymentStep.current, true)
assert.equal(paymentStep.done, false)
})
test('paid reimbursement marks payment progress step as complete', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-finance-paid',
claim_no: 'EXP-202605-005',
employee_name: '张三',
department_name: '市场部',
expense_type: 'transport',
reason: '交通报销',
location: '上海',
amount: 88,
invoice_count: 1,
occurred_at: '2026-05-20T01:00:00.000Z',
submitted_at: '2026-05-20T02:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T05:00:00.000Z',
status: 'paid',
approval_stage: '已付款',
risk_flags_json: [
{
source: 'finance_approval',
operator: '财务复核',
previous_approval_stage: '财务审批',
next_approval_stage: '待付款',
created_at: '2026-05-20T04:00:00.000Z'
},
{
source: 'payment',
event_type: 'expense_claim_payment_completed',
operator: '财务付款',
previous_approval_stage: '待付款',
next_approval_stage: '已付款',
created_at: '2026-05-20T05:00:00.000Z'
}
],
items: []
})
const paymentStep = request.progressSteps.find((step) => step.label === '待付款')
const paidStep = request.progressSteps.find((step) => step.label === '已付款')
assert.equal(request.workflowNode, '已付款')
assert.equal(request.approvalStatus, '已付款')
assert.equal(paymentStep.time, '待付款')
assert.equal(paidStep.time, '已付款')
assert.equal(paidStep.done, true)
})
test('current direct manager step shows how long the claim has stayed there', () => {

View File

@@ -63,6 +63,22 @@ test('detects archived claim view models for delete permission gating', () => {
}),
false
)
assert.equal(
isArchivedRequestView({
status: 'pending_payment',
approval_stage: '待付款',
approvalKey: 'pending_payment'
}),
false
)
assert.equal(
isArchivedRequestView({
status: 'paid',
approval_stage: '已付款',
approvalKey: 'completed'
}),
true
)
assert.equal(
isArchivedRequestView({
status: 'approved',
@@ -84,3 +100,18 @@ test('detects archived claim view models for delete permission gating', () => {
false
)
})
test('normalizes pending payment backend claims', () => {
const request = normalizeRequestForUi({
id: 'EXP-202605-004',
claim_id: 'claim-4',
status: 'pending_payment',
approval_stage: '待付款',
expense_type: 'transport',
amount: 88
})
assert.equal(request.approvalKey, 'pending_payment')
assert.equal(request.approvalStatus, '待付款')
assert.equal(request.node, '待付款')
})