feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
60
document/development/budget-center/MASTER_TODO.md
Normal file
60
document/development/budget-center/MASTER_TODO.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 预算中心 MASTER TODO
|
||||
|
||||
## 总目标
|
||||
|
||||
把预算从首页静态展示升级为真实费控底座,让费用申请和报销都必须经过预算口径校验。
|
||||
|
||||
## 状态图
|
||||
|
||||
```text
|
||||
预算额度
|
||||
-> 申请提交: 预占
|
||||
-> 申请退回/撤回/驳回: 释放
|
||||
-> 申请通过: 保持预占
|
||||
-> 报销提交: 校验申请与预算
|
||||
-> 报销审批通过: 核销
|
||||
-> 报销退回/撤回: 释放或回滚
|
||||
```
|
||||
|
||||
## 总 TODO
|
||||
|
||||
- [ ] 新增预算中心开发文档并纳入每日核对。
|
||||
- [ ] 定义预算维度:部门、成本中心、项目、费用科目、期间。
|
||||
- [ ] 定义预算模型:预算额度、预算交易、预算占用。
|
||||
- [ ] 定义预算状态:正常、预警、超预算、冻结。
|
||||
- [ ] 定义预算交易类型:初始化、调整、预占、释放、核销、回滚。
|
||||
- [ ] 新增预算列表接口。
|
||||
- [ ] 新增预算详情接口。
|
||||
- [ ] 新增预算台账接口。
|
||||
- [ ] 新增预算占用接口或内部服务。
|
||||
- [ ] 新增预算释放接口或内部服务。
|
||||
- [ ] 新增预算核销接口或内部服务。
|
||||
- [ ] 费用申请提交时写入预算预占。
|
||||
- [ ] 费用申请驳回、撤回、取消时释放预算。
|
||||
- [ ] 费用申请转报销时保留预算来源。
|
||||
- [ ] 报销提交时校验预算归属和可用余额。
|
||||
- [ ] 报销审批通过时核销预算。
|
||||
- [ ] 报销退回时回滚预算状态。
|
||||
- [ ] 报销详情展示预算占用和核销信息。
|
||||
- [ ] 申请详情展示预算占用和剩余额度。
|
||||
- [ ] 预算中心页面展示执行率、已占用、已核销、可用余额。
|
||||
- [ ] 预算台账展示每笔来源单据和交易类型。
|
||||
- [ ] 首页预算执行率改为后端真实数据。
|
||||
- [ ] 本体识别支持预算维度字段。
|
||||
- [ ] AI对话能解释预算不足、预算归属缺失、超预算原因。
|
||||
- [ ] 添加后端单元测试。
|
||||
- [ ] 添加前端预算视图测试。
|
||||
- [ ] 添加申请到报销的端到端预算验收场景。
|
||||
|
||||
## 验收场景
|
||||
|
||||
- [ ] 有预算时,费用申请提交成功并预占预算。
|
||||
- [ ] 预算不足时,申请提交被阻断或进入超预算复核。
|
||||
- [ ] 申请驳回后,预算预占被释放。
|
||||
- [ ] 申请审批通过后,预算仍保持预占。
|
||||
- [ ] 申请转报销后,报销单继承预算来源。
|
||||
- [ ] 报销审批通过后,预算从预占转为核销。
|
||||
- [ ] 报销退回后,预算核销回滚。
|
||||
- [ ] 预算中心能看到完整交易台账。
|
||||
- [ ] 首页预算执行率与预算中心汇总一致。
|
||||
|
||||
58
document/development/budget-center/README.md
Normal file
58
document/development/budget-center/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 预算中心开发总览
|
||||
|
||||
## 目标
|
||||
|
||||
预算中心先作为费控平台的前置底座建设,优先打通:
|
||||
|
||||
```text
|
||||
预算编制 -> 预算可用额度 -> 费用申请预占 -> 报销核销 -> 释放/调整 -> 预算看板
|
||||
```
|
||||
|
||||
第一版不追求完整预算编制系统,而是先让申请、报销、审批、付款、归档都有真实预算口径。
|
||||
|
||||
## 当前项目基础
|
||||
|
||||
- 员工和组织已有 `cost_center` 成本中心字段,可作为预算归属维度。
|
||||
- 报销单已有部门、项目、费用类型、金额、状态等字段,可接入预算核销。
|
||||
- 首页已有静态预算执行率展示,但还不是后端真实预算数据。
|
||||
- 费用申请已有前端意图识别和申请草稿痕迹,但预算占用还没有真实台账。
|
||||
|
||||
## 第一版预算中心范围
|
||||
|
||||
必须做:
|
||||
|
||||
- 预算主体:部门、成本中心、项目、费用科目。
|
||||
- 预算期间:月度、季度、年度。
|
||||
- 预算额度:总额、已占用、已核销、已释放、可用余额。
|
||||
- 预算台账:每一次占用、核销、释放、调整都落账。
|
||||
- 申请联动:费用申请提交时预占预算,驳回/撤回时释放。
|
||||
- 报销联动:报销提交或审批通过时核销预算。
|
||||
- 风险拦截:预算不足、超预算、缺预算归属时阻断或进入复核。
|
||||
- 预算中心页面:列表、详情、台账、执行率、异常预算。
|
||||
|
||||
暂缓:
|
||||
|
||||
- 完整预算编制审批流。
|
||||
- 多版本预算测算。
|
||||
- 外部 ERP 预算接口。
|
||||
- 真正多币种预算。
|
||||
- 复杂滚动预算和预测模型。
|
||||
|
||||
## 关键原则
|
||||
|
||||
- 预算中心是独立业务域,不塞进报销 Service。
|
||||
- 所有预算变化必须通过预算交易台账记录。
|
||||
- 不直接改写已用金额,必须由交易汇总得到。
|
||||
- 申请、报销、付款只是预算事件来源。
|
||||
- 预算不足的判断必须来自后端,不依赖前端显示。
|
||||
|
||||
## 7天开发路径
|
||||
|
||||
- Day 1:预算模型、状态机、接口契约。
|
||||
- Day 2:预算中心页面、列表、详情、台账视图。
|
||||
- Day 3:预算占用/释放/核销服务。
|
||||
- Day 4:费用申请与报销联动预算。
|
||||
- Day 5:审批、付款、归档中的预算状态传递。
|
||||
- Day 6:预算看板、本体识别、AI提示。
|
||||
- Day 7:端到端验收、演示数据、测试补齐。
|
||||
|
||||
1179
document/development/budget-center/budget-center-ui-prototype.html
Normal file
1179
document/development/budget-center/budget-center-ui-prototype.html
Normal file
File diff suppressed because it is too large
Load Diff
105
document/development/budget-center/day_1_budget_foundation.md
Normal file
105
document/development/budget-center/day_1_budget_foundation.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Day 1 - 预算模型与接口契约
|
||||
|
||||
## 目标
|
||||
|
||||
先把预算中心的数据边界和接口边界定稳,避免后续把预算逻辑散落在申请、报销、审批和付款模块里。
|
||||
|
||||
## 开发任务
|
||||
|
||||
- [ ] 新增预算模型设计。
|
||||
- [ ] 新增预算交易台账设计。
|
||||
- [ ] 新增预算服务边界设计。
|
||||
- [ ] 新增预算接口契约。
|
||||
- [ ] 新增预算状态与交易类型常量。
|
||||
- [ ] 明确申请、报销、付款对预算服务的调用点。
|
||||
|
||||
## 建议模型
|
||||
|
||||
预算额度:
|
||||
|
||||
```text
|
||||
BudgetAllocation
|
||||
- id
|
||||
- budget_no
|
||||
- fiscal_year
|
||||
- period_type
|
||||
- period_key
|
||||
- department_id
|
||||
- department_name
|
||||
- cost_center
|
||||
- project_code
|
||||
- subject_code
|
||||
- subject_name
|
||||
- original_amount
|
||||
- adjusted_amount
|
||||
- status
|
||||
- warning_threshold
|
||||
- created_at
|
||||
- updated_at
|
||||
```
|
||||
|
||||
预算交易:
|
||||
|
||||
```text
|
||||
BudgetTransaction
|
||||
- id
|
||||
- transaction_no
|
||||
- allocation_id
|
||||
- source_type
|
||||
- source_id
|
||||
- source_no
|
||||
- transaction_type
|
||||
- amount
|
||||
- before_available_amount
|
||||
- after_available_amount
|
||||
- operator
|
||||
- reason
|
||||
- created_at
|
||||
```
|
||||
|
||||
交易类型:
|
||||
|
||||
```text
|
||||
init 初始化
|
||||
adjust 调整
|
||||
reserve 预占
|
||||
release 释放
|
||||
consume 核销
|
||||
rollback 回滚
|
||||
freeze 冻结
|
||||
unfreeze 解冻
|
||||
```
|
||||
|
||||
预算汇总字段由交易汇总得到:
|
||||
|
||||
```text
|
||||
total_amount = original_amount + adjusted_amount
|
||||
reserved_amount = reserve - release
|
||||
consumed_amount = consume - rollback
|
||||
available_amount = total_amount - reserved_amount - consumed_amount
|
||||
```
|
||||
|
||||
## 接口契约
|
||||
|
||||
```text
|
||||
GET /api/v1/budgets/allocations
|
||||
POST /api/v1/budgets/allocations
|
||||
GET /api/v1/budgets/allocations/{id}
|
||||
GET /api/v1/budgets/allocations/{id}/transactions
|
||||
POST /api/v1/budgets/allocations/{id}/adjust
|
||||
POST /api/v1/budgets/check
|
||||
POST /api/v1/budgets/reserve
|
||||
POST /api/v1/budgets/release
|
||||
POST /api/v1/budgets/consume
|
||||
POST /api/v1/budgets/rollback
|
||||
GET /api/v1/budgets/summary
|
||||
```
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 能创建一条部门月度预算。
|
||||
- [ ] 能查询预算列表和详情。
|
||||
- [ ] 能查询预算台账。
|
||||
- [ ] 能根据部门、成本中心、项目、费用科目定位预算。
|
||||
- [ ] 预算不足时 `check` 接口能返回明确原因。
|
||||
|
||||
82
document/development/budget-center/day_2_budget_center_ui.md
Normal file
82
document/development/budget-center/day_2_budget_center_ui.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Day 2 - 预算中心页面
|
||||
|
||||
## 目标
|
||||
|
||||
新增预算中心作为独立主菜单,让预算不再只是首页指标,而是可操作、可追踪、可解释的费控入口。
|
||||
|
||||
## 页面入口
|
||||
|
||||
主菜单建议:
|
||||
|
||||
```text
|
||||
费用申请
|
||||
报销中心
|
||||
审批中心
|
||||
预算中心
|
||||
付款中心
|
||||
归档中心
|
||||
经营分析
|
||||
```
|
||||
|
||||
## 页面结构
|
||||
|
||||
顶部指标:
|
||||
|
||||
- 预算总额
|
||||
- 已预占
|
||||
- 已核销
|
||||
- 可用余额
|
||||
- 超预算事项
|
||||
- 预警预算数
|
||||
|
||||
列表字段:
|
||||
|
||||
- 预算编号
|
||||
- 预算期间
|
||||
- 部门
|
||||
- 成本中心
|
||||
- 项目
|
||||
- 费用科目
|
||||
- 预算总额
|
||||
- 已预占
|
||||
- 已核销
|
||||
- 可用余额
|
||||
- 执行率
|
||||
- 状态
|
||||
|
||||
筛选条件:
|
||||
|
||||
- 年度
|
||||
- 月份/季度
|
||||
- 部门
|
||||
- 成本中心
|
||||
- 项目
|
||||
- 费用科目
|
||||
- 状态
|
||||
|
||||
详情页:
|
||||
|
||||
- 基本信息
|
||||
- 执行概览
|
||||
- 来源单据
|
||||
- 交易台账
|
||||
- 风险提示
|
||||
- 调整记录
|
||||
|
||||
## 操作
|
||||
|
||||
- 新增预算
|
||||
- 调整预算
|
||||
- 冻结预算
|
||||
- 查看台账
|
||||
- 查看关联申请
|
||||
- 查看关联报销
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 预算中心能从主菜单进入。
|
||||
- [ ] 列表能展示后端预算数据。
|
||||
- [ ] 点击预算能进入详情。
|
||||
- [ ] 详情能展示交易台账。
|
||||
- [ ] 首页预算执行率能跳转到预算中心。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Day 3 - 预算占用、释放、核销服务
|
||||
|
||||
## 目标
|
||||
|
||||
把预算变化统一收敛到预算服务,申请、报销、付款都只能通过预算服务改变预算状态。
|
||||
|
||||
## 服务能力
|
||||
|
||||
预算检查:
|
||||
|
||||
- 校验预算归属是否存在。
|
||||
- 校验预算是否被冻结。
|
||||
- 校验可用余额是否充足。
|
||||
- 返回超预算金额和处理建议。
|
||||
|
||||
预算预占:
|
||||
|
||||
- 用于费用申请提交。
|
||||
- 写入 `reserve` 交易。
|
||||
- 记录来源单据。
|
||||
|
||||
预算释放:
|
||||
|
||||
- 用于申请撤回、退回、驳回、取消。
|
||||
- 写入 `release` 交易。
|
||||
- 必须找到原始预占来源。
|
||||
|
||||
预算核销:
|
||||
|
||||
- 用于报销审批通过。
|
||||
- 写入 `consume` 交易。
|
||||
- 如果来源申请已有预占,应先释放预占或转换为核销,不能重复占用。
|
||||
|
||||
预算回滚:
|
||||
|
||||
- 用于报销退回或撤销审批。
|
||||
- 写入 `rollback` 交易。
|
||||
|
||||
## 关键防错
|
||||
|
||||
- 同一来源单据不能重复预占。
|
||||
- 同一报销单不能重复核销。
|
||||
- 释放金额不能超过原预占金额。
|
||||
- 核销金额不能超过可用余额加当前来源预占余额。
|
||||
- 所有预算交易必须有来源单据和操作人。
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 预算预占后可用余额减少。
|
||||
- [ ] 预算释放后可用余额恢复。
|
||||
- [ ] 预算核销后已核销金额增加。
|
||||
- [ ] 重复预占会被阻断。
|
||||
- [ ] 重复核销会被阻断。
|
||||
- [ ] 台账能解释每一次余额变化。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Day 4 - 费用申请与报销联动预算
|
||||
|
||||
## 目标
|
||||
|
||||
让预算成为申请和报销之间的硬约束,先申请、再占用、再报销、再核销。
|
||||
|
||||
## 费用申请联动
|
||||
|
||||
提交申请时:
|
||||
|
||||
- 根据申请人部门、成本中心、项目、费用科目定位预算。
|
||||
- 预算充足则预占。
|
||||
- 预算不足则阻断或进入超预算复核。
|
||||
- 申请详情展示预算占用结果。
|
||||
|
||||
申请退回/驳回/撤回时:
|
||||
|
||||
- 释放对应预算预占。
|
||||
- 记录释放原因。
|
||||
|
||||
申请审批通过时:
|
||||
|
||||
- 保持预算预占。
|
||||
- 允许转报销。
|
||||
|
||||
申请转报销时:
|
||||
|
||||
- 报销单继承申请预算来源。
|
||||
- 报销金额默认不超过申请金额。
|
||||
- 超过申请金额时进入风险提示或复核。
|
||||
|
||||
## 报销联动
|
||||
|
||||
报销提交时:
|
||||
|
||||
- 校验是否需要事前申请。
|
||||
- 校验是否有关联已通过申请。
|
||||
- 校验预算来源是否存在。
|
||||
|
||||
报销审批通过时:
|
||||
|
||||
- 将预算预占转为核销。
|
||||
- 记录核销台账。
|
||||
|
||||
报销退回时:
|
||||
|
||||
- 回滚核销。
|
||||
- 视状态保留或释放预占。
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 有预算的申请提交后形成预占。
|
||||
- [ ] 预算不足的申请不能直接提交。
|
||||
- [ ] 驳回申请释放预算。
|
||||
- [ ] 已通过申请能转报销。
|
||||
- [ ] 报销审批通过后预算转核销。
|
||||
- [ ] 未关联预算的报销不能绕过预算校验。
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Day 5 - 审批、付款、归档中的预算口径
|
||||
|
||||
## 目标
|
||||
|
||||
预算信息不能只停留在申请和报销页面,还要贯穿审批、付款和归档。
|
||||
|
||||
## 审批中心
|
||||
|
||||
审批列表增加预算提示:
|
||||
|
||||
- 是否预算内
|
||||
- 是否超预算
|
||||
- 已预占金额
|
||||
- 可用余额
|
||||
- 预算归属
|
||||
|
||||
审批详情增加预算区块:
|
||||
|
||||
- 当前单据金额
|
||||
- 对应预算额度
|
||||
- 已预占
|
||||
- 已核销
|
||||
- 剩余额度
|
||||
- 超预算原因
|
||||
|
||||
## 付款中心预留
|
||||
|
||||
第一版付款中心可以暂缓实现完整页面,但预算中心需要为付款预留:
|
||||
|
||||
- 付款来源单据
|
||||
- 付款金额
|
||||
- 付款状态
|
||||
- 是否已核销预算
|
||||
- 是否存在预算异常
|
||||
|
||||
## 归档中心
|
||||
|
||||
归档包需要包含预算信息:
|
||||
|
||||
- 预算归属
|
||||
- 预算交易流水
|
||||
- 申请预占记录
|
||||
- 报销核销记录
|
||||
- 超预算审批意见
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 审批人能看到预算是否充足。
|
||||
- [ ] 超预算审批能看到超额金额。
|
||||
- [ ] 归档详情能看到预算台账摘要。
|
||||
- [ ] 预算异常不会在付款/归档阶段丢失。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Day 6 - 预算看板、本体识别与AI解释
|
||||
|
||||
## 目标
|
||||
|
||||
让预算中心不只是数据表,还能被 AI 对话、本体识别和经营分析调用。
|
||||
|
||||
## 看板指标
|
||||
|
||||
- 本月预算总额
|
||||
- 本月已预占
|
||||
- 本月已核销
|
||||
- 本月可用余额
|
||||
- 部门预算排行
|
||||
- 费用科目执行率
|
||||
- 超预算事项数量
|
||||
- 预算预警事项数量
|
||||
|
||||
## 本体字段
|
||||
|
||||
新增或强化字段:
|
||||
|
||||
```text
|
||||
cost_center
|
||||
project_code
|
||||
budget_subject
|
||||
budget_period
|
||||
budget_amount
|
||||
available_amount
|
||||
reserved_amount
|
||||
consumed_amount
|
||||
over_budget
|
||||
budget_warning
|
||||
```
|
||||
|
||||
## AI解释能力
|
||||
|
||||
需要支持的问题:
|
||||
|
||||
- 这个申请为什么预算不足?
|
||||
- 这个报销占用了哪个预算?
|
||||
- 本月哪个部门预算快超了?
|
||||
- 某个项目还剩多少预算?
|
||||
- 超预算申请需要谁审批?
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 本体能识别预算相关问题。
|
||||
- [ ] AI能解释预算不足原因。
|
||||
- [ ] 首页预算看板来自后端真实汇总。
|
||||
- [ ] 预算中心和AI回答的金额一致。
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Day 7 - 联调、测试与演示验收
|
||||
|
||||
## 目标
|
||||
|
||||
冻结新增需求,只修预算闭环缺口,确保演示链路稳定。
|
||||
|
||||
## 端到端验收链路
|
||||
|
||||
链路一:预算内申请到报销
|
||||
|
||||
```text
|
||||
创建预算 -> 发起费用申请 -> 预占预算 -> 审批通过
|
||||
-> 转报销 -> 报销审批通过 -> 核销预算 -> 归档
|
||||
```
|
||||
|
||||
链路二:预算不足
|
||||
|
||||
```text
|
||||
创建低额度预算 -> 发起高金额申请 -> 预算不足
|
||||
-> 阻断提交或进入超预算复核 -> 审批意见留痕
|
||||
```
|
||||
|
||||
链路三:申请驳回释放预算
|
||||
|
||||
```text
|
||||
申请提交 -> 预算预占 -> 审批驳回 -> 预算释放 -> 台账可追溯
|
||||
```
|
||||
|
||||
链路四:重复操作防护
|
||||
|
||||
```text
|
||||
重复提交 / 重复审批 / 重复核销 -> 后端阻断 -> 台账不重复
|
||||
```
|
||||
|
||||
## 测试要求
|
||||
|
||||
- [ ] 后端预算服务单元测试。
|
||||
- [ ] 申请预算预占测试。
|
||||
- [ ] 报销预算核销测试。
|
||||
- [ ] 预算不足阻断测试。
|
||||
- [ ] 前端预算中心列表测试。
|
||||
- [ ] 前端预算详情台账测试。
|
||||
- [ ] 首页预算汇总测试。
|
||||
|
||||
## 演示数据
|
||||
|
||||
至少准备:
|
||||
|
||||
- 一个预算充足的部门预算。
|
||||
- 一个预算不足的部门预算。
|
||||
- 一个项目预算。
|
||||
- 一个会议培训大额预算。
|
||||
- 一个已经预占的申请。
|
||||
- 一个已经核销的报销。
|
||||
- 一个超预算待审批事项。
|
||||
|
||||
## 最终验收
|
||||
|
||||
- [ ] 预算中心能解释每一分钱从哪里来、到哪里去。
|
||||
- [ ] 费用申请不能绕过预算。
|
||||
- [ ] 报销审批不能绕过预算。
|
||||
- [ ] 审批、归档、看板显示同一套预算数据。
|
||||
- [ ] 演示链路可连续跑通。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Header, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.api.deps import (
|
||||
require_rule_editor_user,
|
||||
require_rule_reviewer_user,
|
||||
)
|
||||
from app.db.session import get_session_factory
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetCreate,
|
||||
AgentAssetListItem,
|
||||
@@ -26,6 +27,7 @@ from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleEnabledUpdate,
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
AgentAssetRiskRuleLatestTestSummary,
|
||||
AgentAssetRiskRuleLevelUpdate,
|
||||
AgentAssetRiskRuleReportRequest,
|
||||
AgentAssetRiskRuleReturnRequest,
|
||||
AgentAssetRiskRuleSampleTestRequest,
|
||||
@@ -43,7 +45,7 @@ from app.schemas.agent_asset import (
|
||||
)
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
|
||||
|
||||
router = APIRouter(prefix="/agent-assets")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -71,6 +73,25 @@ def _handle_asset_error(exc: Exception) -> None:
|
||||
raise exc
|
||||
|
||||
|
||||
def _complete_risk_rule_generation_task(
|
||||
asset_id: str,
|
||||
payload: dict,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> None:
|
||||
db = get_session_factory()()
|
||||
try:
|
||||
body = AgentAssetRiskRuleGenerateRequest.model_validate(payload)
|
||||
RiskRuleGenerationJobService(db).complete_rule_asset_generation(
|
||||
asset_id,
|
||||
body,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[AgentAssetListItem],
|
||||
@@ -284,6 +305,7 @@ def save_agent_asset_rule_json(
|
||||
)
|
||||
def generate_agent_asset_risk_rule(
|
||||
payload: AgentAssetRiskRuleGenerateRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
@@ -291,11 +313,18 @@ def generate_agent_asset_risk_rule(
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
actor = (x_actor or current_user.name or "system").strip() or "system"
|
||||
asset_id = RiskRuleGenerationService(db).generate_rule_asset(
|
||||
asset_id = RiskRuleGenerationJobService(db).enqueue_rule_asset_generation(
|
||||
payload,
|
||||
actor=actor,
|
||||
request_id=x_request_id,
|
||||
)
|
||||
background_tasks.add_task(
|
||||
_complete_risk_rule_generation_task,
|
||||
asset_id,
|
||||
payload.model_dump(mode="json"),
|
||||
actor,
|
||||
x_request_id,
|
||||
)
|
||||
asset = AgentAssetService(db).get_asset(asset_id)
|
||||
if asset is None:
|
||||
raise LookupError("Asset not found")
|
||||
@@ -736,6 +765,27 @@ def set_agent_asset_risk_rule_enabled(
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/risk-rule-level",
|
||||
response_model=AgentAssetRead,
|
||||
summary="风险规则风险等级已由评分模型接管",
|
||||
description="风险规则等级和分数由自然语言规则评分模型生成,不再允许人工调整。",
|
||||
)
|
||||
def set_agent_asset_risk_rule_level(
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleLevelUpdate,
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
del asset_id, payload, current_user, db, x_actor, x_request_id
|
||||
raise ValueError("风险等级和分数由评分模型自动计算,不能手动修改。")
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/return",
|
||||
response_model=AgentAssetRiskRuleLatestTestSummary,
|
||||
|
||||
@@ -571,7 +571,7 @@ def approve_expense_claim(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除报销单",
|
||||
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限。",
|
||||
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
|
||||
@@ -11,10 +11,12 @@ class AgentAssetType(StrEnum):
|
||||
|
||||
|
||||
class AgentAssetStatus(StrEnum):
|
||||
GENERATING = "generating"
|
||||
DRAFT = "draft"
|
||||
REVIEW = "review"
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class AgentReviewStatus(StrEnum):
|
||||
|
||||
@@ -113,7 +113,8 @@ class AgentAssetRuleJsonRead(BaseModel):
|
||||
class AgentAssetRiskRuleGenerateRequest(BaseModel):
|
||||
business_domain: AgentAssetDomain = AgentAssetDomain.EXPENSE
|
||||
expense_category: str | None = Field(default=None, max_length=40)
|
||||
risk_level: str = Field(default="medium", pattern="^(low|medium|high)$")
|
||||
rule_title: str | None = Field(default=None, max_length=80)
|
||||
risk_level: str | None = Field(default=None, pattern="^(low|medium|high|critical)$")
|
||||
natural_language: str = Field(min_length=8, max_length=2000)
|
||||
requires_attachment: bool = False
|
||||
|
||||
@@ -195,6 +196,10 @@ class AgentAssetRiskRuleEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AgentAssetRiskRuleLevelUpdate(BaseModel):
|
||||
risk_level: str = Field(pattern="^(low|medium|high|critical)$")
|
||||
|
||||
|
||||
class AgentAssetRiskRuleTestRunRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import AgentAssetRuleJsonRead, AgentAssetRuleJsonWrite
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY, RULE_LIBRARY_NAMES
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
|
||||
|
||||
class AgentAssetJsonRuleMixin:
|
||||
@@ -35,6 +36,7 @@ class AgentAssetJsonRuleMixin:
|
||||
library=rule_library,
|
||||
file_name=file_name,
|
||||
)
|
||||
payload = normalize_risk_rule_manifest(payload)
|
||||
return AgentAssetRuleJsonRead(
|
||||
file_name=file_name,
|
||||
rule_code=str(payload.get("rule_code") or asset.code or ""),
|
||||
|
||||
250
server/src/app/services/agent_asset_risk_rule_level.py
Normal file
250
server/src/app/services/agent_asset_risk_rule_level.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramField,
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
RiskRuleFlowDiagramSpec,
|
||||
build_risk_rule_flow_diagram_details,
|
||||
)
|
||||
|
||||
|
||||
RISK_RULE_LEVEL_LABELS = {
|
||||
"low": "低风险",
|
||||
"medium": "中风险",
|
||||
"high": "高风险",
|
||||
"critical": "极高风险",
|
||||
}
|
||||
|
||||
|
||||
class AgentAssetRiskRuleLevelMixin:
|
||||
_RUNTIME_JSON_BLOCK_PATTERN = re.compile(r"```json\s*\n.*?\n```", re.DOTALL)
|
||||
_RISK_LEVEL_TEXT_PATTERN = re.compile(r"(低|中|高)风险")
|
||||
|
||||
def set_risk_rule_level(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
risk_level: str,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAsset:
|
||||
asset = self._resolve_asset(asset_id)
|
||||
self._require_json_risk_asset(asset)
|
||||
normalized_level = self._normalize_risk_rule_level(risk_level)
|
||||
before = {
|
||||
**self._asset_snapshot(asset),
|
||||
"risk_level": self._resolve_asset_risk_level(asset),
|
||||
}
|
||||
|
||||
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
|
||||
manifest = self.rule_library_manager.read_rule_library_json(
|
||||
library=rule_library,
|
||||
file_name=file_name,
|
||||
)
|
||||
updated_manifest = self._apply_risk_level_to_manifest(asset, manifest, normalized_level)
|
||||
self.rule_library_manager.write_rule_library_json(
|
||||
library=rule_library,
|
||||
file_name=file_name,
|
||||
payload=updated_manifest,
|
||||
)
|
||||
|
||||
config_json = dict(asset.config_json or {})
|
||||
config_json["severity"] = normalized_level
|
||||
config_json["risk_level"] = normalized_level
|
||||
config_json["risk_level_label"] = RISK_RULE_LEVEL_LABELS[normalized_level]
|
||||
asset.config_json = config_json
|
||||
self._sync_current_version_runtime_json(asset, updated_manifest)
|
||||
updated = self.repository.save_asset(asset)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="set_risk_rule_level",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=before,
|
||||
after_json={
|
||||
**self._asset_snapshot(updated),
|
||||
"risk_level": normalized_level,
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
return updated
|
||||
|
||||
def _apply_risk_level_to_manifest(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
manifest: dict[str, Any],
|
||||
risk_level: str,
|
||||
) -> dict[str, Any]:
|
||||
label = RISK_RULE_LEVEL_LABELS[risk_level]
|
||||
payload = dict(manifest or {})
|
||||
payload["severity"] = risk_level
|
||||
|
||||
outcomes = dict(payload.get("outcomes") or {})
|
||||
fail_outcome = dict(outcomes.get("fail") or {})
|
||||
fail_outcome["severity"] = risk_level
|
||||
outcomes["fail"] = fail_outcome
|
||||
outcomes.setdefault("pass", {"severity": "none", "action": "continue"})
|
||||
payload["outcomes"] = outcomes
|
||||
|
||||
metadata = dict(payload.get("metadata") or {})
|
||||
metadata["risk_level"] = risk_level
|
||||
metadata["risk_level_label"] = label
|
||||
metadata["risk_level_updated_at"] = datetime.now(UTC).isoformat()
|
||||
if metadata.get("business_explanation"):
|
||||
metadata["business_explanation"] = self._replace_risk_level_text(
|
||||
metadata["business_explanation"],
|
||||
label,
|
||||
)
|
||||
flow = dict(metadata.get("flow") or {})
|
||||
flow["fail"] = self._replace_risk_level_text(
|
||||
str(flow.get("fail") or f"命中{label},进入人工复核"),
|
||||
label,
|
||||
)
|
||||
metadata["flow"] = flow
|
||||
payload["metadata"] = metadata
|
||||
|
||||
if payload.get("description"):
|
||||
payload["description"] = self._replace_risk_level_text(payload["description"], label)
|
||||
|
||||
params = dict(payload.get("params") or {})
|
||||
params_flow = params.get("flow")
|
||||
if isinstance(params_flow, dict):
|
||||
next_params_flow = dict(params_flow)
|
||||
next_params_flow["fail"] = self._replace_risk_level_text(
|
||||
str(next_params_flow.get("fail") or f"命中{label},进入人工复核"),
|
||||
label,
|
||||
)
|
||||
params["flow"] = next_params_flow
|
||||
payload["params"] = params
|
||||
|
||||
payload["flow_diagram_svg"] = self._build_risk_level_flow_diagram_svg(
|
||||
asset,
|
||||
payload,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
return payload
|
||||
|
||||
def _build_risk_level_flow_diagram_svg(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
risk_level: str,
|
||||
) -> str:
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
fields = self._resolve_risk_rule_diagram_fields(manifest)
|
||||
details = build_risk_rule_flow_diagram_details(manifest, fields)
|
||||
risk_label = RISK_RULE_LEVEL_LABELS[risk_level]
|
||||
return RiskRuleFlowDiagramRenderer().render(
|
||||
RiskRuleFlowDiagramSpec(
|
||||
title=self._clean_risk_level_text(manifest.get("name")) or asset.name,
|
||||
domain_label=self._resolve_risk_rule_domain_label(asset, manifest),
|
||||
severity=risk_level,
|
||||
severity_label=risk_label,
|
||||
fields=tuple(fields),
|
||||
start=self._clean_risk_level_text(flow.get("start")) or "业务单据提交",
|
||||
evidence=self._clean_risk_level_text(flow.get("evidence")) or "读取规则字段",
|
||||
decision=(
|
||||
self._clean_risk_level_text(flow.get("decision"))
|
||||
or self._clean_risk_level_text(params.get("condition_summary"))
|
||||
or "判断是否命中风险"
|
||||
),
|
||||
basis=(
|
||||
self._clean_risk_level_text(metadata.get("condition_summary"))
|
||||
or self._clean_risk_level_text(params.get("condition_summary"))
|
||||
or "根据规则字段判断是否命中风险"
|
||||
),
|
||||
pass_text=self._clean_risk_level_text(flow.get("pass")) or "未命中风险,继续流转",
|
||||
fail_text=self._clean_risk_level_text(flow.get("fail"))
|
||||
or f"命中{risk_label},进入人工复核",
|
||||
fact_lines=details["fact_lines"],
|
||||
condition_lines=details["condition_lines"],
|
||||
hit_logic=str(details["hit_logic"] or ""),
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_risk_rule_diagram_fields(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
) -> list[RiskRuleFlowDiagramField]:
|
||||
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
|
||||
rows = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
||||
fields: list[RiskRuleFlowDiagramField] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
key = self._clean_risk_level_text(row.get("key"))
|
||||
label = self._clean_risk_level_text(row.get("label")) or key
|
||||
if key or label:
|
||||
fields.append(RiskRuleFlowDiagramField(key=key, label=label))
|
||||
return fields
|
||||
|
||||
def _resolve_risk_rule_domain_label(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
manifest: dict[str, Any],
|
||||
) -> str:
|
||||
config_json = dict(asset.config_json or {})
|
||||
return (
|
||||
self._clean_risk_level_text(manifest.get("risk_category"))
|
||||
or self._clean_risk_level_text(config_json.get("risk_category"))
|
||||
or self._clean_risk_level_text(config_json.get("expense_category_label"))
|
||||
or "报销"
|
||||
)
|
||||
|
||||
def _sync_current_version_runtime_json(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
manifest: dict[str, Any],
|
||||
) -> None:
|
||||
version_name = self._resolve_working_version(asset)
|
||||
if not version_name:
|
||||
return
|
||||
version = self.repository.get_version(asset.id, version_name)
|
||||
if version is None or version.content_type != "markdown":
|
||||
return
|
||||
|
||||
runtime_block = f"```json\n{json.dumps(manifest, ensure_ascii=False, indent=2)}\n```"
|
||||
content = str(version.content or "")
|
||||
if self._RUNTIME_JSON_BLOCK_PATTERN.search(content):
|
||||
version.content = self._RUNTIME_JSON_BLOCK_PATTERN.sub(
|
||||
lambda _: runtime_block,
|
||||
content,
|
||||
count=1,
|
||||
)
|
||||
else:
|
||||
version.content = f"{content.rstrip()}\n\n## 运行时 JSON\n\n{runtime_block}"
|
||||
self.db.add(version)
|
||||
|
||||
def _resolve_asset_risk_level(self, asset: AgentAsset) -> str:
|
||||
config_json = dict(asset.config_json or {})
|
||||
return self._normalize_risk_rule_level(config_json.get("severity") or "medium")
|
||||
|
||||
@classmethod
|
||||
def _replace_risk_level_text(cls, value: Any, risk_label: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return text
|
||||
if cls._RISK_LEVEL_TEXT_PATTERN.search(text):
|
||||
return cls._RISK_LEVEL_TEXT_PATTERN.sub(risk_label, text)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _normalize_risk_rule_level(value: Any) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized not in RISK_RULE_LEVEL_LABELS:
|
||||
raise ValueError("风险等级仅支持 low、medium、high、critical。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _clean_risk_level_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.agent_asset import (
|
||||
@@ -9,6 +8,19 @@ from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleSimulationRead,
|
||||
AgentAssetRiskRuleSimulationRequest,
|
||||
)
|
||||
from app.services.agent_asset_risk_rule_simulation_fields import (
|
||||
derive_attachment_field_value,
|
||||
extract_amount,
|
||||
extract_city_mentions,
|
||||
extract_invoice_no,
|
||||
extract_iso_date,
|
||||
field_matches_simulation_key,
|
||||
has_meaningful_value,
|
||||
infer_goods_name,
|
||||
infer_item_type,
|
||||
looks_like_city_field,
|
||||
refine_simulation_values_with_model,
|
||||
)
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
@@ -106,6 +118,7 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
safe_explicit_values = explicit_values if isinstance(explicit_values, dict) else {}
|
||||
corpus = self._build_simulation_corpus(message, attachments)
|
||||
city_mentions = self._extract_city_mentions(corpus)
|
||||
message_city_mentions = self._extract_city_mentions(message)
|
||||
|
||||
for field in fields:
|
||||
key = field["key"]
|
||||
@@ -123,17 +136,41 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
values[key] = attachment_value
|
||||
source_map[key] = "ocr"
|
||||
continue
|
||||
if attachments and key.startswith("attachment.") and (
|
||||
"city" in key or "location" in key
|
||||
):
|
||||
inferred = None
|
||||
else:
|
||||
inferred = self._infer_simulation_value(
|
||||
key,
|
||||
field.get("label") or key,
|
||||
corpus=corpus,
|
||||
city_mentions=city_mentions,
|
||||
city_mentions=(
|
||||
message_city_mentions
|
||||
if key.startswith(("claim.", "item."))
|
||||
else city_mentions
|
||||
),
|
||||
)
|
||||
if self._has_meaningful_value(inferred):
|
||||
values[key] = inferred
|
||||
source_map[key] = "inferred"
|
||||
|
||||
self._apply_compare_city_hints(manifest, values, source_map, city_mentions)
|
||||
allowed_keys = {field["key"] for field in fields}
|
||||
model_values = refine_simulation_values_with_model(
|
||||
getattr(self, "db", None),
|
||||
fields,
|
||||
message=message,
|
||||
attachments=attachments,
|
||||
)
|
||||
for key, value in model_values.items():
|
||||
if not self._has_meaningful_value(value) or key not in allowed_keys:
|
||||
continue
|
||||
if source_map.get(key) == "manual":
|
||||
continue
|
||||
values[key] = value
|
||||
source_map[key] = "model_refined"
|
||||
|
||||
self._apply_compare_city_hints(manifest, values, source_map, message_city_mentions)
|
||||
recognized_fields = self._build_recognized_fields(fields, values, source_map)
|
||||
return values, source_map, recognized_fields
|
||||
|
||||
@@ -163,7 +200,14 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
return city_mentions[0] if city_mentions else ""
|
||||
if field_key.endswith("amount"):
|
||||
return self._extract_amount(corpus)
|
||||
if field_key.endswith("issue_date") or field_key.endswith("item_date"):
|
||||
if (
|
||||
field_key.endswith("issue_date")
|
||||
or field_key.endswith("item_date")
|
||||
or field_key.endswith("trip_start_date")
|
||||
or field_key.endswith("trip_end_date")
|
||||
or field_key.endswith("stay_start_date")
|
||||
or field_key.endswith("stay_end_date")
|
||||
):
|
||||
return self._extract_iso_date(corpus)
|
||||
if field_key.endswith("invoice_no"):
|
||||
return self._extract_invoice_no(corpus)
|
||||
@@ -195,12 +239,18 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
right = str(condition.get("right") or "").strip()
|
||||
if not left or not right:
|
||||
continue
|
||||
if self._looks_like_city_field(left):
|
||||
if self._looks_like_city_field(left) and (
|
||||
not self._has_meaningful_value(values.get(left))
|
||||
or source_map.get(left) == "inferred"
|
||||
):
|
||||
values[left] = city_mentions[0]
|
||||
source_map[left] = source_map.get(left) or "inferred"
|
||||
if self._looks_like_city_field(right):
|
||||
source_map[left] = "inferred"
|
||||
if self._looks_like_city_field(right) and (
|
||||
not self._has_meaningful_value(values.get(right))
|
||||
or source_map.get(right) == "inferred"
|
||||
):
|
||||
values[right] = city_mentions[1]
|
||||
source_map[right] = source_map.get(right) or "inferred"
|
||||
source_map[right] = "inferred"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_simulation_attachments(
|
||||
@@ -263,6 +313,9 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
) -> Any:
|
||||
short_key = field_key.removeprefix("attachment.")
|
||||
for attachment in attachments:
|
||||
derived_value = derive_attachment_field_value(short_key, attachment)
|
||||
if self._has_meaningful_value(derived_value):
|
||||
return derived_value
|
||||
if short_key == "ocr_text":
|
||||
value = attachment.get("ocr_text") or attachment.get("summary")
|
||||
if self._has_meaningful_value(value):
|
||||
@@ -272,34 +325,12 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
continue
|
||||
candidate_key = str(field.get("key") or "").strip().lower()
|
||||
candidate_label = str(field.get("label") or "").strip()
|
||||
if self._field_matches_simulation_key(
|
||||
if field_matches_simulation_key(
|
||||
candidate_key, candidate_label, short_key, label
|
||||
):
|
||||
return field.get("value")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _field_matches_simulation_key(
|
||||
candidate_key: str,
|
||||
candidate_label: str,
|
||||
short_key: str,
|
||||
target_label: str,
|
||||
) -> bool:
|
||||
compact_candidate = candidate_key.replace("_", "")
|
||||
compact_target = short_key.replace("_", "").lower()
|
||||
if compact_target and compact_target in compact_candidate:
|
||||
return True
|
||||
label_text = f"{candidate_label} {target_label}"
|
||||
label_map = {
|
||||
"invoice_no": ("发票号", "发票号码", "票号"),
|
||||
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
|
||||
"route_cities": ("行程", "路线", "目的地", "出差城市"),
|
||||
"goods_name": ("品名", "商品", "服务名称"),
|
||||
"amount": ("金额", "价税合计", "合计"),
|
||||
"issue_date": ("日期", "开票日期", "发票日期"),
|
||||
}
|
||||
return any(token in label_text for token in label_map.get(short_key, ()))
|
||||
|
||||
def _extract_execution_field_keys(self, manifest: dict[str, Any]) -> list[str]:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()
|
||||
@@ -308,9 +339,22 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
conditions = (
|
||||
params.get("conditions") if isinstance(params.get("conditions"), list) else []
|
||||
)
|
||||
for group_name in (
|
||||
"attachment_city_fields",
|
||||
"reference_city_fields",
|
||||
"home_city_fields",
|
||||
"exception_fields",
|
||||
):
|
||||
for key in self._read_string_list(params.get(group_name)):
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
for condition in conditions:
|
||||
if not isinstance(condition, dict):
|
||||
continue
|
||||
for group_name in ("left_group", "right_group", "exception_fields"):
|
||||
for key in self._read_string_list(condition.get(group_name)):
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
for side in ("left", "right"):
|
||||
key = str(condition.get(side) or "").strip()
|
||||
if key and key not in keys:
|
||||
@@ -323,6 +367,24 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
keys.append(key)
|
||||
elif template_key == "field_required_v1":
|
||||
return []
|
||||
elif template_key == "composite_rule_v1":
|
||||
conditions = (
|
||||
params.get("conditions") if isinstance(params.get("conditions"), list) else []
|
||||
)
|
||||
for condition in conditions:
|
||||
if not isinstance(condition, dict):
|
||||
continue
|
||||
for group_name in (
|
||||
"fields",
|
||||
"left_fields",
|
||||
"right_fields",
|
||||
"date_fields",
|
||||
"range_start_fields",
|
||||
"range_end_fields",
|
||||
):
|
||||
for key in self._read_string_list(condition.get(group_name)):
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
def _build_missing_fields(
|
||||
@@ -334,6 +396,28 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
required_keys: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
labels = {field["key"]: field["label"] for field in self._extract_manifest_fields(manifest)}
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
if self._is_city_consistency_manifest(manifest):
|
||||
declared_keys = self._read_string_list(
|
||||
params.get("search_fields") or params.get("field_keys")
|
||||
)
|
||||
candidate_keys = [*required_keys, *declared_keys]
|
||||
reference_keys = [
|
||||
key for key in candidate_keys if key in {"claim.location", "item.item_location"}
|
||||
]
|
||||
attachment_keys = [
|
||||
key
|
||||
for key in candidate_keys
|
||||
if key in {"attachment.route_cities", "attachment.hotel_city"}
|
||||
]
|
||||
missing_groups: list[dict[str, Any]] = []
|
||||
if not any(self._has_meaningful_value(field_values.get(key)) for key in reference_keys):
|
||||
missing_groups.append({"key": "claim.location", "label": labels.get("claim.location", "申报地点")})
|
||||
if not any(self._has_meaningful_value(field_values.get(key)) for key in attachment_keys):
|
||||
missing_groups.append({"key": "attachment.route_cities", "label": labels.get("attachment.route_cities", "行程城市")})
|
||||
return missing_groups
|
||||
if str(params.get("template_key") or manifest.get("template_key") or "") == "composite_rule_v1":
|
||||
return []
|
||||
missing: list[dict[str, Any]] = []
|
||||
for key in required_keys:
|
||||
value = field_values.get(key)
|
||||
@@ -341,6 +425,29 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
missing.append({"key": key, "label": labels.get(key, key)})
|
||||
return missing
|
||||
|
||||
@staticmethod
|
||||
def _is_city_consistency_manifest(manifest: dict[str, Any]) -> bool:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
if str(params.get("semantic_type") or "").strip() in {
|
||||
"travel_city_consistency",
|
||||
"travel_route_city_consistency",
|
||||
}:
|
||||
return True
|
||||
field_keys = AgentAssetRiskRuleSimulationMixin._read_string_list(
|
||||
params.get("search_fields") or params.get("field_keys")
|
||||
)
|
||||
has_reference = any(key in {"claim.location", "item.item_location"} for key in field_keys)
|
||||
has_attachment_city = any(
|
||||
key in {"attachment.route_cities", "attachment.hotel_city"} for key in field_keys
|
||||
)
|
||||
if not (has_reference and has_attachment_city):
|
||||
return False
|
||||
text = "\n".join(
|
||||
str(params.get(key) or "")
|
||||
for key in ("natural_language", "condition_summary", "message_template")
|
||||
)
|
||||
return any(term in text for term in ("一致", "不一致", "匹配", "不符", "对应", "出现在"))
|
||||
|
||||
def _resolve_simulation_block(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
@@ -454,87 +561,35 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
|
||||
@staticmethod
|
||||
def _extract_city_mentions(text: str) -> list[str]:
|
||||
city_names = [
|
||||
"北京",
|
||||
"上海",
|
||||
"广州",
|
||||
"深圳",
|
||||
"杭州",
|
||||
"南京",
|
||||
"成都",
|
||||
"武汉",
|
||||
"重庆",
|
||||
"天津",
|
||||
"苏州",
|
||||
"西安",
|
||||
]
|
||||
pattern = "|".join(re.escape(city) for city in city_names)
|
||||
found: list[str] = []
|
||||
for match in re.finditer(pattern, text):
|
||||
city = match.group(0)
|
||||
if city not in found:
|
||||
found.append(city)
|
||||
return found
|
||||
return extract_city_mentions(text)
|
||||
|
||||
@staticmethod
|
||||
def _extract_amount(text: str) -> str:
|
||||
match = re.search(r"(\d{2,8}(?:\.\d{1,2})?)\s*(?:元|块|人民币|CNY)?", text, re.IGNORECASE)
|
||||
return match.group(1) if match else ""
|
||||
return extract_amount(text)
|
||||
|
||||
@staticmethod
|
||||
def _extract_iso_date(text: str) -> str:
|
||||
match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text)
|
||||
if not match:
|
||||
return ""
|
||||
year, month, day = (int(part) for part in match.groups())
|
||||
try:
|
||||
return date(year, month, day).isoformat()
|
||||
except ValueError:
|
||||
return ""
|
||||
return extract_iso_date(text)
|
||||
|
||||
@staticmethod
|
||||
def _extract_invoice_no(text: str) -> str:
|
||||
match = re.search(r"(?:发票号|发票号码|票号)[::\s]*([A-Z0-9-]{6,32})", text, re.IGNORECASE)
|
||||
return match.group(1) if match else ""
|
||||
return extract_invoice_no(text)
|
||||
|
||||
@staticmethod
|
||||
def _infer_item_type(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
|
||||
return "住宿费"
|
||||
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
|
||||
return "交通费"
|
||||
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
|
||||
return "餐饮费"
|
||||
return "差旅费"
|
||||
return infer_item_type(text)
|
||||
|
||||
@staticmethod
|
||||
def _infer_goods_name(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
|
||||
return "住宿服务"
|
||||
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
|
||||
return "交通服务"
|
||||
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
|
||||
return "餐饮服务"
|
||||
return "报销服务"
|
||||
return infer_goods_name(text)
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_city_field(field_key: str) -> bool:
|
||||
lowered = field_key.lower()
|
||||
return "city" in lowered or "location" in lowered or lowered.endswith("route_cities")
|
||||
return looks_like_city_field(field_key)
|
||||
|
||||
@staticmethod
|
||||
def _has_meaningful_value(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
if isinstance(value, (list, tuple, set, dict)):
|
||||
return bool(value)
|
||||
return True
|
||||
return has_meaningful_value(value)
|
||||
|
||||
@staticmethod
|
||||
def _risk_severity_label(severity: str) -> str:
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
|
||||
CITY_NAMES = [
|
||||
"北京",
|
||||
"上海",
|
||||
"广州",
|
||||
"深圳",
|
||||
"杭州",
|
||||
"南京",
|
||||
"成都",
|
||||
"武汉",
|
||||
"重庆",
|
||||
"天津",
|
||||
"苏州",
|
||||
"西安",
|
||||
"长沙",
|
||||
"郑州",
|
||||
"青岛",
|
||||
"厦门",
|
||||
"福州",
|
||||
"合肥",
|
||||
"济南",
|
||||
"沈阳",
|
||||
"大连",
|
||||
"宁波",
|
||||
"温州",
|
||||
"无锡",
|
||||
"常州",
|
||||
"昆明",
|
||||
"贵阳",
|
||||
"南宁",
|
||||
"南昌",
|
||||
"太原",
|
||||
"石家庄",
|
||||
"哈尔滨",
|
||||
"长春",
|
||||
"兰州",
|
||||
"银川",
|
||||
"西宁",
|
||||
"乌鲁木齐",
|
||||
"呼和浩特",
|
||||
"海口",
|
||||
"三亚",
|
||||
"佛山",
|
||||
"东莞",
|
||||
"珠海",
|
||||
"惠州",
|
||||
"嘉兴",
|
||||
"绍兴",
|
||||
"金华",
|
||||
"台州",
|
||||
"泉州",
|
||||
"烟台",
|
||||
"徐州",
|
||||
"扬州",
|
||||
"南通",
|
||||
"镇江",
|
||||
"洛阳",
|
||||
"襄阳",
|
||||
"宜昌",
|
||||
]
|
||||
|
||||
|
||||
def refine_simulation_values_with_model(
|
||||
db: Any,
|
||||
fields: list[dict[str, str]],
|
||||
*,
|
||||
message: str,
|
||||
attachments: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
if not fields or not attachments or db is None:
|
||||
return {}
|
||||
|
||||
compact_attachments = [
|
||||
{
|
||||
"name": item.get("name") or "",
|
||||
"document_type_label": item.get("document_type_label") or "",
|
||||
"scene_label": item.get("scene_label") or "",
|
||||
"summary": item.get("summary") or "",
|
||||
"ocr_text": str(item.get("ocr_text") or "")[:2500],
|
||||
"document_fields": list(item.get("document_fields") or [])[:20],
|
||||
}
|
||||
for item in attachments[:4]
|
||||
]
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是风险规则仿真测试的票据字段过滤器。只根据用户输入、OCR 文本和结构化字段,"
|
||||
"把信息归一化到给定字段 key。不要判断风险,不要编造不存在的信息。只输出 JSON。"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"available_fields": fields,
|
||||
"user_message": str(message or ""),
|
||||
"attachments": compact_attachments,
|
||||
"output_shape": {
|
||||
"field_values": {
|
||||
"claim.location": "城市或地点",
|
||||
"attachment.route_cities": ["出发城市", "到达城市"],
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
"字段值必须来自 OCR 或用户输入。",
|
||||
"车票路线要拆成城市数组,例如 上海虹桥-武汉 -> [\"上海\", \"武汉\"]。",
|
||||
"没有把握的字段不要返回。",
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
]
|
||||
try:
|
||||
answer = RuntimeChatService(db).complete(
|
||||
messages,
|
||||
max_tokens=700,
|
||||
temperature=0.0,
|
||||
timeout_seconds=8,
|
||||
max_attempts=1,
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
payload = extract_json_payload(answer)
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
field_values = payload.get("field_values") if isinstance(payload.get("field_values"), dict) else {}
|
||||
allowed = {field["key"] for field in fields}
|
||||
return {
|
||||
key: value
|
||||
for key, value in field_values.items()
|
||||
if key in allowed and has_meaningful_value(value)
|
||||
}
|
||||
|
||||
|
||||
def derive_attachment_field_value(short_key: str, attachment: dict[str, Any]) -> Any:
|
||||
fields = [field for field in list(attachment.get("document_fields") or []) if isinstance(field, dict)]
|
||||
text_parts = [
|
||||
str(attachment.get("summary") or ""),
|
||||
str(attachment.get("ocr_text") or ""),
|
||||
*[str(field.get("value") or "") for field in fields],
|
||||
]
|
||||
corpus = "\n".join(part for part in text_parts if part)
|
||||
|
||||
if short_key == "route_cities":
|
||||
for field in fields:
|
||||
key = str(field.get("key") or "").strip().lower()
|
||||
label = str(field.get("label") or "").strip()
|
||||
if looks_like_route_field(key, label):
|
||||
cities = extract_route_cities(str(field.get("value") or ""))
|
||||
if cities:
|
||||
return cities
|
||||
cities = extract_route_cities(corpus)
|
||||
return cities or None
|
||||
|
||||
if short_key in {"hotel_city", "city"}:
|
||||
for field in fields:
|
||||
key = str(field.get("key") or "").strip().lower()
|
||||
label = str(field.get("label") or "").strip()
|
||||
if looks_like_route_field(key, label):
|
||||
continue
|
||||
if looks_like_city_field(f"{key} {label}"):
|
||||
cities = extract_city_mentions(str(field.get("value") or ""))
|
||||
if cities:
|
||||
return cities[0]
|
||||
value = str(field.get("value") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
alias_keys = {
|
||||
"invoice_no": {"invoice_no", "invoice_number", "ticket_number", "order_no", "order_number"},
|
||||
"issue_date": {"issue_date", "invoice_date", "date", "trip_date", "travel_date"},
|
||||
"stay_start_date": {"stay_start_date", "check_in_date", "arrival_date", "入住日期"},
|
||||
"stay_end_date": {"stay_end_date", "check_out_date", "departure_date", "离店日期"},
|
||||
"amount": {"amount", "total_amount", "payment_amount", "paid_amount"},
|
||||
"goods_name": {"goods_name", "service_name", "item_name", "merchant_name"},
|
||||
}.get(short_key, set())
|
||||
if alias_keys:
|
||||
for field in fields:
|
||||
key = str(field.get("key") or "").strip().lower()
|
||||
label = str(field.get("label") or "").strip()
|
||||
if (
|
||||
key in alias_keys
|
||||
or field_matches_simulation_key(key, label, short_key, short_key)
|
||||
) and has_meaningful_value(field.get("value")):
|
||||
return field.get("value")
|
||||
return None
|
||||
|
||||
|
||||
def field_matches_simulation_key(
|
||||
candidate_key: str,
|
||||
candidate_label: str,
|
||||
short_key: str,
|
||||
target_label: str,
|
||||
) -> bool:
|
||||
compact_candidate = candidate_key.replace("_", "")
|
||||
compact_target = short_key.replace("_", "").lower()
|
||||
if compact_target and compact_target in compact_candidate:
|
||||
return True
|
||||
label_text = f"{candidate_label} {target_label}"
|
||||
label_map = {
|
||||
"invoice_no": ("发票号", "发票号码", "票号"),
|
||||
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
|
||||
"route_cities": ("行程", "路线", "目的地", "出差城市"),
|
||||
"goods_name": ("品名", "商品", "服务名称"),
|
||||
"amount": ("金额", "价税合计", "合计"),
|
||||
"issue_date": ("日期", "开票日期", "发票日期"),
|
||||
"stay_start_date": ("入住日期", "住宿开始", "入住时间", "开始日期"),
|
||||
"stay_end_date": ("离店日期", "退房日期", "住宿结束", "结束日期"),
|
||||
}
|
||||
return any(token in label_text for token in label_map.get(short_key, ()))
|
||||
|
||||
|
||||
def extract_json_payload(response_text: str | None) -> dict[str, Any] | None:
|
||||
if not response_text:
|
||||
return None
|
||||
cleaned = re.sub(
|
||||
r"<think>.*?</think>",
|
||||
"",
|
||||
str(response_text),
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
).strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
candidates: list[str] = []
|
||||
fenced_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", cleaned, flags=re.DOTALL)
|
||||
if fenced_match:
|
||||
candidates.append(fenced_match.group(1))
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
candidates.append(cleaned[start : end + 1])
|
||||
candidates.append(cleaned)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def extract_city_mentions(text: str) -> list[str]:
|
||||
content = str(text or "")
|
||||
if not content:
|
||||
return []
|
||||
pattern = "|".join(re.escape(city) for city in CITY_NAMES)
|
||||
found: list[str] = []
|
||||
for match in re.finditer(pattern, content):
|
||||
city = match.group(0)
|
||||
if city not in found:
|
||||
found.append(city)
|
||||
return found
|
||||
|
||||
|
||||
def extract_route_cities(text: str) -> list[str]:
|
||||
mentions = extract_city_mentions(text)
|
||||
if mentions:
|
||||
return mentions
|
||||
route_text = str(text or "")
|
||||
if not route_text:
|
||||
return []
|
||||
fragments = re.split(r"(?:->|→|—|--|-|至|到|开往|前往|赴)", route_text)
|
||||
cities: list[str] = []
|
||||
for fragment in fragments:
|
||||
candidate = normalize_city_token(fragment)
|
||||
if candidate and candidate not in cities:
|
||||
cities.append(candidate)
|
||||
return cities[:4]
|
||||
|
||||
|
||||
def normalize_city_token(value: str) -> str:
|
||||
text = re.sub(r"[^\u4e00-\u9fff]", "", str(value or ""))
|
||||
if not text:
|
||||
return ""
|
||||
for suffix in (
|
||||
"火车站",
|
||||
"高铁站",
|
||||
"汽车站",
|
||||
"机场",
|
||||
"虹桥",
|
||||
"东站",
|
||||
"西站",
|
||||
"南站",
|
||||
"北站",
|
||||
"站",
|
||||
"市",
|
||||
):
|
||||
text = text.replace(suffix, "")
|
||||
if 2 <= len(text) <= 6:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def extract_amount(text: str) -> str:
|
||||
match = re.search(r"(\d{2,8}(?:\.\d{1,2})?)\s*(?:元|块|人民币|CNY)?", text, re.IGNORECASE)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def extract_iso_date(text: str) -> str:
|
||||
match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text)
|
||||
if not match:
|
||||
return ""
|
||||
year, month, day = (int(part) for part in match.groups())
|
||||
try:
|
||||
return date(year, month, day).isoformat()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def extract_invoice_no(text: str) -> str:
|
||||
match = re.search(r"(?:发票号|发票号码|票号)[::\s]*([A-Z0-9-]{6,32})", text, re.IGNORECASE)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def infer_item_type(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
|
||||
return "住宿费"
|
||||
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
|
||||
return "交通费"
|
||||
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
|
||||
return "餐饮费"
|
||||
return "差旅费"
|
||||
|
||||
|
||||
def infer_goods_name(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
|
||||
return "住宿服务"
|
||||
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
|
||||
return "交通服务"
|
||||
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
|
||||
return "餐饮服务"
|
||||
return "报销服务"
|
||||
|
||||
|
||||
def looks_like_city_field(field_key: str) -> bool:
|
||||
lowered = field_key.lower()
|
||||
return "city" in lowered or "location" in lowered or lowered.endswith("route_cities")
|
||||
|
||||
|
||||
def looks_like_route_field(candidate_key: str, candidate_label: str = "") -> bool:
|
||||
text = f"{candidate_key} {candidate_label}".lower()
|
||||
return any(
|
||||
token in text
|
||||
for token in (
|
||||
"route",
|
||||
"trip_route",
|
||||
"travel_route",
|
||||
"from_to",
|
||||
"origin_destination",
|
||||
"行程",
|
||||
"路线",
|
||||
"出发",
|
||||
"到达",
|
||||
"目的地",
|
||||
"车次",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def has_meaningful_value(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
if isinstance(value, (list, tuple, set, dict)):
|
||||
return bool(value)
|
||||
return True
|
||||
@@ -14,6 +14,7 @@ from app.core.agent_enums import (
|
||||
AgentReviewStatus,
|
||||
)
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestRun
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleLatestTestSummary,
|
||||
@@ -25,6 +26,7 @@ from app.schemas.agent_asset import (
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
|
||||
|
||||
class AgentAssetRiskRuleTestingMixin:
|
||||
@@ -333,6 +335,7 @@ class AgentAssetRiskRuleTestingMixin:
|
||||
library=rule_library,
|
||||
file_name=file_name,
|
||||
)
|
||||
manifest = normalize_risk_rule_manifest(manifest)
|
||||
return asset, target_version, manifest
|
||||
|
||||
def _create_test_run(
|
||||
@@ -451,6 +454,13 @@ class AgentAssetRiskRuleTestingMixin:
|
||||
item_amount=self._to_decimal(values.get("item.item_amount") or claim.amount),
|
||||
)
|
||||
claim.items = [item]
|
||||
if values.get("employee.location"):
|
||||
claim.employee = Employee(
|
||||
employee_no="TEST-EMPLOYEE",
|
||||
name=claim.employee_name,
|
||||
email="risk-rule-test@example.com",
|
||||
location=str(values.get("employee.location") or ""),
|
||||
)
|
||||
|
||||
attachment_fields = []
|
||||
document_info: dict[str, Any] = {"fields": attachment_fields}
|
||||
@@ -585,7 +595,14 @@ class AgentAssetRiskRuleTestingMixin:
|
||||
def _default_value_for_field(field_key: str) -> Any:
|
||||
if field_key.endswith("amount"):
|
||||
return "100.00"
|
||||
if field_key.endswith("issue_date"):
|
||||
if (
|
||||
field_key.endswith("issue_date")
|
||||
or field_key.endswith("stay_start_date")
|
||||
or field_key.endswith("stay_end_date")
|
||||
or field_key.endswith("trip_start_date")
|
||||
or field_key.endswith("trip_end_date")
|
||||
or field_key.endswith("item_date")
|
||||
):
|
||||
return date.today().isoformat()
|
||||
if field_key.endswith("route_cities"):
|
||||
return ["北京"]
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.schemas.agent_asset import (
|
||||
)
|
||||
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
|
||||
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin
|
||||
from app.services.agent_asset_risk_rule_level import AgentAssetRiskRuleLevelMixin
|
||||
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
|
||||
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
@@ -43,6 +44,7 @@ logger = get_logger("app.services.agent_assets")
|
||||
class AgentAssetService(
|
||||
AgentAssetOnlyOfficeMixin,
|
||||
AgentAssetSpreadsheetHelperMixin,
|
||||
AgentAssetRiskRuleLevelMixin,
|
||||
AgentAssetRiskRuleTestingMixin,
|
||||
AgentAssetRiskRuleSimulationMixin,
|
||||
AgentAssetTimelineMixin,
|
||||
@@ -106,6 +108,9 @@ class AgentAssetService(
|
||||
latest_test_summary=self.get_latest_risk_rule_test_summary(asset)
|
||||
if str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "json_risk"
|
||||
and working_version
|
||||
and asset.status
|
||||
not in {AgentAssetStatus.GENERATING.value, AgentAssetStatus.FAILED.value}
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,62 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.agent_enums import (
|
||||
AgentAssetContentType,
|
||||
AgentAssetDomain,
|
||||
AgentAssetStatus,
|
||||
AgentAssetType,
|
||||
AgentName,
|
||||
AgentPermissionLevel,
|
||||
AgentReviewStatus,
|
||||
AgentRunSource,
|
||||
AgentRunStatus,
|
||||
AgentToolType,
|
||||
)
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.financial_record import (
|
||||
AccountsPayableRecord,
|
||||
AccountsReceivableRecord,
|
||||
ExpenseClaim,
|
||||
ExpenseClaimItem,
|
||||
)
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
AgentAssetSpreadsheetManager,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
RISK_RULES_LIBRARY,
|
||||
)
|
||||
from app.services.expense_rule_runtime import (
|
||||
build_scene_submission_standard_markdown,
|
||||
build_travel_risk_control_standard_markdown,
|
||||
)
|
||||
from app.services.agent_foundation_constants import (
|
||||
ATTACHMENT_RULE_ASSET_CODE,
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG,
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DEMO_EXPENSE_CLAIM_SIGNATURES,
|
||||
DEMO_PAYABLE_SIGNATURES,
|
||||
DEMO_RECEIVABLE_SIGNATURES,
|
||||
LEGACY_RULE_CODES,
|
||||
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
|
||||
)
|
||||
from app.core.logging import get_logger
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
RISK_RULES_LIBRARY,
|
||||
)
|
||||
from app.services.agent_foundation_constants import (
|
||||
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
|
||||
)
|
||||
|
||||
logger = get_logger("app.services.agent_foundation")
|
||||
|
||||
@@ -67,20 +30,51 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
manifests: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
for file_name in sorted(manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)):
|
||||
for file_name in sorted(
|
||||
manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)
|
||||
):
|
||||
|
||||
payload = manager.read_rule_library_json(library=RISK_RULES_LIBRARY, file_name=file_name)
|
||||
payload = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
)
|
||||
|
||||
if payload.get("enabled") is False:
|
||||
|
||||
continue
|
||||
|
||||
if self._is_user_generated_risk_manifest(payload):
|
||||
|
||||
continue
|
||||
|
||||
manifests.append((file_name, payload))
|
||||
|
||||
return manifests
|
||||
|
||||
@staticmethod
|
||||
|
||||
def _is_user_generated_risk_manifest(manifest: dict[str, object]) -> bool:
|
||||
|
||||
rule_code = str(manifest.get("rule_code") or "").strip().lower()
|
||||
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
|
||||
stability = str(metadata.get("stability") or "").strip().lower()
|
||||
|
||||
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||
|
||||
if stability == "generated_draft":
|
||||
|
||||
return True
|
||||
|
||||
if source_ref == "自然语言风险规则":
|
||||
|
||||
return True
|
||||
|
||||
return ".generated_" in rule_code
|
||||
|
||||
@staticmethod
|
||||
|
||||
def _resolve_platform_risk_category(manifest: dict[str, object]) -> str:
|
||||
|
||||
explicit = str(manifest.get("risk_category") or "").strip()
|
||||
@@ -91,7 +85,9 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
rule_code = str(manifest.get("rule_code") or "").strip().lower()
|
||||
|
||||
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||
applies_to = (
|
||||
manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||
)
|
||||
|
||||
domains = {str(item or "").strip().lower() for item in applies_to.get("domains") or []}
|
||||
|
||||
@@ -133,7 +129,9 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
return [category] if category else ["通用"]
|
||||
|
||||
def _platform_risk_config_json(self, file_name: str, manifest: dict[str, object]) -> dict[str, object]:
|
||||
def _platform_risk_config_json(
|
||||
self, file_name: str, manifest: dict[str, object]
|
||||
) -> dict[str, object]:
|
||||
|
||||
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
|
||||
|
||||
@@ -191,7 +189,9 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
continue
|
||||
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
metadata = (
|
||||
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
)
|
||||
|
||||
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||
|
||||
@@ -255,7 +255,11 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
"Platform risk rules synced from library",
|
||||
|
||||
extra={"manifest_count": manifest_count, "created_count": synced, "total": len(after_codes)},
|
||||
extra={
|
||||
"manifest_count": manifest_count,
|
||||
"created_count": synced,
|
||||
"total": len(after_codes),
|
||||
},
|
||||
|
||||
)
|
||||
|
||||
@@ -271,7 +275,9 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
continue
|
||||
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
metadata = (
|
||||
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
)
|
||||
|
||||
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||
|
||||
@@ -347,7 +353,11 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
version="v1.0.0",
|
||||
|
||||
content=self._platform_risk_rule_markdown(asset, manifest=manifest, file_name=file_name),
|
||||
content=self._platform_risk_rule_markdown(
|
||||
asset,
|
||||
manifest=manifest,
|
||||
file_name=file_name,
|
||||
),
|
||||
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
|
||||
@@ -389,19 +399,25 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
config = asset.config_json if isinstance(asset.config_json, dict) else {}
|
||||
|
||||
rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
|
||||
rule_document = (
|
||||
config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
|
||||
)
|
||||
|
||||
resolved_file_name = file_name or str(rule_document.get("file_name") or "").strip()
|
||||
|
||||
evaluator = str(config.get("evaluator") or (manifest or {}).get("evaluator") or "").strip()
|
||||
|
||||
ontology_signal = str(config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or "").strip()
|
||||
ontology_signal = str(
|
||||
config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or ""
|
||||
).strip()
|
||||
|
||||
source_ref = str(config.get("source_ref") or "").strip()
|
||||
|
||||
if not source_ref and isinstance(manifest, dict):
|
||||
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
metadata = (
|
||||
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
)
|
||||
|
||||
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||
|
||||
@@ -457,7 +473,10 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
return AgentFoundationRiskRuleMixin._platform_risk_rule_markdown(
|
||||
|
||||
AgentAsset(name="申报地点与票据地点一致", config_json={"evaluator": "location_consistency"}),
|
||||
AgentAsset(
|
||||
name="申报地点与票据地点一致",
|
||||
config_json={"evaluator": "location_consistency"},
|
||||
),
|
||||
|
||||
manifest={
|
||||
|
||||
|
||||
86
server/src/app/services/document_numbering.py
Normal file
86
server/src/app/services/document_numbering.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Callable, Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
|
||||
DocumentNumberKind = Literal["application", "reimbursement", "audit"]
|
||||
|
||||
DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = {
|
||||
"application": "AP",
|
||||
"reimbursement": "RE",
|
||||
"audit": "AD",
|
||||
}
|
||||
DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
DOCUMENT_NUMBER_TOKEN_LENGTH = 8
|
||||
DOCUMENT_NUMBER_PATTERN = re.compile(
|
||||
rf"^(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}$",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile(
|
||||
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
||||
r"|APP-\d{8}-[A-Z0-9]{6}"
|
||||
r"|EXP-\d{6}-\d{3}",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def generate_document_token() -> str:
|
||||
return "".join(
|
||||
secrets.choice(DOCUMENT_NUMBER_TOKEN_ALPHABET)
|
||||
for _ in range(DOCUMENT_NUMBER_TOKEN_LENGTH)
|
||||
)
|
||||
|
||||
|
||||
def build_document_number(
|
||||
kind: DocumentNumberKind,
|
||||
*,
|
||||
timestamp: datetime | None = None,
|
||||
token: str | None = None,
|
||||
) -> str:
|
||||
prefix = DOCUMENT_NUMBER_PREFIXES[kind]
|
||||
generated_at = timestamp or datetime.now(UTC)
|
||||
if generated_at.tzinfo is None:
|
||||
generated_at = generated_at.replace(tzinfo=UTC)
|
||||
normalized_token = (token or generate_document_token()).strip().upper()
|
||||
if not re.fullmatch(
|
||||
rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
|
||||
normalized_token,
|
||||
):
|
||||
raise ValueError("document number token must be 8 chars from the configured alphabet")
|
||||
return f"{prefix}-{generated_at.astimezone(UTC):%Y%m%d%H%M%S}-{normalized_token}"
|
||||
|
||||
|
||||
def generate_unique_expense_claim_no(
|
||||
db: Session,
|
||||
kind: DocumentNumberKind,
|
||||
*,
|
||||
timestamp: datetime | None = None,
|
||||
token_factory: Callable[[], str] = generate_document_token,
|
||||
max_attempts: int = 8,
|
||||
) -> str:
|
||||
for _ in range(max_attempts):
|
||||
candidate = build_document_number(
|
||||
kind,
|
||||
timestamp=timestamp,
|
||||
token=token_factory(),
|
||||
)
|
||||
exists = db.scalar(
|
||||
select(ExpenseClaim.id)
|
||||
.where(ExpenseClaim.claim_no == candidate)
|
||||
.limit(1)
|
||||
)
|
||||
if exists is None:
|
||||
return candidate
|
||||
raise RuntimeError(f"failed to generate a unique {kind} document number")
|
||||
|
||||
|
||||
def is_application_claim_no(value: object) -> bool:
|
||||
normalized = str(value or "").strip().upper()
|
||||
return normalized.startswith(("AP-", "APP-"))
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
from collections import Counter
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -29,24 +28,31 @@ from app.schemas.employee import (
|
||||
EmployeeUpdate,
|
||||
)
|
||||
from app.services.employee_import import EmployeeImportCoordinator
|
||||
from app.services.employee_serialization import (
|
||||
format_history_datetime as serialize_history_datetime,
|
||||
serialize_employee,
|
||||
)
|
||||
from app.services.employee_serialization import serialize_employee
|
||||
from app.services.employee_spreadsheet import build_import_template_bytes
|
||||
from app.services.employee_seed import (
|
||||
CANONICAL_DEPARTMENT_CODES,
|
||||
EMPLOYEE_DEFINITIONS,
|
||||
EMPLOYEE_PROFILE_REPAIRS,
|
||||
LEGACY_ORGANIZATION_UNIT_CODE_MAP,
|
||||
ORGANIZATION_DEFINITIONS,
|
||||
ROLE_DEFINITIONS,
|
||||
ROLE_DISPLAY_ORDER,
|
||||
ROLE_PERMISSION_MAP,
|
||||
normalize_organization_unit_code,
|
||||
)
|
||||
from app.services.employee_time import (
|
||||
format_date,
|
||||
format_datetime,
|
||||
format_history_datetime,
|
||||
normalize_optional_text,
|
||||
parse_date,
|
||||
parse_datetime,
|
||||
)
|
||||
|
||||
logger = get_logger("app.services.employee")
|
||||
DEFAULT_EMPLOYEE_PASSWORD = "123456"
|
||||
MAX_EMPLOYEE_CHANGE_LOGS = 5
|
||||
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
STATUS_TONE_MAP = {
|
||||
"在职": "success",
|
||||
@@ -85,6 +91,7 @@ class EmployeeService:
|
||||
self._seed_roles()
|
||||
self._seed_organization_units()
|
||||
self._seed_employees()
|
||||
self._normalize_legacy_employee_departments()
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
@@ -132,6 +139,7 @@ class EmployeeService:
|
||||
for role in self._sorted_roles(self.repository.list_roles())
|
||||
]
|
||||
|
||||
canonical_department_codes = set(CANONICAL_DEPARTMENT_CODES)
|
||||
organization_options = [
|
||||
EmployeeOrganizationRead(
|
||||
id=unit.id,
|
||||
@@ -143,7 +151,11 @@ class EmployeeService:
|
||||
managerName=unit.manager_name,
|
||||
)
|
||||
for unit in sorted(
|
||||
self.repository.list_organization_units(),
|
||||
(
|
||||
unit
|
||||
for unit in self.repository.list_organization_units()
|
||||
if unit.unit_code in canonical_department_codes
|
||||
),
|
||||
key=lambda item: item.name,
|
||||
)
|
||||
]
|
||||
@@ -185,7 +197,8 @@ class EmployeeService:
|
||||
)
|
||||
|
||||
if payload.organization_unit_code:
|
||||
employee.organization_unit = self.repository.get_organization_by_code(payload.organization_unit_code)
|
||||
organization_code = normalize_organization_unit_code(payload.organization_unit_code)
|
||||
employee.organization_unit = self.repository.get_organization_by_code(organization_code)
|
||||
|
||||
if payload.manager_employee_no:
|
||||
employee.manager = self.repository.get_by_employee_no(payload.manager_employee_no)
|
||||
@@ -224,7 +237,7 @@ class EmployeeService:
|
||||
changed_fields.append("姓名")
|
||||
|
||||
if "gender" in payload.model_fields_set:
|
||||
gender = self._normalize_optional_text(payload.gender)
|
||||
gender = normalize_optional_text(payload.gender)
|
||||
if gender != employee.gender:
|
||||
employee.gender = gender
|
||||
changed_fields.append("性别")
|
||||
@@ -236,7 +249,7 @@ class EmployeeService:
|
||||
changed_fields.append("出生日期")
|
||||
|
||||
if "phone" in payload.model_fields_set:
|
||||
phone = self._normalize_optional_text(payload.phone)
|
||||
phone = normalize_optional_text(payload.phone)
|
||||
if phone != employee.phone:
|
||||
employee.phone = phone
|
||||
changed_fields.append("手机号")
|
||||
@@ -257,7 +270,7 @@ class EmployeeService:
|
||||
changed_fields.append("入职日期")
|
||||
|
||||
if "location" in payload.model_fields_set:
|
||||
location = self._normalize_optional_text(payload.location)
|
||||
location = normalize_optional_text(payload.location)
|
||||
if location != employee.location:
|
||||
employee.location = location
|
||||
changed_fields.append("办公地点")
|
||||
@@ -279,19 +292,21 @@ class EmployeeService:
|
||||
changed_fields.append("职级")
|
||||
|
||||
if "cost_center" in payload.model_fields_set:
|
||||
cost_center = self._normalize_optional_text(payload.cost_center)
|
||||
cost_center = normalize_optional_text(payload.cost_center)
|
||||
if cost_center != employee.cost_center:
|
||||
employee.cost_center = cost_center
|
||||
changed_fields.append("成本中心")
|
||||
|
||||
if "finance_owner_name" in payload.model_fields_set:
|
||||
finance_owner_name = self._normalize_optional_text(payload.finance_owner_name)
|
||||
finance_owner_name = normalize_optional_text(payload.finance_owner_name)
|
||||
if finance_owner_name != employee.finance_owner_name:
|
||||
employee.finance_owner_name = finance_owner_name
|
||||
changed_fields.append("财务归口")
|
||||
|
||||
if "organization_unit_code" in payload.model_fields_set:
|
||||
organization_code = self._normalize_optional_text(payload.organization_unit_code)
|
||||
organization_code = normalize_organization_unit_code(
|
||||
normalize_optional_text(payload.organization_unit_code)
|
||||
)
|
||||
current_code = (
|
||||
employee.organization_unit.unit_code if employee.organization_unit else None
|
||||
)
|
||||
@@ -306,7 +321,7 @@ class EmployeeService:
|
||||
changed_fields.append("所属部门")
|
||||
|
||||
if "manager_employee_no" in payload.model_fields_set:
|
||||
manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
|
||||
manager_employee_no = normalize_optional_text(payload.manager_employee_no)
|
||||
current_manager_no = employee.manager.employee_no if employee.manager else None
|
||||
|
||||
if manager_employee_no:
|
||||
@@ -448,8 +463,8 @@ class EmployeeService:
|
||||
self.repository,
|
||||
sorted_roles=self._sorted_roles,
|
||||
append_change_log=self._append_change_log,
|
||||
format_date=self._format_date,
|
||||
format_datetime=self._format_datetime,
|
||||
format_date=format_date,
|
||||
format_datetime=format_datetime,
|
||||
default_password=DEFAULT_EMPLOYEE_PASSWORD,
|
||||
)
|
||||
|
||||
@@ -487,6 +502,12 @@ class EmployeeService:
|
||||
)
|
||||
self.db.add(organization)
|
||||
existing_by_code[organization.unit_code] = organization
|
||||
else:
|
||||
organization.name = definition["name"]
|
||||
organization.unit_type = definition["unit_type"]
|
||||
organization.cost_center = definition.get("cost_center")
|
||||
organization.location = definition.get("location")
|
||||
organization.manager_name = definition.get("manager_name")
|
||||
|
||||
self.db.flush()
|
||||
|
||||
@@ -496,12 +517,30 @@ class EmployeeService:
|
||||
continue
|
||||
|
||||
organization = existing_by_code[definition["unit_code"]]
|
||||
if organization.parent_id:
|
||||
parent = existing_by_code.get(parent_code)
|
||||
if parent is not None and organization.parent_id != parent.id:
|
||||
organization.parent = parent
|
||||
|
||||
self.db.flush()
|
||||
|
||||
def _normalize_legacy_employee_departments(self) -> None:
|
||||
if not LEGACY_ORGANIZATION_UNIT_CODE_MAP:
|
||||
return
|
||||
|
||||
organizations_by_code = {
|
||||
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||
}
|
||||
for employee in self.repository.list():
|
||||
current_code = (
|
||||
employee.organization_unit.unit_code if employee.organization_unit else None
|
||||
)
|
||||
next_code = normalize_organization_unit_code(current_code)
|
||||
if not next_code or next_code == current_code:
|
||||
continue
|
||||
|
||||
parent = existing_by_code.get(parent_code)
|
||||
if parent is not None:
|
||||
organization.parent = parent
|
||||
organization = organizations_by_code.get(next_code)
|
||||
if organization is not None:
|
||||
employee.organization_unit = organization
|
||||
|
||||
self.db.flush()
|
||||
|
||||
@@ -524,9 +563,9 @@ class EmployeeService:
|
||||
name=definition["name"],
|
||||
email=definition["email"],
|
||||
gender=definition.get("gender"),
|
||||
birth_date=self._parse_date(definition.get("birth_date")),
|
||||
birth_date=parse_date(definition.get("birth_date")),
|
||||
phone=definition.get("phone"),
|
||||
join_date=self._parse_date(definition.get("join_date")),
|
||||
join_date=parse_date(definition.get("join_date")),
|
||||
location=definition.get("location"),
|
||||
position=definition.get("position", "员工"),
|
||||
grade=definition.get("grade", "P3"),
|
||||
@@ -535,8 +574,8 @@ class EmployeeService:
|
||||
employment_status=definition.get("employment_status", "在职"),
|
||||
sync_state=definition.get("sync_state", "已同步"),
|
||||
spotlight=bool(definition.get("spotlight")),
|
||||
last_sync_at=self._parse_datetime(definition.get("last_sync_at")),
|
||||
updated_at=self._parse_datetime(definition.get("updated_at")),
|
||||
last_sync_at=parse_datetime(definition.get("last_sync_at")),
|
||||
updated_at=parse_datetime(definition.get("updated_at")),
|
||||
)
|
||||
self.db.add(employee)
|
||||
employees_by_no[employee_no] = employee
|
||||
@@ -659,7 +698,7 @@ class EmployeeService:
|
||||
|
||||
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
|
||||
existing_keys = {
|
||||
(item.action, item.owner, self._format_datetime(item.occurred_at))
|
||||
(item.action, item.owner, format_datetime(item.occurred_at))
|
||||
for item in employee.change_logs
|
||||
}
|
||||
|
||||
@@ -674,14 +713,14 @@ class EmployeeService:
|
||||
]
|
||||
|
||||
for history in history_items:
|
||||
occurred_at = self._parse_datetime(history.get("occurred_at"))
|
||||
occurred_at = parse_datetime(history.get("occurred_at"))
|
||||
if occurred_at is None:
|
||||
continue
|
||||
|
||||
identity = (
|
||||
history["action"],
|
||||
history["owner"],
|
||||
self._format_datetime(occurred_at),
|
||||
format_datetime(occurred_at),
|
||||
)
|
||||
if identity in existing_keys:
|
||||
continue
|
||||
@@ -743,9 +782,9 @@ class EmployeeService:
|
||||
employee,
|
||||
sorted_roles=self._sorted_roles(list(employee.roles)),
|
||||
sorted_change_logs=self._sorted_change_logs(employee),
|
||||
format_date=self._format_date,
|
||||
format_datetime=self._format_datetime,
|
||||
format_history_datetime=self._format_history_datetime,
|
||||
format_date=format_date,
|
||||
format_datetime=format_datetime,
|
||||
format_history_datetime=format_history_datetime,
|
||||
role_permission_map=ROLE_PERMISSION_MAP,
|
||||
status_tone_map=STATUS_TONE_MAP,
|
||||
max_change_logs=MAX_EMPLOYEE_CHANGE_LOGS,
|
||||
@@ -753,52 +792,3 @@ class EmployeeService:
|
||||
|
||||
def _sorted_roles(self, roles: list[Role]) -> list[Role]:
|
||||
return sorted(roles, key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_optional_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text_value = value.strip()
|
||||
return text_value or None
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(value: str | datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.strptime(value, "%Y-%m-%d %H:%M")
|
||||
|
||||
@staticmethod
|
||||
def _format_date(value: date | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strftime("%Y-%m-%d")
|
||||
|
||||
@staticmethod
|
||||
def _to_display_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
normalized = value.replace(tzinfo=UTC)
|
||||
else:
|
||||
normalized = value.astimezone(UTC)
|
||||
return normalized.astimezone(DISPLAY_TIMEZONE)
|
||||
|
||||
@staticmethod
|
||||
def _format_datetime(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
local = EmployeeService._to_display_datetime(value)
|
||||
return local.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
@staticmethod
|
||||
def _format_history_datetime(value: datetime | None) -> str:
|
||||
return serialize_history_datetime(
|
||||
value,
|
||||
to_display_datetime=EmployeeService._to_display_datetime,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.services.employee_spreadsheet import (
|
||||
build_export_workbook_bytes,
|
||||
parse_employee_workbook,
|
||||
)
|
||||
from app.services.employee_seed import normalize_organization_unit_code
|
||||
|
||||
logger = get_logger("app.services.employee")
|
||||
|
||||
@@ -52,6 +53,9 @@ class EmployeeImportCoordinator:
|
||||
for employee in employees:
|
||||
organization = employee.organization_unit
|
||||
role_codes = ",".join(role.role_code for role in self.sorted_roles(list(employee.roles)))
|
||||
organization_code = (
|
||||
normalize_organization_unit_code(organization.unit_code) if organization else ""
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
employee.employee_no,
|
||||
@@ -64,7 +68,7 @@ class EmployeeImportCoordinator:
|
||||
employee.location or "",
|
||||
employee.position,
|
||||
employee.grade,
|
||||
organization.unit_code if organization else "",
|
||||
organization_code or "",
|
||||
employee.manager.employee_no if employee.manager else "",
|
||||
employee.finance_owner_name or "",
|
||||
employee.cost_center or "",
|
||||
@@ -167,7 +171,8 @@ class EmployeeImportCoordinator:
|
||||
)
|
||||
)
|
||||
|
||||
if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
|
||||
organization_code = normalize_organization_unit_code(row.organization_unit_code)
|
||||
if organization_code and organization_code not in organizations_by_code:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
@@ -266,8 +271,9 @@ class EmployeeImportCoordinator:
|
||||
employee.sync_state = "已同步"
|
||||
employee.last_sync_at = now
|
||||
|
||||
if row.organization_unit_code:
|
||||
employee.organization_unit = organizations_by_code[row.organization_unit_code]
|
||||
organization_code = normalize_organization_unit_code(row.organization_unit_code)
|
||||
if organization_code:
|
||||
employee.organization_unit = organizations_by_code[organization_code]
|
||||
else:
|
||||
employee.organization_unit = None
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.employee_seed_roles import ROLE_DEFINITIONS, ROLE_DISPLAY_ORDER, ROLE_PERMISSION_MAP
|
||||
from app.services.employee_seed_organizations import EMPLOYEE_PROFILE_REPAIRS, ORGANIZATION_DEFINITIONS
|
||||
from app.services.employee_seed_organizations import (
|
||||
CANONICAL_DEPARTMENT_CODES,
|
||||
EMPLOYEE_PROFILE_REPAIRS,
|
||||
LEGACY_ORGANIZATION_UNIT_CODE_MAP,
|
||||
ORGANIZATION_DEFINITIONS,
|
||||
normalize_organization_unit_code,
|
||||
)
|
||||
from app.services.employee_seed_part1 import EMPLOYEE_DEFINITIONS_PART_1
|
||||
from app.services.employee_seed_part2 import EMPLOYEE_DEFINITIONS_PART_2
|
||||
|
||||
@@ -11,7 +17,10 @@ __all__ = [
|
||||
"ROLE_DISPLAY_ORDER",
|
||||
"ROLE_DEFINITIONS",
|
||||
"ROLE_PERMISSION_MAP",
|
||||
"CANONICAL_DEPARTMENT_CODES",
|
||||
"ORGANIZATION_DEFINITIONS",
|
||||
"LEGACY_ORGANIZATION_UNIT_CODE_MAP",
|
||||
"EMPLOYEE_PROFILE_REPAIRS",
|
||||
"EMPLOYEE_DEFINITIONS",
|
||||
"normalize_organization_unit_code",
|
||||
]
|
||||
|
||||
@@ -11,88 +11,89 @@ ORGANIZATION_DEFINITIONS = [
|
||||
"manager_name": "李文静",
|
||||
},
|
||||
{
|
||||
"unit_code": "EXEC-OFFICE",
|
||||
"name": "总经办",
|
||||
"unit_code": "TECH-DEPT",
|
||||
"name": "技术部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-1001",
|
||||
"location": "上海",
|
||||
"manager_name": "李文静",
|
||||
},
|
||||
{
|
||||
"unit_code": "FIN-SSC",
|
||||
"name": "财务共享中心",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-2108",
|
||||
"location": "上海",
|
||||
"manager_name": "张晓晴",
|
||||
},
|
||||
{
|
||||
"unit_code": "HR-OD",
|
||||
"name": "人力与组织",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-3206",
|
||||
"location": "杭州",
|
||||
"manager_name": "陈硕",
|
||||
},
|
||||
{
|
||||
"unit_code": "SALES-SOUTH",
|
||||
"name": "华南销售部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-4102",
|
||||
"location": "深圳",
|
||||
"manager_name": "陈嘉",
|
||||
},
|
||||
{
|
||||
"unit_code": "SALES-EAST",
|
||||
"name": "华东销售部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-4108",
|
||||
"location": "上海",
|
||||
"manager_name": "秦墨然",
|
||||
},
|
||||
{
|
||||
"unit_code": "MKT-BRAND",
|
||||
"name": "市场品牌部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-5203",
|
||||
"location": "北京",
|
||||
"manager_name": "刘思雨",
|
||||
},
|
||||
{
|
||||
"unit_code": "RND-CENTER",
|
||||
"name": "产品研发中心",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-6105",
|
||||
"cost_center": "CC-6100",
|
||||
"location": "北京",
|
||||
"manager_name": "吴磊",
|
||||
},
|
||||
{
|
||||
"unit_code": "OPS-ADMIN",
|
||||
"name": "行政采购部",
|
||||
"unit_code": "MARKET-DEPT",
|
||||
"name": "市场部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-7204",
|
||||
"cost_center": "CC-4100",
|
||||
"location": "上海",
|
||||
"manager_name": "刘思雨",
|
||||
},
|
||||
{
|
||||
"unit_code": "FINANCE-DEPT",
|
||||
"name": "财务部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-2100",
|
||||
"location": "上海",
|
||||
"manager_name": "张晓晴",
|
||||
},
|
||||
{
|
||||
"unit_code": "HR-DEPT",
|
||||
"name": "人力资源部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-3200",
|
||||
"location": "杭州",
|
||||
"manager_name": "陈硕",
|
||||
},
|
||||
{
|
||||
"unit_code": "PRODUCTION-DEPT",
|
||||
"name": "生产部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-7200",
|
||||
"location": "南京",
|
||||
"manager_name": "梁雨辰",
|
||||
},
|
||||
{
|
||||
"unit_code": "AUDIT-RISK",
|
||||
"name": "风控与审计部",
|
||||
"unit_code": "PRESIDENT-OFFICE",
|
||||
"name": "总裁办",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-8102",
|
||||
"cost_center": "CC-1000",
|
||||
"location": "上海",
|
||||
"manager_name": "顾承宇",
|
||||
"manager_name": "李文静",
|
||||
},
|
||||
]
|
||||
|
||||
CANONICAL_DEPARTMENT_CODES = (
|
||||
"TECH-DEPT",
|
||||
"MARKET-DEPT",
|
||||
"FINANCE-DEPT",
|
||||
"HR-DEPT",
|
||||
"PRODUCTION-DEPT",
|
||||
"PRESIDENT-OFFICE",
|
||||
)
|
||||
|
||||
LEGACY_ORGANIZATION_UNIT_CODE_MAP = {
|
||||
"RND-CENTER": "TECH-DEPT",
|
||||
"SALES-SOUTH": "MARKET-DEPT",
|
||||
"SALES-EAST": "MARKET-DEPT",
|
||||
"MKT-BRAND": "MARKET-DEPT",
|
||||
"FIN-SSC": "FINANCE-DEPT",
|
||||
"AUDIT-RISK": "FINANCE-DEPT",
|
||||
"HR-OD": "HR-DEPT",
|
||||
"OPS-ADMIN": "PRODUCTION-DEPT",
|
||||
"EXEC-OFFICE": "PRESIDENT-OFFICE",
|
||||
}
|
||||
|
||||
|
||||
def normalize_organization_unit_code(unit_code: str | None) -> str | None:
|
||||
if not unit_code:
|
||||
return unit_code
|
||||
return LEGACY_ORGANIZATION_UNIT_CODE_MAP.get(unit_code, unit_code)
|
||||
|
||||
|
||||
EMPLOYEE_PROFILE_REPAIRS = [
|
||||
{
|
||||
"employee_no": "E90919",
|
||||
@@ -101,7 +102,7 @@ EMPLOYEE_PROFILE_REPAIRS = [
|
||||
"location": "武汉",
|
||||
"position": "财务智能化产品经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6112",
|
||||
|
||||
@@ -12,7 +12,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "上海",
|
||||
"position": "高级财务总监",
|
||||
"grade": "D2",
|
||||
"organization_unit_code": "EXEC-OFFICE",
|
||||
"organization_unit_code": "PRESIDENT-OFFICE",
|
||||
"manager_employee_no": None,
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-1001",
|
||||
@@ -34,7 +34,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "上海",
|
||||
"position": "费用运营经理",
|
||||
"grade": "M3",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2108",
|
||||
@@ -68,7 +68,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "上海",
|
||||
"position": "财务分析师",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2111",
|
||||
@@ -90,7 +90,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "上海",
|
||||
"position": "财务系统专员",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2112",
|
||||
@@ -112,7 +112,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "上海",
|
||||
"position": "差旅合规专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2115",
|
||||
@@ -134,7 +134,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "杭州",
|
||||
"position": "组织发展主管",
|
||||
"grade": "P6",
|
||||
"organization_unit_code": "HR-OD",
|
||||
"organization_unit_code": "HR-DEPT",
|
||||
"manager_employee_no": "E11618",
|
||||
"finance_owner_name": "总部财务BP",
|
||||
"cost_center": "CC-3206",
|
||||
@@ -156,7 +156,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "杭州",
|
||||
"position": "人力资源经理",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "HR-OD",
|
||||
"organization_unit_code": "HR-DEPT",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "总部财务BP",
|
||||
"cost_center": "CC-3201",
|
||||
@@ -178,7 +178,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "杭州",
|
||||
"position": "HRBP",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "HR-OD",
|
||||
"organization_unit_code": "HR-DEPT",
|
||||
"manager_employee_no": "E11618",
|
||||
"finance_owner_name": "总部财务BP",
|
||||
"cost_center": "CC-3208",
|
||||
@@ -200,7 +200,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "北京",
|
||||
"position": "品牌市场经理",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "MKT-BRAND",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "市场财务BP",
|
||||
"cost_center": "CC-5203",
|
||||
@@ -222,7 +222,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "北京",
|
||||
"position": "品牌策划",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "MKT-BRAND",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E11026",
|
||||
"finance_owner_name": "市场财务BP",
|
||||
"cost_center": "CC-5207",
|
||||
@@ -244,7 +244,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "北京",
|
||||
"position": "数字营销专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "MKT-BRAND",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E11026",
|
||||
"finance_owner_name": "市场财务BP",
|
||||
"cost_center": "CC-5209",
|
||||
@@ -266,7 +266,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "深圳",
|
||||
"position": "区域销售经理",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4102",
|
||||
@@ -288,7 +288,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "深圳",
|
||||
"position": "销售运营专家",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E11602",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4106",
|
||||
@@ -310,7 +310,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "深圳",
|
||||
"position": "大客户代表",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E11602",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4109",
|
||||
@@ -332,7 +332,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "深圳",
|
||||
"position": "销售协调专员",
|
||||
"grade": "P3",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E11602",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4112",
|
||||
@@ -354,7 +354,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "北京",
|
||||
"position": "研发平台主管",
|
||||
"grade": "M3",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6105",
|
||||
@@ -376,7 +376,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "北京",
|
||||
"position": "产品经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6112",
|
||||
@@ -398,7 +398,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "北京",
|
||||
"position": "后端工程师",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6114",
|
||||
@@ -420,7 +420,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
|
||||
"location": "北京",
|
||||
"position": "数据工程师",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6116",
|
||||
|
||||
@@ -12,7 +12,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "北京",
|
||||
"position": "测试负责人",
|
||||
"grade": "P6",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6119",
|
||||
@@ -34,7 +34,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "南京",
|
||||
"position": "行政采购专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "OPS-ADMIN",
|
||||
"organization_unit_code": "PRODUCTION-DEPT",
|
||||
"manager_employee_no": "E12653",
|
||||
"finance_owner_name": "行政财务BP",
|
||||
"cost_center": "CC-7204",
|
||||
@@ -56,7 +56,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "南京",
|
||||
"position": "行政运营经理",
|
||||
"grade": "M1",
|
||||
"organization_unit_code": "OPS-ADMIN",
|
||||
"organization_unit_code": "PRODUCTION-DEPT",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "行政财务BP",
|
||||
"cost_center": "CC-7201",
|
||||
@@ -78,7 +78,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "风控审计经理",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "AUDIT-RISK",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-8102",
|
||||
@@ -112,7 +112,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "审计专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "AUDIT-RISK",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E12661",
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-8105",
|
||||
@@ -134,7 +134,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "南京",
|
||||
"position": "采购合规分析师",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "OPS-ADMIN",
|
||||
"organization_unit_code": "PRODUCTION-DEPT",
|
||||
"manager_employee_no": "E12653",
|
||||
"finance_owner_name": "行政财务BP",
|
||||
"cost_center": "CC-7208",
|
||||
@@ -156,7 +156,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "华东销售总监",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "SALES-EAST",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-4108",
|
||||
@@ -178,7 +178,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "重点客户经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "SALES-EAST",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E12067",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-4111",
|
||||
@@ -200,7 +200,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "销售代表",
|
||||
"grade": "P3",
|
||||
"organization_unit_code": "SALES-EAST",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E12067",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-4114",
|
||||
@@ -222,7 +222,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "北京",
|
||||
"position": "数据分析师",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6122",
|
||||
@@ -244,7 +244,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "费用核算专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2118",
|
||||
@@ -266,7 +266,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "预算控制经理",
|
||||
"grade": "M1",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-2120",
|
||||
@@ -288,7 +288,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "深圳",
|
||||
"position": "渠道销售经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E11602",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4116",
|
||||
@@ -310,7 +310,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "北京",
|
||||
"position": "内容运营经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "MKT-BRAND",
|
||||
"organization_unit_code": "MARKET-DEPT",
|
||||
"manager_employee_no": "E11026",
|
||||
"finance_owner_name": "市场财务BP",
|
||||
"cost_center": "CC-5211",
|
||||
@@ -332,7 +332,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "北京",
|
||||
"position": "架构工程师",
|
||||
"grade": "P6",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6125",
|
||||
@@ -354,7 +354,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "南京",
|
||||
"position": "供应商管理专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "OPS-ADMIN",
|
||||
"organization_unit_code": "PRODUCTION-DEPT",
|
||||
"manager_employee_no": "E12653",
|
||||
"finance_owner_name": "行政财务BP",
|
||||
"cost_center": "CC-7210",
|
||||
@@ -376,7 +376,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "风控策略分析师",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "AUDIT-RISK",
|
||||
"organization_unit_code": "FINANCE-DEPT",
|
||||
"manager_employee_no": "E12661",
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-8108",
|
||||
@@ -398,7 +398,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
|
||||
"location": "上海",
|
||||
"position": "合规产品负责人",
|
||||
"grade": "P7",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"organization_unit_code": "TECH-DEPT",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6128",
|
||||
|
||||
@@ -103,7 +103,7 @@ def build_import_template_bytes() -> bytes:
|
||||
("办公地点", "可选。"),
|
||||
("岗位*", "必填。"),
|
||||
("职级*", "必填,例如 P3、P5。"),
|
||||
("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC。"),
|
||||
("部门编码", "可选,须与系统组织编码一致,例如 FINANCE-DEPT。"),
|
||||
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
|
||||
("财务归口", "可选。"),
|
||||
("成本中心", "可选。"),
|
||||
|
||||
53
server/src/app/services/employee_time.py
Normal file
53
server/src/app/services/employee_time.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.services.employee_serialization import format_history_datetime as serialize_history_datetime
|
||||
|
||||
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def normalize_optional_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text_value = value.strip()
|
||||
return text_value or None
|
||||
|
||||
|
||||
def parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def parse_datetime(value: str | datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.strptime(value, "%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def format_date(value: date | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def to_display_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
normalized = value.replace(tzinfo=UTC)
|
||||
else:
|
||||
normalized = value.astimezone(UTC)
|
||||
return normalized.astimezone(DISPLAY_TIMEZONE)
|
||||
|
||||
|
||||
def format_datetime(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return to_display_datetime(value).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def format_history_datetime(value: datetime | None) -> str:
|
||||
return serialize_history_datetime(value, to_display_datetime=to_display_datetime)
|
||||
@@ -17,6 +17,7 @@ ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive", "auditor"}
|
||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||
APPLICATION_ARCHIVED_STAGES = ("审批完成", "申请归档", "completed")
|
||||
|
||||
|
||||
class ExpenseClaimAccessPolicy:
|
||||
@@ -39,9 +40,22 @@ class ExpenseClaimAccessPolicy:
|
||||
def build_archived_claim_condition() -> Any:
|
||||
normalized_status = func.lower(func.coalesce(ExpenseClaim.status, ""))
|
||||
stage = func.coalesce(ExpenseClaim.approval_stage, "")
|
||||
normalized_type = func.lower(func.coalesce(ExpenseClaim.expense_type, ""))
|
||||
claim_no = func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
|
||||
application_condition = or_(
|
||||
claim_no.like("AP-%"),
|
||||
claim_no.like("APP-%"),
|
||||
normalized_type == "application",
|
||||
normalized_type.like("%\\_application", escape="\\"),
|
||||
)
|
||||
return or_(
|
||||
stage == "归档入账",
|
||||
stage == "completed",
|
||||
and_(
|
||||
application_condition,
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
stage.in_(APPLICATION_ARCHIVED_STAGES),
|
||||
),
|
||||
and_(
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
or_(
|
||||
@@ -59,6 +73,27 @@ class ExpenseClaimAccessPolicy:
|
||||
return True
|
||||
return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & CLAIM_DELETE_ROLE_CODES)
|
||||
|
||||
@staticmethod
|
||||
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 {"归档入账", "completed"}:
|
||||
return True
|
||||
normalized_type = str(claim.expense_type or "").strip().lower()
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
is_application_claim = (
|
||||
claim_no.startswith(("AP-", "APP-"))
|
||||
or normalized_type == "application"
|
||||
or normalized_type.endswith("_application")
|
||||
)
|
||||
if (
|
||||
is_application_claim
|
||||
and normalized_status in ARCHIVED_CLAIM_STATUSES
|
||||
and stage in APPLICATION_ARCHIVED_STAGES
|
||||
):
|
||||
return True
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
|
||||
|
||||
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
if self.has_privileged_claim_access(current_user):
|
||||
return True
|
||||
@@ -338,6 +373,7 @@ class ExpenseClaimAccessPolicy:
|
||||
else:
|
||||
add_condition("employee_id", username)
|
||||
add_condition("employee_name", username)
|
||||
add_condition("employee_name", str(current_user.name or "").strip())
|
||||
|
||||
return conditions
|
||||
|
||||
@@ -422,10 +458,14 @@ class ExpenseClaimAccessPolicy:
|
||||
return stmt.where(or_(*conditions))
|
||||
|
||||
def apply_archived_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||
archived_condition = self.build_archived_claim_condition()
|
||||
if not self.has_archive_center_access(current_user):
|
||||
owned_conditions = self.build_personal_claim_conditions(current_user)
|
||||
if not owned_conditions:
|
||||
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
|
||||
return stmt.where(archived_condition, or_(*owned_conditions))
|
||||
|
||||
return stmt.where(self.build_archived_claim_condition())
|
||||
return stmt.where(archived_condition)
|
||||
|
||||
@staticmethod
|
||||
def resolve_claim_manager_name(claim: ExpenseClaim) -> str:
|
||||
|
||||
83
server/src/app/services/expense_claim_application_handoff.py
Normal file
83
server/src/app/services/expense_claim_application_handoff.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
|
||||
|
||||
APPLICATION_REIMBURSEMENT_TYPE_MAP = {
|
||||
"travel_application": "travel",
|
||||
"purchase_application": "office",
|
||||
"meeting_application": "meeting",
|
||||
"expense_application": "other",
|
||||
"application": "other",
|
||||
}
|
||||
|
||||
|
||||
class ExpenseClaimApplicationHandoffMixin:
|
||||
@staticmethod
|
||||
def _resolve_reimbursement_type_from_application(expense_type: str | None) -> str:
|
||||
normalized = str(expense_type or "").strip().lower()
|
||||
if normalized in APPLICATION_REIMBURSEMENT_TYPE_MAP:
|
||||
return APPLICATION_REIMBURSEMENT_TYPE_MAP[normalized]
|
||||
if normalized.endswith("_application"):
|
||||
return normalized.removesuffix("_application") or "other"
|
||||
return normalized or "other"
|
||||
|
||||
def _create_reimbursement_draft_from_application(
|
||||
self,
|
||||
*,
|
||||
application_claim: ExpenseClaim,
|
||||
approval_flag: dict[str, Any],
|
||||
operator: str,
|
||||
) -> ExpenseClaim:
|
||||
occurred_at = application_claim.occurred_at or datetime.now(UTC)
|
||||
created_at = datetime.now(UTC)
|
||||
draft_claim = ExpenseClaim(
|
||||
claim_no=self._generate_claim_no(occurred_at),
|
||||
employee_id=application_claim.employee_id,
|
||||
employee_name=application_claim.employee_name,
|
||||
department_id=application_claim.department_id,
|
||||
department_name=application_claim.department_name,
|
||||
project_code=application_claim.project_code,
|
||||
expense_type=self._resolve_reimbursement_type_from_application(application_claim.expense_type),
|
||||
reason=application_claim.reason,
|
||||
location=application_claim.location,
|
||||
amount=application_claim.amount or Decimal("0.00"),
|
||||
currency=application_claim.currency or "CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=occurred_at,
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "application_handoff",
|
||||
"event_type": "expense_application_to_reimbursement_draft",
|
||||
"handoff_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "申请转报销草稿",
|
||||
"message": (
|
||||
f"费用申请 {application_claim.claim_no} 已由 {operator} 确认审核,"
|
||||
"系统已生成报销草稿。"
|
||||
),
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": application_claim.claim_no,
|
||||
"application_budget_amount": str(application_claim.amount or Decimal("0.00")),
|
||||
"application_approval_event_id": str(approval_flag.get("approval_event_id") or ""),
|
||||
"leader_opinion": str(approval_flag.get("opinion") or "").strip(),
|
||||
"created_at": created_at.isoformat(),
|
||||
}
|
||||
],
|
||||
)
|
||||
self.db.add(draft_claim)
|
||||
self.db.flush()
|
||||
|
||||
approval_flag["generated_draft_claim_id"] = draft_claim.id
|
||||
approval_flag["generated_draft_claim_no"] = draft_claim.claim_no
|
||||
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
|
||||
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}。"
|
||||
return draft_claim
|
||||
@@ -33,6 +33,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.document_intelligence import build_document_insight
|
||||
from app.services.document_numbering import generate_unique_expense_claim_no
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
@@ -250,23 +251,11 @@ class ExpenseClaimDraftPersistenceMixin:
|
||||
)
|
||||
|
||||
def _generate_claim_no(self, occurred_at: datetime) -> str:
|
||||
month_code = occurred_at.strftime("%Y%m")
|
||||
prefix = f"EXP-{month_code}-"
|
||||
existing_claim_nos = list(
|
||||
self.db.scalars(
|
||||
select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{prefix}%"))
|
||||
return generate_unique_expense_claim_no(
|
||||
self.db,
|
||||
"reimbursement",
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
max_suffix = 0
|
||||
for claim_no in existing_claim_nos:
|
||||
normalized = str(claim_no or "").strip()
|
||||
if not normalized.startswith(prefix):
|
||||
continue
|
||||
suffix = normalized[len(prefix):]
|
||||
if not suffix.isdigit():
|
||||
continue
|
||||
max_suffix = max(max_suffix, int(suffix))
|
||||
return f"{prefix}{max_suffix + 1:03d}"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_claim_no_retry_count(context_json: dict[str, Any]) -> int:
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.expense_rule_runtime import (
|
||||
RuntimeTravelPolicy,
|
||||
)
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
@@ -46,7 +47,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
flags.append(flag)
|
||||
severity = str(flag.get("severity") or "").strip().lower()
|
||||
action = str(flag.get("action") or "").strip().lower()
|
||||
if severity == "high" or action == "block":
|
||||
if severity in {"high", "critical"} or action == "block":
|
||||
blocking_reasons.append(str(flag.get("message") or flag.get("label") or "").strip())
|
||||
|
||||
deduplicated_reasons = list(dict.fromkeys(reason for reason in blocking_reasons if reason))
|
||||
@@ -100,6 +101,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
except (FileNotFoundError, ValueError):
|
||||
continue
|
||||
|
||||
payload = normalize_risk_rule_manifest(payload)
|
||||
manifest_code = str(payload.get("rule_code") or rule_code).strip()
|
||||
if not manifest_code or (code_filter and manifest_code not in code_filter):
|
||||
continue
|
||||
@@ -129,6 +131,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
)
|
||||
except (FileNotFoundError, ValueError):
|
||||
continue
|
||||
payload = normalize_risk_rule_manifest(payload)
|
||||
rule_code = str(payload.get("rule_code") or "").strip()
|
||||
if not rule_code or rule_code in manifests_by_code:
|
||||
continue
|
||||
@@ -612,7 +615,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
|
||||
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium"
|
||||
default_action = "block" if severity == "high" else "manual_review"
|
||||
default_action = "block" if severity in {"high", "critical"} else "manual_review"
|
||||
action = str(fail_outcome.get("action") or default_action).strip()
|
||||
label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip()
|
||||
|
||||
|
||||
@@ -33,9 +33,11 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.document_intelligence import build_document_insight
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
||||
from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin
|
||||
from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin
|
||||
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
|
||||
@@ -124,6 +126,7 @@ from app.services.ocr import OcrService
|
||||
|
||||
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
ExpenseClaimReviewPreviewMixin,
|
||||
ExpenseClaimDraftFlowMixin,
|
||||
@@ -153,7 +156,7 @@ class ExpenseClaimService(
|
||||
or ""
|
||||
).strip().lower()
|
||||
return (
|
||||
claim_no.startswith("APP-")
|
||||
is_application_claim_no(claim_no)
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
or document_type in {"application", "expense_application"}
|
||||
@@ -492,9 +495,28 @@ class ExpenseClaimService(
|
||||
|
||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None and (
|
||||
current_user.is_admin or self._access_policy.has_archive_center_access(current_user)
|
||||
):
|
||||
candidate_claim = self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
if candidate_claim is not None and (
|
||||
current_user.is_admin or self._access_policy.is_archived_claim(candidate_claim)
|
||||
):
|
||||
claim = candidate_claim
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
|
||||
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
|
||||
|
||||
if not self._access_policy.has_claim_delete_access(current_user):
|
||||
self._ensure_draft_claim(claim)
|
||||
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
@@ -642,7 +664,7 @@ class ExpenseClaimService(
|
||||
label = "领导审批通过"
|
||||
next_status = "approved"
|
||||
next_stage = "审批完成"
|
||||
default_message = "{operator} 已审批通过,申请流程完成。"
|
||||
default_message = "{operator} 已确认审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
event_type = "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
@@ -663,9 +685,12 @@ class ExpenseClaimService(
|
||||
else:
|
||||
raise ValueError("当前节点不支持审批通过。")
|
||||
|
||||
approval_opinion = str(opinion or "").strip()
|
||||
if previous_stage == "直属领导审批" and not approval_opinion:
|
||||
raise ValueError("领导审核意见不能为空,请填写意见后再确认审核。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
approval_opinion = str(opinion or "").strip()
|
||||
approval_flag = {
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
@@ -692,6 +717,12 @@ class ExpenseClaimService(
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
if is_application_claim and previous_stage == "直属领导审批":
|
||||
generated_draft = self._create_reimbursement_draft_from_application(
|
||||
application_claim=claim,
|
||||
approval_flag=approval_flag,
|
||||
operator=operator,
|
||||
)
|
||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), approval_flag]
|
||||
|
||||
self.db.commit()
|
||||
|
||||
@@ -399,6 +399,10 @@ class OntologyDetectionMixin:
|
||||
"missing_slots 使用简短 snake_case,例如 expense_type, amount, "
|
||||
"customer_name, participants, attachments。"
|
||||
"entity_hints 只填写你比较确定的业务对象;如果不确定,可以返回空数组。"
|
||||
"费用申请场景下,建议把干净的申请事由放入 type=reason,"
|
||||
"把出行方式放入 type=transport_mode,取值优先为飞机、火车、轮船。"
|
||||
"reason 只能保留真实业务目的,例如“服务美团业务部署”,"
|
||||
"不要把发生时间、地点、出差天数、交通方式混进 reason。"
|
||||
)
|
||||
user_prompt = (
|
||||
"请根据以下事实输出 JSON:\n"
|
||||
@@ -415,6 +419,9 @@ class OntologyDetectionMixin:
|
||||
' "entity_hints": [\n'
|
||||
' {"type": "expense_type", "value": "交通费", '
|
||||
'"normalized_value": "transport", "role": "filter", '
|
||||
'"confidence": 0.86},\n'
|
||||
' {"type": "reason", "value": "服务客户业务部署", '
|
||||
'"normalized_value": "服务客户业务部署", "role": "target", '
|
||||
'"confidence": 0.86}\n'
|
||||
" ]\n"
|
||||
"}"
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.schemas.ontology import (
|
||||
OntologyPermission,
|
||||
OntologyTimeRange,
|
||||
)
|
||||
from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN
|
||||
from app.services.ontology_rules import (
|
||||
AMOUNT_PATTERN,
|
||||
DATE_RANGE_PATTERN,
|
||||
@@ -243,7 +244,8 @@ class OntologyExtractionMixin:
|
||||
|
||||
for code in re.findall(r"PRJ-[A-Z]+-\d+", query, flags=re.IGNORECASE):
|
||||
upsert(self._make_entity("project", code, code.upper(), role="filter"))
|
||||
for code in re.findall(r"EXP-\d{6}-\d{3}", query, flags=re.IGNORECASE):
|
||||
for match in DOCUMENT_NUMBER_EXTRACT_PATTERN.finditer(query):
|
||||
code = match.group(0)
|
||||
upsert(self._make_entity("expense_claim", code, code.upper()))
|
||||
for code in re.findall(r"AR-\d{6}-\d{3}", query, flags=re.IGNORECASE):
|
||||
upsert(self._make_entity("receivable", code, code.upper()))
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -25,6 +26,9 @@ class RiskRuleFlowDiagramSpec:
|
||||
basis: str
|
||||
pass_text: str
|
||||
fail_text: str
|
||||
fact_lines: tuple[str, ...] = ()
|
||||
condition_lines: tuple[str, ...] = ()
|
||||
hit_logic: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -66,35 +70,49 @@ class RiskRuleFlowDiagramRenderer:
|
||||
border="#fecaca",
|
||||
surface="#fef2f2",
|
||||
),
|
||||
"critical": RiskRuleFlowDiagramPalette(
|
||||
accent="#991b1b",
|
||||
accent_dark="#7f1d1d",
|
||||
border="#fca5a5",
|
||||
surface="#fff1f2",
|
||||
),
|
||||
}
|
||||
|
||||
def render(self, spec: RiskRuleFlowDiagramSpec) -> str:
|
||||
title = self._truncate(spec.title, 26)
|
||||
palette = self._palette(spec.severity)
|
||||
fact_lines = spec.fact_lines or self._field_lines(spec.fields)
|
||||
condition_lines = spec.condition_lines or (spec.basis,)
|
||||
hit_logic = spec.hit_logic or spec.basis
|
||||
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-labelledby="risk-flow-title risk-flow-desc">
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="860" height="360" viewBox="0 0 860 360" data-risk-flow-style="review-node-only" data-risk-flow-detail="logic-v2" role="img" aria-labelledby="risk-flow-title risk-flow-desc">
|
||||
<title id="risk-flow-title">{self._escape(title)}流程说明</title>
|
||||
<desc id="risk-flow-desc">风险规则只读流程图,展示从业务单据提交到风险复核的判断路径。</desc>
|
||||
<desc id="risk-flow-desc">风险规则只读流程图,展示字段事实、集合交集、日期范围、例外说明和命中路径。</desc>
|
||||
<defs>
|
||||
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="{self._NEUTRAL_LINE}"/>
|
||||
</marker>
|
||||
<marker id="arrow-risk" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="{palette.accent}"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="760" height="280" fill="#ffffff"/>
|
||||
<rect x="18" y="18" width="724" height="244" rx="8" ry="8" fill="none" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<rect width="860" height="360" fill="#ffffff"/>
|
||||
<rect x="18" y="18" width="824" height="324" rx="8" ry="8" fill="none" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="34" y="43" fill="{self._MUTED}" font-family="{self._FONT}" font-size="11" font-weight="500">RULE FLOW</text>
|
||||
{self._node("业务输入", spec.start, 48, 118, 124, 60)}
|
||||
{self._node("字段取数", "读取字段证据", 214, 118, 132, 60)}
|
||||
{self._diamond("判断依据", spec.decision, 392, 92, 112, 112)}
|
||||
{self._node("继续流转", spec.pass_text, 562, 74, 126, 60)}
|
||||
{self._node("进入复核", spec.fail_text, 562, 190, 126, 62, palette=palette)}
|
||||
{self._note(spec.basis, 214, 218, 290, 36)}
|
||||
<line x1="172" y1="148" x2="214" y2="148" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
|
||||
<line x1="346" y1="148" x2="392" y2="148" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
|
||||
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
|
||||
<text x="534" y="119" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="400">否</text>
|
||||
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
|
||||
<text x="534" y="195" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="600">是</text>
|
||||
{self._node("业务输入", spec.start, 38, 142, 120, 62)}
|
||||
{self._panel("字段事实", fact_lines, 196, 64, 240, 128)}
|
||||
{self._panel("判断条件", condition_lines, 196, 216, 382, 104)}
|
||||
{self._diamond("命中逻辑", hit_logic, 494, 80, 122, 122)}
|
||||
{self._node("继续流转", spec.pass_text, 688, 76, 122, 60)}
|
||||
{self._node("进入复核", spec.fail_text, 688, 226, 122, 68, palette=palette)}
|
||||
<path d="M 158 173 H 176 V 128 H 196" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
|
||||
<line x1="316" y1="192" x2="316" y2="216" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" marker-end="url(#arrow-neutral)"/>
|
||||
<path d="M 436 128 H 466 V 141 H 494" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
|
||||
<line x1="555" y1="216" x2="555" y2="202" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" stroke-linecap="round" marker-end="url(#arrow-neutral)"/>
|
||||
<path d="M 616 125 H 648 V 106 H 688" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
|
||||
<text x="651" y="119" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="500">否</text>
|
||||
<path d="M 616 166 H 648 V 260 H 688" fill="none" stroke="{palette.accent}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-risk)"/>
|
||||
<text x="651" y="214" text-anchor="middle" fill="{palette.accent_dark}" font-family="{self._FONT}" font-size="10.5" font-weight="700">是</text>
|
||||
</svg>"""
|
||||
|
||||
def _node(
|
||||
@@ -137,6 +155,28 @@ class RiskRuleFlowDiagramRenderer:
|
||||
{self._text_lines(body_lines, cx, cy + 11, "middle", self._MUTED, 10.2)}
|
||||
</g>"""
|
||||
|
||||
def _panel(
|
||||
self,
|
||||
title: str,
|
||||
lines: tuple[str, ...],
|
||||
x: int,
|
||||
y: int,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> str:
|
||||
visible = [self._truncate(line, 36) for line in list(lines)[:4]]
|
||||
if not visible:
|
||||
visible = ["读取规则字段并归一化为判断事实"]
|
||||
rows = "\n ".join(
|
||||
f'<text x="{x + 16}" y="{y + 48 + index * 18}" fill="{self._TEXT}" font-family="{self._FONT}" font-size="11" font-weight="400">{self._escape(line)}</text>'
|
||||
for index, line in enumerate(visible)
|
||||
)
|
||||
return f"""<g>
|
||||
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1.2"/>
|
||||
<text x="{x + 16}" y="{y + 26}" fill="{self._TEXT}" font-family="{self._FONT}" font-size="13" font-weight="650">{self._escape(title)}</text>
|
||||
{rows}
|
||||
</g>"""
|
||||
|
||||
def _note(
|
||||
self,
|
||||
body: str,
|
||||
@@ -152,6 +192,13 @@ class RiskRuleFlowDiagramRenderer:
|
||||
{self._text_lines(lines, x + 54, y + 22, "start", self._TEXT, 10.2)}
|
||||
</g>"""
|
||||
|
||||
def _field_lines(self, fields: tuple[RiskRuleFlowDiagramField, ...]) -> tuple[str, ...]:
|
||||
rows = []
|
||||
for index, field in enumerate(fields[:4]):
|
||||
label = field.label or field.key
|
||||
rows.append(f"{chr(65 + index)}={label}[{field.key}]")
|
||||
return tuple(rows)
|
||||
|
||||
def _text_lines(
|
||||
self,
|
||||
lines: list[str],
|
||||
@@ -189,3 +236,130 @@ class RiskRuleFlowDiagramRenderer:
|
||||
@classmethod
|
||||
def _palette(cls, severity: str) -> RiskRuleFlowDiagramPalette:
|
||||
return cls._PALETTES.get(str(severity or "").strip().lower(), cls._PALETTES["medium"])
|
||||
|
||||
|
||||
def build_risk_rule_flow_diagram_details(
|
||||
payload: dict[str, Any],
|
||||
fields: list[RiskRuleFlowDiagramField],
|
||||
) -> dict[str, tuple[str, ...] | str]:
|
||||
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
||||
rule_ir = params.get("rule_ir") if isinstance(params.get("rule_ir"), dict) else {}
|
||||
facts = rule_ir.get("facts") if isinstance(rule_ir.get("facts"), list) else []
|
||||
fact_lines = _build_fact_lines(facts, fields)
|
||||
condition_lines = _build_condition_lines(params, fields)
|
||||
hit_logic = _format_hit_logic(params.get("hit_logic")) or str(
|
||||
params.get("formula") or params.get("condition_summary") or ""
|
||||
).strip()
|
||||
return {
|
||||
"fact_lines": tuple(fact_lines),
|
||||
"condition_lines": tuple(condition_lines),
|
||||
"hit_logic": hit_logic,
|
||||
}
|
||||
|
||||
|
||||
def _build_fact_lines(
|
||||
facts: list[Any],
|
||||
fields: list[RiskRuleFlowDiagramField],
|
||||
) -> list[str]:
|
||||
label_by_key = {field.key: field.label or field.key for field in fields}
|
||||
rows: list[str] = []
|
||||
for fact in facts[:4]:
|
||||
if not isinstance(fact, dict):
|
||||
continue
|
||||
fact_id = str(fact.get("id") or "").strip()
|
||||
label = str(fact.get("label") or fact_id or "事实").strip()
|
||||
field_keys = _read_string_list(fact.get("fields"))
|
||||
field_text = "∪".join(label_by_key.get(key, key) for key in field_keys[:3])
|
||||
rows.append(f"{fact_id + '=' if fact_id else ''}{label}: {field_text or '规则字段'}")
|
||||
if rows:
|
||||
return rows
|
||||
return [
|
||||
f"{chr(65 + index)}={field.label or field.key}[{field.key}]"
|
||||
for index, field in enumerate(fields[:4])
|
||||
]
|
||||
|
||||
|
||||
def _build_condition_lines(
|
||||
params: dict[str, Any],
|
||||
fields: list[RiskRuleFlowDiagramField],
|
||||
) -> list[str]:
|
||||
label_by_key = {field.key: field.label or field.key for field in fields}
|
||||
rows: list[str] = []
|
||||
conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
|
||||
for index, condition in enumerate(conditions[:4], start=1):
|
||||
if not isinstance(condition, dict):
|
||||
continue
|
||||
rows.append(_format_condition(condition, label_by_key, index))
|
||||
if rows:
|
||||
return rows
|
||||
summary = str(params.get("condition_summary") or "").strip()
|
||||
return [summary] if summary else []
|
||||
|
||||
|
||||
def _format_condition(condition: dict[str, Any], label_by_key: dict[str, str], index: int) -> str:
|
||||
operator = str(condition.get("operator") or "").strip()
|
||||
condition_id = str(condition.get("id") or f"C{index}").strip()
|
||||
prefix = f"{condition_id}: "
|
||||
if operator in {"not_in_scope", "not_in_set", "not_overlap"}:
|
||||
left = _field_group(condition.get("left_fields"), label_by_key)
|
||||
right = _field_group(condition.get("right_fields"), label_by_key)
|
||||
return f"{prefix}{left} ∩ {right} = ∅"
|
||||
if operator in {"in_scope", "overlap"}:
|
||||
left = _field_group(condition.get("left_fields"), label_by_key)
|
||||
right = _field_group(condition.get("right_fields"), label_by_key)
|
||||
return f"{prefix}{left} ∩ {right} ≠ ∅"
|
||||
if operator == "date_outside_range":
|
||||
dates = _field_group(condition.get("date_fields"), label_by_key)
|
||||
start = _field_group(condition.get("range_start_fields"), label_by_key)
|
||||
end = _field_group(condition.get("range_end_fields"), label_by_key)
|
||||
return f"{prefix}{dates} 不在 [{start}, {end}]"
|
||||
if operator in {"contains_any", "not_contains_any"}:
|
||||
fields = _field_group(condition.get("fields"), label_by_key)
|
||||
keywords = "、".join(_read_string_list(condition.get("keywords"))[:4])
|
||||
verb = "不含" if operator == "not_contains_any" else "包含"
|
||||
return f"{prefix}{fields} {verb} {keywords or '关键词'}"
|
||||
if operator in {"exists_any", "exists_all", "all_present"}:
|
||||
fields = _field_group(condition.get("fields"), label_by_key)
|
||||
verb = "任一有值" if operator == "exists_any" else "全部有值"
|
||||
return f"{prefix}{fields} {verb}"
|
||||
left = str(condition.get("left") or "").strip()
|
||||
right = str(condition.get("right") or "").strip()
|
||||
if left or right:
|
||||
return f"{prefix}{label_by_key.get(left, left)} {operator or 'compare'} {label_by_key.get(right, right)}"
|
||||
return f"{prefix}{operator or '规则条件'}"
|
||||
|
||||
|
||||
def _field_group(value: Any, label_by_key: dict[str, str]) -> str:
|
||||
keys = _read_string_list(value)
|
||||
if not keys:
|
||||
return "字段集合"
|
||||
return "∪".join(label_by_key.get(key, key) for key in keys[:3])
|
||||
|
||||
|
||||
def _format_hit_logic(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, list):
|
||||
return " AND ".join(_format_hit_logic(item) for item in value if _format_hit_logic(item))
|
||||
if not isinstance(value, dict):
|
||||
return ""
|
||||
if isinstance(value.get("all"), list):
|
||||
return " AND ".join(_wrap_logic_part(item) for item in value["all"])
|
||||
if isinstance(value.get("any"), list):
|
||||
return " OR ".join(_wrap_logic_part(item) for item in value["any"])
|
||||
if "not" in value:
|
||||
return f"NOT {_wrap_logic_part(value.get('not'))}"
|
||||
return ""
|
||||
|
||||
|
||||
def _wrap_logic_part(value: Any) -> str:
|
||||
text = _format_hit_logic(value)
|
||||
if isinstance(value, dict) and text:
|
||||
return f"({text})"
|
||||
return text
|
||||
|
||||
|
||||
def _read_string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item or "").strip() for item in value if str(item or "").strip()]
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -14,135 +13,34 @@ from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.expense_type_keywords import EXPENSE_TYPE_LABEL_BY_CODE
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramField,
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
RiskRuleFlowDiagramSpec,
|
||||
build_risk_rule_flow_diagram_details,
|
||||
)
|
||||
from app.services.risk_rule_generation_ontology import (
|
||||
BUSINESS_DOMAIN_LABELS,
|
||||
DOMAIN_FIELD_PREFIXES,
|
||||
EXPENSE_RISK_CATEGORY_ALIASES,
|
||||
EXPENSE_RISK_CATEGORY_LABELS,
|
||||
FIELD_ONTOLOGY,
|
||||
RISK_LEVEL_LABELS,
|
||||
RiskRuleField,
|
||||
)
|
||||
from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
|
||||
from app.services.risk_rule_generation_semantics import (
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPE,
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPES,
|
||||
build_city_consistency_draft,
|
||||
build_city_consistency_params,
|
||||
)
|
||||
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskRuleField:
|
||||
key: str
|
||||
label: str
|
||||
field_type: str
|
||||
source: str
|
||||
aliases: tuple[str, ...]
|
||||
|
||||
|
||||
BUSINESS_DOMAIN_LABELS: dict[str, str] = {
|
||||
AgentAssetDomain.EXPENSE.value: "报销",
|
||||
AgentAssetDomain.AR.value: "应收",
|
||||
AgentAssetDomain.AP.value: "应付",
|
||||
}
|
||||
|
||||
RISK_LEVEL_LABELS: dict[str, str] = {
|
||||
"low": "低风险",
|
||||
"medium": "中风险",
|
||||
"high": "高风险",
|
||||
}
|
||||
|
||||
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"office",
|
||||
"training",
|
||||
"communication",
|
||||
"welfare",
|
||||
)
|
||||
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
|
||||
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
|
||||
}
|
||||
EXPENSE_RISK_CATEGORY_ALIASES = {
|
||||
"entertainment": "meal",
|
||||
}
|
||||
|
||||
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
||||
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
|
||||
RiskRuleField(
|
||||
"claim.location",
|
||||
"申报地点",
|
||||
"text",
|
||||
"claim",
|
||||
("地点", "城市", "出差地", "申报地点", "申报目的地", "目的地"),
|
||||
),
|
||||
RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额", "费用", "超额", "额度")),
|
||||
RiskRuleField("claim.employee_name", "报销人", "text", "claim", ("报销人", "员工", "申请人")),
|
||||
RiskRuleField("claim.department_name", "部门", "text", "claim", ("部门", "组织")),
|
||||
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
|
||||
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),
|
||||
RiskRuleField("item.item_location", "明细地点", "text", "item", ("明细地点", "发生地点")),
|
||||
RiskRuleField(
|
||||
"attachment.invoice_no", "发票号码", "text", "attachment", ("发票号", "发票号码", "票号")
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.buyer_name", "购买方名称", "text", "attachment", ("抬头", "购买方", "开票单位")
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.goods_name",
|
||||
"商品服务名称",
|
||||
"text",
|
||||
"attachment",
|
||||
("品名", "商品", "服务名称", "摘要"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.issue_date",
|
||||
"开票日期",
|
||||
"date",
|
||||
"attachment",
|
||||
("开票日期", "发票日期", "票据日期"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.hotel_city",
|
||||
"住宿城市",
|
||||
"text",
|
||||
"attachment",
|
||||
("住宿城市", "酒店城市", "酒店地点", "酒店发票城市", "酒店票城市", "住宿发票城市"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.route_cities",
|
||||
"行程城市",
|
||||
"list",
|
||||
"attachment",
|
||||
("行程", "路线", "途经城市", "出差城市", "交通票行程", "交通票城市"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.ocr_text",
|
||||
"票据全文",
|
||||
"text",
|
||||
"attachment",
|
||||
("票据内容", "OCR", "全文", "关键字", "关键词"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"receivable.aging_days", "应收账龄", "number", "receivable", ("账龄", "逾期", "应收逾期")
|
||||
),
|
||||
RiskRuleField(
|
||||
"receivable.amount_outstanding",
|
||||
"应收未收金额",
|
||||
"number",
|
||||
"receivable",
|
||||
("未收金额", "欠款", "应收余额"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"payable.vendor_name", "供应商名称", "text", "payable", ("供应商", "付款方", "往来单位")
|
||||
),
|
||||
RiskRuleField(
|
||||
"payable.amount_outstanding", "应付未付金额", "number", "payable", ("未付金额", "应付余额")
|
||||
),
|
||||
)
|
||||
|
||||
DOMAIN_FIELD_PREFIXES: dict[str, tuple[str, ...]] = {
|
||||
AgentAssetDomain.EXPENSE.value: ("claim.", "item.", "attachment."),
|
||||
AgentAssetDomain.AR.value: ("receivable.",),
|
||||
AgentAssetDomain.AP.value: ("payable.",),
|
||||
}
|
||||
|
||||
|
||||
class RiskRuleGenerationService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -172,9 +70,10 @@ class RiskRuleGenerationService:
|
||||
if len(natural_language) < 8:
|
||||
raise ValueError("请至少输入 8 个字的风险规则描述。")
|
||||
|
||||
risk_level = str(body.risk_level or "medium").strip().lower()
|
||||
if risk_level not in RISK_LEVEL_LABELS:
|
||||
raise ValueError("风险等级仅支持 low、medium、high。")
|
||||
rule_title = self._clean_text(body.rule_title)
|
||||
if rule_title and len(rule_title) < 2:
|
||||
raise ValueError("规则标题至少需要 2 个字。")
|
||||
|
||||
requires_attachment = bool(body.requires_attachment)
|
||||
expense_category = self._normalize_expense_category(body.expense_category, domain)
|
||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||
@@ -186,20 +85,30 @@ class RiskRuleGenerationService:
|
||||
domain=domain,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level=risk_level,
|
||||
fields=fields,
|
||||
) or self._build_fallback_draft(
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level=risk_level,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
draft = self._align_draft_fields(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
risk_score = calculate_risk_rule_score(
|
||||
natural_language=natural_language,
|
||||
draft=draft,
|
||||
fields=fields,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
requires_attachment=requires_attachment,
|
||||
)
|
||||
risk_level = str(risk_score["level"])
|
||||
draft = apply_risk_score_to_draft(draft, risk_score)
|
||||
payload = self._build_rule_payload(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
@@ -211,6 +120,8 @@ class RiskRuleGenerationService:
|
||||
created_at=created_at,
|
||||
actor=actor,
|
||||
requires_attachment=requires_attachment,
|
||||
rule_title=rule_title,
|
||||
risk_score=risk_score,
|
||||
)
|
||||
rule_code = str(payload["rule_code"])
|
||||
file_name = f"{rule_code}.json"
|
||||
@@ -236,6 +147,10 @@ class RiskRuleGenerationService:
|
||||
working_version="v0.1.0",
|
||||
config_json={
|
||||
"severity": risk_level,
|
||||
"risk_score": risk_score["score"],
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": risk_score["level_label"],
|
||||
"risk_score_detail": risk_score,
|
||||
"enabled": True,
|
||||
"requires_attachment": requires_attachment,
|
||||
"tag": "风险规则",
|
||||
@@ -260,7 +175,7 @@ class RiskRuleGenerationService:
|
||||
AgentAssetVersion(
|
||||
asset_id=asset.id,
|
||||
version="v0.1.0",
|
||||
content=self._build_version_markdown(payload),
|
||||
content=build_risk_rule_version_markdown(payload),
|
||||
content_type="markdown",
|
||||
change_note="通过自然语言新建风险规则草稿。",
|
||||
created_by=actor,
|
||||
@@ -275,6 +190,7 @@ class RiskRuleGenerationService:
|
||||
after_json={
|
||||
"rule_code": rule_code,
|
||||
"risk_level": risk_level,
|
||||
"risk_score": risk_score["score"],
|
||||
"domain": domain,
|
||||
"expense_category": expense_category,
|
||||
"requires_attachment": requires_attachment,
|
||||
@@ -291,7 +207,6 @@ class RiskRuleGenerationService:
|
||||
domain: str,
|
||||
expense_category: str | None,
|
||||
expense_category_label: str,
|
||||
risk_level: str,
|
||||
fields: list[RiskRuleField],
|
||||
) -> dict[str, Any] | None:
|
||||
field_payload = [
|
||||
@@ -303,50 +218,17 @@ class RiskRuleGenerationService:
|
||||
}
|
||||
for item in fields
|
||||
]
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是 X-Financial 风险规则编译器。只能输出 JSON 对象,不要解释。"
|
||||
"必须从给定字段本体中选择字段,不允许编造字段。"
|
||||
"template_key 只能是 field_required_v1、field_compare_v1、keyword_match_v1。"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"business_domain": domain,
|
||||
"business_domain_label": BUSINESS_DOMAIN_LABELS[domain],
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": expense_category_label,
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": RISK_LEVEL_LABELS[risk_level],
|
||||
"natural_language": natural_language,
|
||||
"available_fields": field_payload,
|
||||
"required_json_shape": {
|
||||
"name": "规则名称",
|
||||
"description": "面向业务用户的说明",
|
||||
"template_key": "field_required_v1",
|
||||
"field_keys": ["claim.reason"],
|
||||
"condition_summary": "判断依据",
|
||||
"keywords": [],
|
||||
"flow": {
|
||||
"start": "提交业务单据",
|
||||
"evidence": "读取字段",
|
||||
"decision": "判断依据",
|
||||
"pass": "继续流转",
|
||||
"fail": "提示风险",
|
||||
},
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
]
|
||||
messages = build_risk_rule_compiler_messages(
|
||||
domain=domain,
|
||||
domain_label=BUSINESS_DOMAIN_LABELS[domain],
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
natural_language=natural_language,
|
||||
available_fields=field_payload,
|
||||
)
|
||||
answer = self.runtime_chat_service.complete(
|
||||
messages,
|
||||
max_tokens=700,
|
||||
max_tokens=1400,
|
||||
temperature=0.1,
|
||||
timeout_seconds=12,
|
||||
max_attempts=1,
|
||||
@@ -370,7 +252,12 @@ class RiskRuleGenerationService:
|
||||
) -> dict[str, Any]:
|
||||
allowed_fields = {item.key for item in fields}
|
||||
template_key = str(payload.get("template_key") or "").strip()
|
||||
if template_key not in {"field_required_v1", "field_compare_v1", "keyword_match_v1"}:
|
||||
if template_key not in {
|
||||
"field_required_v1",
|
||||
"field_compare_v1",
|
||||
"keyword_match_v1",
|
||||
COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
}:
|
||||
template_key = "field_required_v1"
|
||||
|
||||
raw_field_keys = payload.get("field_keys")
|
||||
@@ -389,14 +276,37 @@ class RiskRuleGenerationService:
|
||||
)
|
||||
if str(item or "").strip()
|
||||
]
|
||||
exception_keywords = [
|
||||
str(item or "").strip()
|
||||
for item in (
|
||||
payload.get("exception_keywords")
|
||||
if isinstance(payload.get("exception_keywords"), list)
|
||||
else []
|
||||
)
|
||||
if str(item or "").strip()
|
||||
]
|
||||
unsupported_fields = [
|
||||
str(item or "").strip()
|
||||
for item in (
|
||||
payload.get("unsupported_fields")
|
||||
if isinstance(payload.get("unsupported_fields"), list)
|
||||
else []
|
||||
)
|
||||
if str(item or "").strip()
|
||||
]
|
||||
flow = payload.get("flow") if isinstance(payload.get("flow"), dict) else {}
|
||||
return {
|
||||
rule_ir = payload.get("rule_ir") if isinstance(payload.get("rule_ir"), dict) else {}
|
||||
draft = {
|
||||
"name": self._clean_text(payload.get("name"))[:80],
|
||||
"description": self._clean_text(payload.get("description")),
|
||||
"template_key": template_key,
|
||||
"semantic_type": self._clean_text(payload.get("semantic_type")),
|
||||
"field_keys": field_keys,
|
||||
"condition_summary": self._clean_text(payload.get("condition_summary")),
|
||||
"keywords": keywords[:12],
|
||||
"exception_keywords": exception_keywords[:12],
|
||||
"unsupported_fields": unsupported_fields[:20],
|
||||
"rule_ir": rule_ir,
|
||||
"flow": {
|
||||
"start": self._clean_text(flow.get("start")) or "提交业务单据",
|
||||
"evidence": self._clean_text(flow.get("evidence")) or "读取规则字段",
|
||||
@@ -405,6 +315,18 @@ class RiskRuleGenerationService:
|
||||
"fail": self._clean_text(flow.get("fail")) or "提示风险并进入复核",
|
||||
},
|
||||
}
|
||||
for key in ("conditions", "hit_logic", "field_groups"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, (list, dict)):
|
||||
draft[key] = value
|
||||
scoring_evidence = payload.get("risk_scoring_evidence")
|
||||
if isinstance(scoring_evidence, dict):
|
||||
draft["risk_scoring_evidence"] = scoring_evidence
|
||||
for key in ("formula", "message_template"):
|
||||
value = self._clean_text(payload.get(key))
|
||||
if value:
|
||||
draft[key] = value
|
||||
return draft
|
||||
|
||||
def _build_fallback_draft(
|
||||
self,
|
||||
@@ -457,6 +379,8 @@ class RiskRuleGenerationService:
|
||||
created_at: datetime,
|
||||
actor: str,
|
||||
requires_attachment: bool,
|
||||
rule_title: str = "",
|
||||
risk_score: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
created_stamp = created_at.strftime("%Y%m%d%H%M%S%f")
|
||||
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
|
||||
@@ -472,6 +396,11 @@ class RiskRuleGenerationService:
|
||||
self._clean_text(draft.get("condition_summary")) or "判断是否符合自然语言规则描述"
|
||||
)
|
||||
risk_category = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
|
||||
risk_score_payload = dict(risk_score or {})
|
||||
risk_score_value = int(risk_score_payload.get("score") or 0)
|
||||
risk_level_label = str(
|
||||
risk_score_payload.get("level_label") or RISK_LEVEL_LABELS.get(risk_level, "风险")
|
||||
)
|
||||
keywords = list(draft.get("keywords") or [])
|
||||
field_by_key = {item.key: item for item in fields}
|
||||
params: dict[str, Any] = {
|
||||
@@ -480,9 +409,23 @@ class RiskRuleGenerationService:
|
||||
"condition_summary": condition_summary,
|
||||
"natural_language": natural_language,
|
||||
}
|
||||
semantic_type = str(draft.get("semantic_type") or "").strip()
|
||||
if semantic_type:
|
||||
params["semantic_type"] = semantic_type
|
||||
if template_key == COMPOSITE_RULE_TEMPLATE_KEY and isinstance(draft.get("rule_ir"), dict):
|
||||
params["rule_ir"] = draft["rule_ir"]
|
||||
for key in ("conditions", "hit_logic", "field_groups", "formula", "message_template"):
|
||||
if key in draft:
|
||||
params[key] = draft[key]
|
||||
for key in ("keywords", "exception_keywords", "unsupported_fields"):
|
||||
values = draft.get(key)
|
||||
if isinstance(values, list):
|
||||
params[key] = values
|
||||
if draft.get("semantic_type") == CITY_CONSISTENCY_SEMANTIC_TYPE:
|
||||
params.update(build_city_consistency_params(draft))
|
||||
if template_key == "field_required_v1":
|
||||
params["required_fields"] = field_keys
|
||||
if template_key == "field_compare_v1":
|
||||
if template_key == "field_compare_v1" and "conditions" not in params:
|
||||
params["conditions"] = self._build_compare_conditions(field_keys)
|
||||
if template_key == "keyword_match_v1":
|
||||
params["keywords"] = keywords
|
||||
@@ -494,7 +437,9 @@ class RiskRuleGenerationService:
|
||||
payload = {
|
||||
"schema_version": "2.0",
|
||||
"rule_code": rule_code,
|
||||
"name": self._clean_text(draft.get("name")) or self._infer_rule_name(natural_language),
|
||||
"name": rule_title
|
||||
or self._clean_text(draft.get("name"))
|
||||
or self._infer_rule_name(natural_language),
|
||||
"description": self._clean_text(draft.get("description")) or natural_language,
|
||||
"enabled": True,
|
||||
"requires_attachment": requires_attachment,
|
||||
@@ -503,6 +448,7 @@ class RiskRuleGenerationService:
|
||||
"ontology_signal": "natural_language_risk",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": template_key,
|
||||
"semantic_type": str(draft.get("semantic_type") or "").strip() or None,
|
||||
"applies_to": applies_to,
|
||||
"inputs": {
|
||||
"fields": [
|
||||
@@ -521,6 +467,7 @@ class RiskRuleGenerationService:
|
||||
"fail": {
|
||||
"severity": risk_level,
|
||||
"action": "manual_review",
|
||||
"risk_score": risk_score_value,
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
@@ -530,11 +477,18 @@ class RiskRuleGenerationService:
|
||||
"created_at": created_at.isoformat(),
|
||||
"created_by": actor,
|
||||
"requires_attachment": requires_attachment,
|
||||
"risk_score": risk_score_value,
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": risk_level_label,
|
||||
"risk_score_model": risk_score_payload.get("model"),
|
||||
"risk_score_detail": risk_score_payload,
|
||||
"rule_title": rule_title,
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": expense_category_label,
|
||||
"natural_language": natural_language,
|
||||
"business_explanation": self._clean_text(draft.get("description")),
|
||||
"condition_summary": condition_summary,
|
||||
"rule_ir": draft.get("rule_ir") if isinstance(draft.get("rule_ir"), dict) else {},
|
||||
"flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {},
|
||||
},
|
||||
}
|
||||
@@ -559,15 +513,17 @@ class RiskRuleGenerationService:
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
|
||||
condition_summary = self._clean_text(metadata.get("condition_summary"))
|
||||
diagram_fields = [
|
||||
RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields
|
||||
]
|
||||
details = build_risk_rule_flow_diagram_details(payload, diagram_fields)
|
||||
return self.flow_diagram_renderer.render(
|
||||
RiskRuleFlowDiagramSpec(
|
||||
title=self._clean_text(payload.get("name")) or "风险规则判断流程",
|
||||
domain_label=domain_label or BUSINESS_DOMAIN_LABELS.get(domain, "业务"),
|
||||
severity=risk_level,
|
||||
severity_label=RISK_LEVEL_LABELS.get(risk_level, "中风险"),
|
||||
fields=tuple(
|
||||
RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields
|
||||
),
|
||||
fields=tuple(diagram_fields),
|
||||
start=self._clean_text(flow.get("start")) or "业务单据提交",
|
||||
evidence=self._clean_text(flow.get("evidence")) or "读取规则字段",
|
||||
decision=self._clean_text(flow.get("decision"))
|
||||
@@ -581,6 +537,9 @@ class RiskRuleGenerationService:
|
||||
pass_text=self._clean_text(flow.get("pass")) or "未命中风险,继续流转",
|
||||
fail_text=self._clean_text(flow.get("fail"))
|
||||
or f"命中{RISK_LEVEL_LABELS.get(risk_level, '风险')},进入人工复核",
|
||||
fact_lines=details["fact_lines"],
|
||||
condition_lines=details["condition_lines"],
|
||||
hit_logic=str(details["hit_logic"] or ""),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -615,7 +574,19 @@ class RiskRuleGenerationService:
|
||||
(10, field)
|
||||
for field in candidates
|
||||
if field.key
|
||||
in {"claim.location", "attachment.hotel_city", "attachment.route_cities"}
|
||||
in {
|
||||
"claim.reason",
|
||||
"claim.location",
|
||||
"employee.location",
|
||||
"item.item_date",
|
||||
"item.item_reason",
|
||||
"item.item_location",
|
||||
"attachment.hotel_city",
|
||||
"attachment.route_cities",
|
||||
"attachment.issue_date",
|
||||
"attachment.stay_start_date",
|
||||
"attachment.stay_end_date",
|
||||
}
|
||||
)
|
||||
if any(keyword in text for keyword in ("发票", "票据", "品名", "抬头", "开票")):
|
||||
matched.extend(
|
||||
@@ -639,7 +610,7 @@ class RiskRuleGenerationService:
|
||||
seen.add(field.key)
|
||||
deduped.append(field)
|
||||
if deduped:
|
||||
return deduped[:8]
|
||||
return deduped[:10]
|
||||
return candidates[:4]
|
||||
|
||||
@staticmethod
|
||||
@@ -657,6 +628,14 @@ class RiskRuleGenerationService:
|
||||
term in text for term in ("行程", "交通票", "路线", "途经")
|
||||
):
|
||||
score += 10
|
||||
if field.key in {
|
||||
"claim.trip_start_date",
|
||||
"claim.trip_end_date",
|
||||
"item.item_date",
|
||||
"attachment.stay_start_date",
|
||||
"attachment.stay_end_date",
|
||||
} and any(term in text for term in ("日期", "时间", "出差开始", "出差结束", "入住", "离店")):
|
||||
score += 10
|
||||
if field.key == "claim.location" and any(
|
||||
term in text for term in ("申报目的地", "申报地点", "目的地", "出差地")
|
||||
):
|
||||
@@ -670,14 +649,26 @@ class RiskRuleGenerationService:
|
||||
draft: dict[str, Any],
|
||||
*,
|
||||
natural_language: str,
|
||||
risk_level: str,
|
||||
fields: list[RiskRuleField],
|
||||
) -> dict[str, Any]:
|
||||
if str(draft.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return build_city_consistency_draft(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
fields=fields,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
|
||||
field_by_key = {field.key: field for field in fields}
|
||||
original_keys = [
|
||||
str(item or "").strip()
|
||||
for item in list(draft.get("field_keys") or [])
|
||||
if str(item or "").strip() in field_by_key
|
||||
]
|
||||
if draft.get("template_key") == COMPOSITE_RULE_TEMPLATE_KEY:
|
||||
return {**draft, "field_keys": original_keys or [field.key for field in fields[:8]]}
|
||||
|
||||
preferred_keys: list[str] = []
|
||||
|
||||
def add_preferred(key: str, *terms: str) -> None:
|
||||
@@ -783,40 +774,3 @@ class RiskRuleGenerationService:
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError("JSON object not found.")
|
||||
return normalized[start : end + 1]
|
||||
|
||||
@staticmethod
|
||||
def _build_version_markdown(payload: dict[str, Any]) -> str:
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
fields = (
|
||||
payload.get("inputs", {}).get("fields")
|
||||
if isinstance(payload.get("inputs"), dict)
|
||||
else []
|
||||
)
|
||||
field_labels = [
|
||||
str(item.get("label") or item.get("key") or "").strip()
|
||||
for item in fields
|
||||
if isinstance(item, dict) and str(item.get("label") or item.get("key") or "").strip()
|
||||
]
|
||||
return "\n".join(
|
||||
[
|
||||
f"# {payload.get('name')}",
|
||||
"",
|
||||
"## 业务说明",
|
||||
"",
|
||||
str(payload.get("description") or ""),
|
||||
"",
|
||||
"## 自然语言原文",
|
||||
"",
|
||||
str(metadata.get("natural_language") or ""),
|
||||
"",
|
||||
"## 使用字段",
|
||||
"",
|
||||
"、".join(field_labels) or "未识别字段",
|
||||
"",
|
||||
"## 运行时 JSON",
|
||||
"",
|
||||
"```json",
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
]
|
||||
)
|
||||
|
||||
17
server/src/app/services/risk_rule_generation_interpreter.py
Normal file
17
server/src/app/services/risk_rule_generation_interpreter.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
COMPOSITE_RULE_TEMPLATE_KEY = "composite_rule_v1"
|
||||
|
||||
COMPOSITE_RULE_OPERATORS = {
|
||||
"exists_any",
|
||||
"exists_all",
|
||||
"all_present",
|
||||
"in_scope",
|
||||
"not_in_scope",
|
||||
"not_in_set",
|
||||
"overlap",
|
||||
"not_overlap",
|
||||
"date_outside_range",
|
||||
"contains_any",
|
||||
"not_contains_any",
|
||||
}
|
||||
336
server/src/app/services/risk_rule_generation_jobs.py
Normal file
336
server/src/app/services/risk_rule_generation_jobs.py
Normal file
@@ -0,0 +1,336 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.risk_rule_generation import (
|
||||
BUSINESS_DOMAIN_LABELS,
|
||||
EXPENSE_RISK_CATEGORY_LABELS,
|
||||
RiskRuleGenerationService,
|
||||
)
|
||||
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
|
||||
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
|
||||
class RiskRuleGenerationJobService:
|
||||
"""把自然语言风险规则生成拆成可追踪的后台任务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
rule_library_manager: Any | None = None,
|
||||
runtime_chat_service: RuntimeChatService | None = None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.generator = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=rule_library_manager,
|
||||
runtime_chat_service=runtime_chat_service,
|
||||
)
|
||||
self.audit_service = AuditLogService(db)
|
||||
|
||||
def enqueue_rule_asset_generation(
|
||||
self,
|
||||
body: AgentAssetRiskRuleGenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
domain = self._validate_domain(body)
|
||||
natural_language = self._validate_natural_language(body)
|
||||
rule_title = self._validate_rule_title(body)
|
||||
requires_attachment = bool(body.requires_attachment)
|
||||
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
|
||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||
|
||||
created_at = datetime.now(UTC)
|
||||
rule_code = self._build_placeholder_rule_code(
|
||||
domain=domain,
|
||||
expense_category=expense_category,
|
||||
created_at=created_at,
|
||||
)
|
||||
category_label = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
|
||||
display_name = rule_title or self.generator._infer_rule_name(natural_language)
|
||||
file_name = f"{rule_code}.json"
|
||||
|
||||
asset = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=rule_code,
|
||||
name=display_name,
|
||||
description=natural_language,
|
||||
domain=domain,
|
||||
scenario_json=[category_label],
|
||||
owner=actor,
|
||||
reviewer=None,
|
||||
status=AgentAssetStatus.GENERATING.value,
|
||||
current_version=None,
|
||||
published_version=None,
|
||||
working_version=None,
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"risk_score_status": "pending",
|
||||
"enabled": True,
|
||||
"requires_attachment": requires_attachment,
|
||||
"tag": "风险规则",
|
||||
"detail_mode": "json_risk",
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": expense_category_label,
|
||||
"risk_category": category_label,
|
||||
"rule_library": RISK_RULES_LIBRARY,
|
||||
"rule_document": {
|
||||
"file_name": file_name,
|
||||
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||
},
|
||||
"generated_by": "natural_language",
|
||||
"generation_status": AgentAssetStatus.GENERATING.value,
|
||||
"generation_started_at": created_at.isoformat(),
|
||||
"generation_request": self._dump_generation_request(body),
|
||||
},
|
||||
)
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="enqueue_agent_asset_risk_rule_generation",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=None,
|
||||
after_json={
|
||||
"rule_code": rule_code,
|
||||
"domain": domain,
|
||||
"expense_category": expense_category,
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
self.db.refresh(asset)
|
||||
return asset.id
|
||||
|
||||
def complete_rule_asset_generation(
|
||||
self,
|
||||
asset_id: str,
|
||||
body: AgentAssetRiskRuleGenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
asset = self.db.get(AgentAsset, asset_id)
|
||||
if asset is None or asset.status != AgentAssetStatus.GENERATING.value:
|
||||
return
|
||||
self._complete_rule_asset(asset, body, actor=actor, request_id=request_id)
|
||||
except Exception as exc: # noqa: BLE001 - 后台任务必须把失败写回资产状态
|
||||
self.mark_generation_failed(
|
||||
asset_id,
|
||||
error_message=str(exc) or exc.__class__.__name__,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
def mark_generation_failed(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
error_message: str,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
asset = self.db.get(AgentAsset, asset_id)
|
||||
if asset is None:
|
||||
return
|
||||
|
||||
config_json = dict(asset.config_json or {})
|
||||
config_json.update(
|
||||
{
|
||||
"generation_status": AgentAssetStatus.FAILED.value,
|
||||
"generation_error": error_message[:1000],
|
||||
"generation_failed_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
asset.status = AgentAssetStatus.FAILED.value
|
||||
asset.config_json = config_json
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="fail_agent_asset_risk_rule_generation",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=None,
|
||||
after_json={"generation_error": error_message[:1000]},
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
def _complete_rule_asset(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
body: AgentAssetRiskRuleGenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> None:
|
||||
domain = self._validate_domain(body)
|
||||
natural_language = self._validate_natural_language(body)
|
||||
rule_title = self._validate_rule_title(body)
|
||||
requires_attachment = bool(body.requires_attachment)
|
||||
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
|
||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||
created_at = asset.created_at or datetime.now(UTC)
|
||||
fields = self.generator._resolve_fields(natural_language, domain=domain)
|
||||
|
||||
draft = self.generator._compile_with_model(
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
fields=fields,
|
||||
) or self.generator._build_fallback_draft(
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
draft = self.generator._align_draft_fields(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
risk_score = calculate_risk_rule_score(
|
||||
natural_language=natural_language,
|
||||
draft=draft,
|
||||
fields=fields,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
requires_attachment=requires_attachment,
|
||||
)
|
||||
risk_level = str(risk_score["level"])
|
||||
draft = apply_risk_score_to_draft(draft, risk_score)
|
||||
payload = self.generator._build_rule_payload(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level=risk_level,
|
||||
fields=fields,
|
||||
created_at=created_at,
|
||||
actor=actor,
|
||||
requires_attachment=requires_attachment,
|
||||
rule_title=rule_title,
|
||||
risk_score=risk_score,
|
||||
)
|
||||
rule_code = str(payload["rule_code"])
|
||||
file_name = f"{rule_code}.json"
|
||||
self.generator.rule_library_manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
config_json = {
|
||||
"severity": risk_level,
|
||||
"risk_score": risk_score["score"],
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": risk_score["level_label"],
|
||||
"risk_score_detail": risk_score,
|
||||
"enabled": True,
|
||||
"requires_attachment": requires_attachment,
|
||||
"tag": "风险规则",
|
||||
"detail_mode": "json_risk",
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": expense_category_label,
|
||||
"risk_category": payload.get("risk_category"),
|
||||
"rule_library": RISK_RULES_LIBRARY,
|
||||
"rule_document": {
|
||||
"file_name": file_name,
|
||||
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||
},
|
||||
"ontology_signal": payload.get("ontology_signal"),
|
||||
"evaluator": payload.get("evaluator"),
|
||||
"generated_by": "natural_language",
|
||||
"source_ref": "自然语言风险规则",
|
||||
"generation_status": "completed",
|
||||
"generation_completed_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
asset.code = rule_code
|
||||
asset.name = str(payload["name"])
|
||||
asset.description = str(payload["description"])
|
||||
asset.domain = domain
|
||||
asset.scenario_json = [str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[domain])]
|
||||
asset.status = AgentAssetStatus.DRAFT.value
|
||||
asset.current_version = "v0.1.0"
|
||||
asset.published_version = None
|
||||
asset.working_version = "v0.1.0"
|
||||
asset.config_json = config_json
|
||||
self.db.add(asset)
|
||||
self.db.add(
|
||||
AgentAssetVersion(
|
||||
asset_id=asset.id,
|
||||
version="v0.1.0",
|
||||
content=build_risk_rule_version_markdown(payload),
|
||||
content_type="markdown",
|
||||
change_note="通过自然语言新建风险规则草稿。",
|
||||
created_by=actor,
|
||||
)
|
||||
)
|
||||
self.db.flush()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="complete_agent_asset_risk_rule_generation",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=None,
|
||||
after_json={
|
||||
"rule_code": rule_code,
|
||||
"risk_level": risk_level,
|
||||
"risk_score": risk_score["score"],
|
||||
"domain": domain,
|
||||
"expense_category": expense_category,
|
||||
"requires_attachment": requires_attachment,
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
def _validate_domain(self, body: AgentAssetRiskRuleGenerateRequest) -> str:
|
||||
domain = body.business_domain.value
|
||||
if domain not in BUSINESS_DOMAIN_LABELS:
|
||||
raise ValueError("当前仅支持报销、应收、应付业务域的新建风险规则。")
|
||||
return domain
|
||||
|
||||
def _validate_natural_language(self, body: AgentAssetRiskRuleGenerateRequest) -> str:
|
||||
natural_language = self.generator._clean_text(body.natural_language)
|
||||
if len(natural_language) < 8:
|
||||
raise ValueError("请至少输入 8 个字的风险规则描述。")
|
||||
return natural_language
|
||||
|
||||
def _validate_rule_title(self, body: AgentAssetRiskRuleGenerateRequest) -> str:
|
||||
rule_title = self.generator._clean_text(body.rule_title)
|
||||
if rule_title and len(rule_title) < 2:
|
||||
raise ValueError("规则标题至少需要 2 个字。")
|
||||
return rule_title
|
||||
|
||||
@staticmethod
|
||||
def _build_placeholder_rule_code(
|
||||
*,
|
||||
domain: str,
|
||||
expense_category: str | None,
|
||||
created_at: datetime,
|
||||
) -> str:
|
||||
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
|
||||
category_slug = f".{expense_category}" if expense_category else ""
|
||||
return f"risk.{domain_slug}{category_slug}.generating_{created_at.strftime('%Y%m%d%H%M%S%f')}"
|
||||
|
||||
@staticmethod
|
||||
def _dump_generation_request(body: AgentAssetRiskRuleGenerateRequest) -> dict[str, Any]:
|
||||
return body.model_dump(mode="json")
|
||||
39
server/src/app/services/risk_rule_generation_markdown.py
Normal file
39
server/src/app/services/risk_rule_generation_markdown.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_risk_rule_version_markdown(payload: dict[str, Any]) -> str:
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
fields = (
|
||||
payload.get("inputs", {}).get("fields") if isinstance(payload.get("inputs"), dict) else []
|
||||
)
|
||||
field_labels = [
|
||||
str(item.get("label") or item.get("key") or "").strip()
|
||||
for item in fields
|
||||
if isinstance(item, dict) and str(item.get("label") or item.get("key") or "").strip()
|
||||
]
|
||||
return "\n".join(
|
||||
[
|
||||
f"# {payload.get('name')}",
|
||||
"",
|
||||
"## 业务说明",
|
||||
"",
|
||||
str(payload.get("description") or ""),
|
||||
"",
|
||||
"## 自然语言原文",
|
||||
"",
|
||||
str(metadata.get("natural_language") or ""),
|
||||
"",
|
||||
"## 使用字段",
|
||||
"",
|
||||
"、".join(field_labels) or "未识别字段",
|
||||
"",
|
||||
"## 运行时 JSON",
|
||||
"",
|
||||
"```json",
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
]
|
||||
)
|
||||
163
server/src/app/services/risk_rule_generation_ontology.py
Normal file
163
server/src/app/services/risk_rule_generation_ontology.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain
|
||||
from app.services.expense_type_keywords import EXPENSE_TYPE_LABEL_BY_CODE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskRuleField:
|
||||
key: str
|
||||
label: str
|
||||
field_type: str
|
||||
source: str
|
||||
aliases: tuple[str, ...]
|
||||
|
||||
|
||||
BUSINESS_DOMAIN_LABELS: dict[str, str] = {
|
||||
AgentAssetDomain.EXPENSE.value: "报销",
|
||||
AgentAssetDomain.AR.value: "应收",
|
||||
AgentAssetDomain.AP.value: "应付",
|
||||
}
|
||||
|
||||
RISK_LEVEL_LABELS: dict[str, str] = {
|
||||
"low": "低风险",
|
||||
"medium": "中风险",
|
||||
"high": "高风险",
|
||||
"critical": "极高风险",
|
||||
}
|
||||
|
||||
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"office",
|
||||
"training",
|
||||
"communication",
|
||||
"welfare",
|
||||
)
|
||||
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
|
||||
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
|
||||
}
|
||||
EXPENSE_RISK_CATEGORY_ALIASES = {
|
||||
"entertainment": "meal",
|
||||
}
|
||||
|
||||
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
||||
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
|
||||
RiskRuleField(
|
||||
"claim.location",
|
||||
"申报地点",
|
||||
"text",
|
||||
"claim",
|
||||
("地点", "城市", "出差地", "申报地点", "申报目的地", "目的地"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"claim.trip_start_date",
|
||||
"出差开始日期",
|
||||
"date",
|
||||
"claim",
|
||||
("出差开始", "行程开始", "开始日期", "出差起始", "出发日期"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"claim.trip_end_date",
|
||||
"出差结束日期",
|
||||
"date",
|
||||
"claim",
|
||||
("出差结束", "行程结束", "结束日期", "返程日期", "返回日期"),
|
||||
),
|
||||
RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额", "费用", "超额", "额度")),
|
||||
RiskRuleField("claim.employee_name", "报销人", "text", "claim", ("报销人", "员工", "申请人")),
|
||||
RiskRuleField("claim.department_name", "部门", "text", "claim", ("部门", "组织")),
|
||||
RiskRuleField(
|
||||
"employee.location",
|
||||
"员工常驻地",
|
||||
"text",
|
||||
"employee",
|
||||
("常驻地", "办公地", "员工所在地", "出发地", "所在城市"),
|
||||
),
|
||||
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
|
||||
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),
|
||||
RiskRuleField("item.item_location", "明细地点", "text", "item", ("明细地点", "发生地点")),
|
||||
RiskRuleField("item.item_date", "明细发生日期", "date", "item", ("明细日期", "发生日期", "费用日期")),
|
||||
RiskRuleField(
|
||||
"attachment.invoice_no", "发票号码", "text", "attachment", ("发票号", "发票号码", "票号")
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.buyer_name", "购买方名称", "text", "attachment", ("抬头", "购买方", "开票单位")
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.goods_name",
|
||||
"商品服务名称",
|
||||
"text",
|
||||
"attachment",
|
||||
("品名", "商品", "服务名称", "摘要"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.issue_date",
|
||||
"开票日期",
|
||||
"date",
|
||||
"attachment",
|
||||
("开票日期", "发票日期", "票据日期"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.stay_start_date",
|
||||
"住宿开始日期",
|
||||
"date",
|
||||
"attachment",
|
||||
("入住日期", "住宿开始", "入住时间", "住宿开始日期"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.stay_end_date",
|
||||
"住宿结束日期",
|
||||
"date",
|
||||
"attachment",
|
||||
("离店日期", "退房日期", "住宿结束", "住宿结束日期"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.hotel_city",
|
||||
"住宿城市",
|
||||
"text",
|
||||
"attachment",
|
||||
("住宿城市", "酒店城市", "酒店地点", "酒店发票城市", "酒店票城市", "住宿发票城市"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.route_cities",
|
||||
"行程城市",
|
||||
"list",
|
||||
"attachment",
|
||||
("行程", "路线", "途经城市", "出差城市", "交通票行程", "交通票城市"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"attachment.ocr_text",
|
||||
"票据全文",
|
||||
"text",
|
||||
"attachment",
|
||||
("票据内容", "OCR", "全文", "关键字", "关键词"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"receivable.aging_days", "应收账龄", "number", "receivable", ("账龄", "逾期", "应收逾期")
|
||||
),
|
||||
RiskRuleField(
|
||||
"receivable.amount_outstanding",
|
||||
"应收未收金额",
|
||||
"number",
|
||||
"receivable",
|
||||
("未收金额", "欠款", "应收余额"),
|
||||
),
|
||||
RiskRuleField(
|
||||
"payable.vendor_name", "供应商名称", "text", "payable", ("供应商", "付款方", "往来单位")
|
||||
),
|
||||
RiskRuleField(
|
||||
"payable.amount_outstanding", "应付未付金额", "number", "payable", ("未付金额", "应付余额")
|
||||
),
|
||||
)
|
||||
|
||||
DOMAIN_FIELD_PREFIXES: dict[str, tuple[str, ...]] = {
|
||||
AgentAssetDomain.EXPENSE.value: ("claim.", "item.", "attachment.", "employee."),
|
||||
AgentAssetDomain.AR.value: ("receivable.",),
|
||||
AgentAssetDomain.AP.value: ("payable.",),
|
||||
}
|
||||
147
server/src/app/services/risk_rule_generation_prompt.py
Normal file
147
server/src/app/services/risk_rule_generation_prompt.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_risk_rule_compiler_messages(
|
||||
*,
|
||||
domain: str,
|
||||
domain_label: str,
|
||||
expense_category: str | None,
|
||||
expense_category_label: str,
|
||||
natural_language: str,
|
||||
available_fields: list[dict[str, Any]],
|
||||
) -> list[dict[str, str]]:
|
||||
"""构造自然语言规则编译提示词。
|
||||
|
||||
大模型只负责把业务语言拆成“语义计划”,后端会校验字段、操作符和模板。
|
||||
"""
|
||||
|
||||
schema = {
|
||||
"name": "规则名称,短句",
|
||||
"description": "面向业务和审核人员的说明,不要写实现细节",
|
||||
"template_key": "field_required_v1 | field_compare_v1 | keyword_match_v1 | composite_rule_v1",
|
||||
"semantic_type": (
|
||||
"可选。可用稳定英文短语描述语义类型;"
|
||||
"已知差旅票据城市/路线一致性可使用 travel_route_city_consistency,其他规则按业务含义命名"
|
||||
),
|
||||
"field_keys": ["只能选择 available_fields.key"],
|
||||
"condition_summary": "用公式化语言描述判断依据,不要写'是否出现风险关键词'",
|
||||
"rule_ir": {
|
||||
"facts": "事实变量数组,例如 A=票据事实、B=业务申报事实、E=例外说明",
|
||||
"conditions": "条件数组,必须能被人解释",
|
||||
"hit_logic": "命中逻辑,例如 D AND ((A NOT_IN B) OR DATE_OUTSIDE(T,R)) AND NOT EXCEPTION(E)",
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "稳定英文标识",
|
||||
"operator": (
|
||||
"exists_any | exists_all | in_scope | not_in_scope | overlap | "
|
||||
"not_overlap | date_outside_range | contains_any | not_contains_any"
|
||||
),
|
||||
"fields": ["exists/contains 类操作使用"],
|
||||
"left_fields": ["集合比较左侧字段"],
|
||||
"right_fields": ["集合比较右侧字段"],
|
||||
"date_fields": ["日期字段"],
|
||||
"range_start_fields": ["日期范围开始字段"],
|
||||
"range_end_fields": ["日期范围结束字段"],
|
||||
"keywords": ["例外或风险词"],
|
||||
}
|
||||
],
|
||||
"hit_logic": {"all": ["condition_id", {"any": ["condition_id"]}]},
|
||||
"formula": "可执行逻辑公式,字段使用事实变量表达",
|
||||
"message_template": "命中后的业务提示",
|
||||
"unsupported_fields": ["用户规则提到但 available_fields 中暂时没有的字段"],
|
||||
"keywords": "仅 keyword_match_v1 使用,且必须是真正风险词,不得把例外说明词当风险词",
|
||||
"exception_keywords": "例外说明词,例如绕行、跨城办事、临时改签",
|
||||
"risk_scoring_evidence": {
|
||||
"impact_level": "low | medium | high | critical",
|
||||
"violation_certainty": "low | medium | high | critical",
|
||||
"evidence_strength": "low | medium | high | critical",
|
||||
"exception_dependence": "low | medium | high | critical",
|
||||
"control_action": "remind | supplement | manual_review | return | block",
|
||||
"business_sensitivity": "low | medium | high | critical",
|
||||
"reason": "用一句话说明这些评分证据来自哪些业务语义",
|
||||
},
|
||||
"flow": {
|
||||
"start": "流程起点",
|
||||
"evidence": "读取哪些事实",
|
||||
"decision": "判断公式或分支条件",
|
||||
"pass": "未命中时说明",
|
||||
"fail": "命中时说明",
|
||||
},
|
||||
}
|
||||
guardrails = [
|
||||
"只能输出 JSON 对象,不能输出 Markdown 或解释。",
|
||||
"字段必须来自 available_fields,不能编造字段。",
|
||||
"多步骤规则要使用 composite_rule_v1:先抽取事实变量,再写 conditions 和 hit_logic,不要压扁成单个关键词判断。",
|
||||
"城市/地点/路线一致性必须用 field_compare_v1 或 semantic_type=travel_route_city_consistency。",
|
||||
"涉及多个字段、日期范围、金额范围、集合关系、例外说明的规则必须使用 composite_rule_v1。",
|
||||
"日期字段必须区分事实日期、票据日期和业务期间;如果只能拿到替代字段,要在 rule_ir 中说明这是 fallback evidence。",
|
||||
"composite_rule_v1 只能使用受控 operator:exists_any、exists_all、in_scope、not_in_scope、overlap、not_overlap、date_outside_range、contains_any、not_contains_any。",
|
||||
"差旅路线规则中,交通票行程城市和住宿发票城市属于附件城市集合。",
|
||||
"申报目的地和明细发生地点属于申报行程城市集合。",
|
||||
"员工常驻地/出发地如可用,属于合理起终点集合,不等同于申报目的地。",
|
||||
"绕行、跨城办事、临时改签是例外说明证据,不是风险命中关键词。",
|
||||
"如果票据路线出现申报目的地和常驻地之外的额外城市,应描述为中途周转/绕行异常。",
|
||||
"keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。",
|
||||
"不要直接指定 risk_level 或 risk_score;只输出 risk_scoring_evidence,后端会按固定评分模型计算 0-100 分和风险等级。",
|
||||
"评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。",
|
||||
]
|
||||
examples = [
|
||||
{
|
||||
"user_rule": (
|
||||
"差旅报销时,交通票或住宿票据中的城市均无法与申报目的地、"
|
||||
"明细地点形成一致关系,且事由未说明绕行或改签原因,则高风险。"
|
||||
),
|
||||
"expected": {
|
||||
"template_key": "field_compare_v1",
|
||||
"semantic_type": "travel_route_city_consistency",
|
||||
"field_keys": [
|
||||
"attachment.route_cities",
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"item.item_location",
|
||||
"employee.location",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
],
|
||||
"condition_summary": (
|
||||
"A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
||||
"C=员工常驻地/合理起终点;A与B无交集且无合理说明,或A中出现B∪C之外城市时命中。"
|
||||
),
|
||||
"keywords": [],
|
||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
},
|
||||
}
|
||||
]
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "\n".join(
|
||||
[
|
||||
"你是 X-Financial 风险规则语义编译器。",
|
||||
"你的任务是把自然语言规则转换成可校验 JSON 语义计划。",
|
||||
"后端执行器只接受受控模板和受控字段,所以你必须严格遵守以下约束:",
|
||||
*[f"- {item}" for item in guardrails],
|
||||
]
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"business_domain": domain,
|
||||
"business_domain_label": domain_label,
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": expense_category_label,
|
||||
"natural_language": natural_language,
|
||||
"available_fields": available_fields,
|
||||
"required_json_shape": schema,
|
||||
"examples": examples,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
]
|
||||
119
server/src/app/services/risk_rule_generation_semantics.py
Normal file
119
server/src/app/services/risk_rule_generation_semantics.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
TRAVEL_ROUTE_CITY_SEMANTIC_TYPE = "travel_route_city_consistency"
|
||||
LEGACY_CITY_CONSISTENCY_SEMANTIC_TYPE = "travel_city_consistency"
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPES = {
|
||||
TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
|
||||
LEGACY_CITY_CONSISTENCY_SEMANTIC_TYPE,
|
||||
}
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPE = TRAVEL_ROUTE_CITY_SEMANTIC_TYPE
|
||||
|
||||
RISK_LEVEL_LABELS = {
|
||||
"low": "低风险",
|
||||
"medium": "中风险",
|
||||
"high": "高风险",
|
||||
"critical": "极高风险",
|
||||
}
|
||||
|
||||
CITY_ATTACHMENT_FIELDS = ("attachment.route_cities", "attachment.hotel_city")
|
||||
CITY_REFERENCE_FIELDS = ("claim.location", "item.item_location")
|
||||
CITY_HOME_FIELDS = ("employee.location",)
|
||||
CITY_EXCEPTION_FIELDS = ("claim.reason", "item.item_reason")
|
||||
CITY_EXCEPTION_KEYWORDS = ("绕行", "跨城办事", "跨城", "临时改签", "改签", "变更")
|
||||
|
||||
|
||||
def is_city_consistency_rule(text: str) -> bool:
|
||||
normalized = str(text or "")
|
||||
has_city_subject = any(
|
||||
term in normalized
|
||||
for term in ("交通票", "住宿票", "住宿发票", "票据", "附件", "行程城市", "住宿城市")
|
||||
)
|
||||
has_reference = any(
|
||||
term in normalized
|
||||
for term in ("申报目的地", "申报地点", "明细地点", "发生地点", "意图城市", "目的地")
|
||||
)
|
||||
has_relation = any(
|
||||
term in normalized
|
||||
for term in ("一致", "不一致", "形成一致关系", "匹配", "无法与", "对应")
|
||||
)
|
||||
has_route_anomaly = any(term in normalized for term in ("绕行", "跨城", "中转", "周转", "改签"))
|
||||
return has_city_subject and has_reference and (has_relation or has_route_anomaly)
|
||||
|
||||
|
||||
def build_city_consistency_draft(
|
||||
draft: dict[str, Any],
|
||||
*,
|
||||
natural_language: str,
|
||||
fields: list[Any],
|
||||
risk_level: str,
|
||||
) -> dict[str, Any]:
|
||||
del natural_language
|
||||
field_by_key = {field.key: field for field in fields}
|
||||
field_keys = [
|
||||
key
|
||||
for key in (
|
||||
*CITY_ATTACHMENT_FIELDS,
|
||||
*CITY_REFERENCE_FIELDS,
|
||||
*CITY_HOME_FIELDS,
|
||||
*CITY_EXCEPTION_FIELDS,
|
||||
)
|
||||
if key in field_by_key
|
||||
]
|
||||
risk_label = RISK_LEVEL_LABELS.get(risk_level, "风险")
|
||||
condition_summary = (
|
||||
"判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
||||
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明,"
|
||||
"或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。"
|
||||
)
|
||||
flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {}
|
||||
return {
|
||||
**draft,
|
||||
"template_key": "field_compare_v1",
|
||||
"field_keys": field_keys,
|
||||
"semantic_type": TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
|
||||
"condition_summary": condition_summary,
|
||||
"keywords": [],
|
||||
"exception_keywords": list(CITY_EXCEPTION_KEYWORDS),
|
||||
"flow": {
|
||||
**flow,
|
||||
"start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
|
||||
"evidence": "读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由",
|
||||
"decision": "附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市",
|
||||
"pass": "票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
|
||||
"fail": f"票据路线存在目的地不一致或额外中转城市,命中{risk_label}并要求补充说明或退回修改",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_city_consistency_params(draft: dict[str, Any]) -> dict[str, Any]:
|
||||
exception_keywords = list(draft.get("exception_keywords") or CITY_EXCEPTION_KEYWORDS)
|
||||
return {
|
||||
"semantic_type": TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
|
||||
"attachment_city_fields": list(CITY_ATTACHMENT_FIELDS),
|
||||
"reference_city_fields": list(CITY_REFERENCE_FIELDS),
|
||||
"home_city_fields": list(CITY_HOME_FIELDS),
|
||||
"exception_fields": list(CITY_EXCEPTION_FIELDS),
|
||||
"exception_keywords": exception_keywords,
|
||||
"keywords": [],
|
||||
"route_anomaly_policy": "flag_unexpected_intermediate_cities",
|
||||
"exception_handling": "exception_text_is_evidence_not_auto_pass_for_route_anomaly",
|
||||
"formula": (
|
||||
"A=UNION(attachment.route_cities, attachment.hotel_city); "
|
||||
"B=UNION(claim.location, item.item_location); "
|
||||
"C=UNION(employee.location); "
|
||||
"HIT WHEN (A∩B=∅ AND NOT CONTAINS_ANY(exception_fields, exception_keywords)) "
|
||||
"OR EXISTS(city IN A WHERE city NOT IN B∪C)"
|
||||
),
|
||||
"conditions": [
|
||||
{
|
||||
"left_group": list(CITY_ATTACHMENT_FIELDS),
|
||||
"operator": "route_city_consistency",
|
||||
"right_group": list(CITY_REFERENCE_FIELDS),
|
||||
"home_group": list(CITY_HOME_FIELDS),
|
||||
"exception_fields": list(CITY_EXCEPTION_FIELDS),
|
||||
"exception_keywords": exception_keywords,
|
||||
}
|
||||
],
|
||||
}
|
||||
199
server/src/app/services/risk_rule_manifest_normalizer.py
Normal file
199
server/src/app/services/risk_rule_manifest_normalizer.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramField,
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
RiskRuleFlowDiagramSpec,
|
||||
)
|
||||
from app.services.risk_rule_generation_semantics import (
|
||||
CITY_ATTACHMENT_FIELDS,
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPE,
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPES,
|
||||
CITY_EXCEPTION_FIELDS,
|
||||
CITY_EXCEPTION_KEYWORDS,
|
||||
CITY_HOME_FIELDS,
|
||||
CITY_REFERENCE_FIELDS,
|
||||
build_city_consistency_params,
|
||||
is_city_consistency_rule,
|
||||
)
|
||||
|
||||
RISK_LEVEL_LABELS = {
|
||||
"low": "低风险",
|
||||
"medium": "中风险",
|
||||
"high": "高风险",
|
||||
"critical": "极高风险",
|
||||
}
|
||||
|
||||
CITY_ROUTE_CONDITION_SUMMARY = (
|
||||
"判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
||||
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明,"
|
||||
"或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。"
|
||||
)
|
||||
CITY_ROUTE_FLOW_DECISION = (
|
||||
"附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市"
|
||||
)
|
||||
CITY_ROUTE_FLOW_EVIDENCE = (
|
||||
"读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
|
||||
)
|
||||
|
||||
|
||||
def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把历史误编译的城市一致性规则规范为受控语义 DSL。"""
|
||||
|
||||
if not isinstance(manifest, dict) or not _looks_like_city_consistency_manifest(manifest):
|
||||
return manifest
|
||||
|
||||
payload = dict(manifest)
|
||||
metadata = dict(payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {})
|
||||
params = dict(payload.get("params") if isinstance(payload.get("params"), dict) else {})
|
||||
exception_keywords = _read_string_list(
|
||||
payload.get("exception_keywords") or params.get("exception_keywords")
|
||||
) or list(CITY_EXCEPTION_KEYWORDS)
|
||||
field_keys = _resolve_city_field_keys(payload, params)
|
||||
severity = _resolve_severity(payload)
|
||||
severity_label = RISK_LEVEL_LABELS.get(severity, "中风险")
|
||||
|
||||
payload["template_key"] = "field_compare_v1"
|
||||
payload["semantic_type"] = CITY_CONSISTENCY_SEMANTIC_TYPE
|
||||
payload["keywords"] = []
|
||||
payload["exception_keywords"] = exception_keywords
|
||||
|
||||
params.update(
|
||||
build_city_consistency_params(
|
||||
{
|
||||
"exception_keywords": exception_keywords,
|
||||
}
|
||||
)
|
||||
)
|
||||
params["template_key"] = "field_compare_v1"
|
||||
params["field_keys"] = field_keys
|
||||
params["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
||||
payload["params"] = params
|
||||
|
||||
payload["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
||||
flow = dict(metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {})
|
||||
flow["evidence"] = CITY_ROUTE_FLOW_EVIDENCE
|
||||
flow["decision"] = CITY_ROUTE_FLOW_DECISION
|
||||
flow.setdefault(
|
||||
"start",
|
||||
"差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
|
||||
)
|
||||
flow.setdefault(
|
||||
"pass",
|
||||
"票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
|
||||
)
|
||||
flow["fail"] = (
|
||||
f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改"
|
||||
)
|
||||
metadata["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
||||
metadata["flow"] = flow
|
||||
payload["metadata"] = metadata
|
||||
payload["flow_diagram_svg"] = _build_city_flow_svg(payload, field_keys, severity, severity_label)
|
||||
return payload
|
||||
|
||||
|
||||
def _looks_like_city_consistency_manifest(manifest: dict[str, Any]) -> bool:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
semantic_type = str(manifest.get("semantic_type") or params.get("semantic_type") or "").strip()
|
||||
if semantic_type in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return True
|
||||
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
text = "\n".join(
|
||||
str(value or "")
|
||||
for value in (
|
||||
metadata.get("natural_language"),
|
||||
params.get("natural_language"),
|
||||
manifest.get("description"),
|
||||
metadata.get("condition_summary"),
|
||||
params.get("condition_summary"),
|
||||
)
|
||||
)
|
||||
if is_city_consistency_rule(text):
|
||||
return True
|
||||
|
||||
field_keys = set(_resolve_city_field_keys(manifest, params))
|
||||
has_attachment_city = bool(field_keys & set(CITY_ATTACHMENT_FIELDS))
|
||||
has_reference_city = bool(field_keys & set(CITY_REFERENCE_FIELDS))
|
||||
return has_attachment_city and has_reference_city and "风险关键词" in text
|
||||
|
||||
|
||||
def _resolve_city_field_keys(manifest: dict[str, Any], params: dict[str, Any]) -> list[str]:
|
||||
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
|
||||
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
||||
known = {
|
||||
str(item.get("key") or "").strip()
|
||||
for item in input_fields
|
||||
if isinstance(item, dict) and str(item.get("key") or "").strip()
|
||||
}
|
||||
candidates = [
|
||||
*_read_string_list(manifest.get("field_keys")),
|
||||
*_read_string_list(params.get("field_keys") or params.get("search_fields")),
|
||||
*CITY_ATTACHMENT_FIELDS,
|
||||
*CITY_REFERENCE_FIELDS,
|
||||
*CITY_HOME_FIELDS,
|
||||
*CITY_EXCEPTION_FIELDS,
|
||||
]
|
||||
resolved: list[str] = []
|
||||
for key in candidates:
|
||||
if known and key not in known and key not in {
|
||||
*CITY_ATTACHMENT_FIELDS,
|
||||
*CITY_REFERENCE_FIELDS,
|
||||
*CITY_HOME_FIELDS,
|
||||
*CITY_EXCEPTION_FIELDS,
|
||||
}:
|
||||
continue
|
||||
if key not in resolved:
|
||||
resolved.append(key)
|
||||
return resolved
|
||||
|
||||
|
||||
def _build_city_flow_svg(
|
||||
payload: dict[str, Any],
|
||||
field_keys: list[str],
|
||||
severity: str,
|
||||
severity_label: str,
|
||||
) -> str:
|
||||
inputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
|
||||
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
||||
label_by_key = {
|
||||
str(item.get("key") or "").strip(): str(item.get("label") or item.get("key") or "").strip()
|
||||
for item in input_fields
|
||||
if isinstance(item, dict) and str(item.get("key") or "").strip()
|
||||
}
|
||||
fields = tuple(
|
||||
RiskRuleFlowDiagramField(key=key, label=label_by_key.get(key) or key)
|
||||
for key in field_keys[:8]
|
||||
)
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
|
||||
return RiskRuleFlowDiagramRenderer().render(
|
||||
RiskRuleFlowDiagramSpec(
|
||||
title=str(payload.get("name") or "风险规则判断流程").strip(),
|
||||
domain_label=str(payload.get("risk_category") or "差旅费").strip(),
|
||||
severity=severity,
|
||||
severity_label=severity_label,
|
||||
fields=fields,
|
||||
start=str(flow.get("start") or "差旅报销单据提交").strip(),
|
||||
evidence=CITY_ROUTE_FLOW_EVIDENCE,
|
||||
decision=CITY_ROUTE_FLOW_DECISION,
|
||||
basis=CITY_ROUTE_CONDITION_SUMMARY,
|
||||
pass_text=str(flow.get("pass") or "未命中风险,继续流转").strip(),
|
||||
fail_text=str(flow.get("fail") or f"命中{severity_label},进入人工复核").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_severity(payload: dict[str, Any]) -> str:
|
||||
outcomes = payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {}
|
||||
fail = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||
severity = str(fail.get("severity") or payload.get("severity") or "").strip().lower()
|
||||
return severity if severity in RISK_LEVEL_LABELS else "medium"
|
||||
|
||||
|
||||
def _read_string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item or "").strip() for item in value if str(item or "").strip()]
|
||||
322
server/src/app/services/risk_rule_scoring.py
Normal file
322
server/src/app/services/risk_rule_scoring.py
Normal file
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
RISK_LEVEL_LABELS: dict[str, str] = {
|
||||
"low": "低风险",
|
||||
"medium": "中风险",
|
||||
"high": "高风险",
|
||||
"critical": "极高风险",
|
||||
}
|
||||
|
||||
RISK_SCORE_MODEL_VERSION = "risk_score_v1"
|
||||
|
||||
RISK_SCORE_WEIGHTS: dict[str, float] = {
|
||||
"impact": 0.35,
|
||||
"certainty": 0.25,
|
||||
"evidence": 0.15,
|
||||
"exception": 0.10,
|
||||
"action": 0.10,
|
||||
"sensitivity": 0.05,
|
||||
}
|
||||
|
||||
LEVEL_SCORE_MAP: dict[str, int] = {
|
||||
"none": 0,
|
||||
"very_low": 12,
|
||||
"low": 25,
|
||||
"medium": 55,
|
||||
"high": 78,
|
||||
"critical": 94,
|
||||
}
|
||||
|
||||
LEVEL_ALIASES: dict[str, str] = {
|
||||
"极低": "very_low",
|
||||
"很低": "very_low",
|
||||
"低": "low",
|
||||
"低风险": "low",
|
||||
"中": "medium",
|
||||
"中等": "medium",
|
||||
"中风险": "medium",
|
||||
"高": "high",
|
||||
"高风险": "high",
|
||||
"极高": "critical",
|
||||
"严重": "critical",
|
||||
"重大": "critical",
|
||||
"极高风险": "critical",
|
||||
"very_low": "very_low",
|
||||
"low": "low",
|
||||
"medium": "medium",
|
||||
"high": "high",
|
||||
"critical": "critical",
|
||||
"extreme": "critical",
|
||||
}
|
||||
|
||||
ACTION_SCORE_MAP: dict[str, int] = {
|
||||
"observe": 20,
|
||||
"remind": 35,
|
||||
"supplement": 48,
|
||||
"manual_review": 65,
|
||||
"return": 78,
|
||||
"block": 94,
|
||||
}
|
||||
|
||||
ACTION_ALIASES: dict[str, str] = {
|
||||
"提示": "remind",
|
||||
"提醒": "remind",
|
||||
"补充": "supplement",
|
||||
"补充说明": "supplement",
|
||||
"人工复核": "manual_review",
|
||||
"复核": "manual_review",
|
||||
"审核": "manual_review",
|
||||
"退回": "return",
|
||||
"驳回": "return",
|
||||
"阻断": "block",
|
||||
"禁止": "block",
|
||||
"禁止提交": "block",
|
||||
"observe": "observe",
|
||||
"remind": "remind",
|
||||
"supplement": "supplement",
|
||||
"manual_review": "manual_review",
|
||||
"review": "manual_review",
|
||||
"return": "return",
|
||||
"reject": "return",
|
||||
"block": "block",
|
||||
}
|
||||
|
||||
SENSITIVE_CATEGORY_SCORES: dict[str, int] = {
|
||||
"travel": 70,
|
||||
"hotel": 76,
|
||||
"transport": 68,
|
||||
"meal": 72,
|
||||
"meeting": 58,
|
||||
"training": 48,
|
||||
"communication": 36,
|
||||
"office": 30,
|
||||
"welfare": 42,
|
||||
}
|
||||
|
||||
|
||||
def calculate_risk_rule_score(
|
||||
*,
|
||||
natural_language: str,
|
||||
draft: dict[str, Any],
|
||||
fields: list[Any],
|
||||
expense_category: str | None,
|
||||
expense_category_label: str,
|
||||
requires_attachment: bool,
|
||||
) -> dict[str, Any]:
|
||||
evidence = _read_scoring_evidence(draft)
|
||||
text = _join_text(
|
||||
natural_language,
|
||||
draft.get("description"),
|
||||
draft.get("condition_summary"),
|
||||
draft.get("formula"),
|
||||
draft.get("message_template"),
|
||||
)
|
||||
template_key = str(draft.get("template_key") or "").strip()
|
||||
field_keys = _read_string_list(draft.get("field_keys"))
|
||||
condition_count = len(draft.get("conditions") if isinstance(draft.get("conditions"), list) else [])
|
||||
|
||||
components = {
|
||||
"impact": _component_score(
|
||||
evidence.get("impact_level"),
|
||||
_infer_impact_score(text, template_key=template_key),
|
||||
),
|
||||
"certainty": _component_score(
|
||||
evidence.get("violation_certainty"),
|
||||
_infer_certainty_score(template_key=template_key, condition_count=condition_count),
|
||||
),
|
||||
"evidence": _component_score(
|
||||
evidence.get("evidence_strength"),
|
||||
_infer_evidence_score(field_keys, fields=fields, requires_attachment=requires_attachment),
|
||||
),
|
||||
"exception": _component_score(
|
||||
evidence.get("exception_dependence"),
|
||||
_infer_exception_score(text, draft),
|
||||
),
|
||||
"action": _action_score(
|
||||
evidence.get("control_action"),
|
||||
_infer_action_score(text, draft),
|
||||
),
|
||||
"sensitivity": _component_score(
|
||||
evidence.get("business_sensitivity"),
|
||||
_infer_sensitivity_score(text, expense_category=expense_category),
|
||||
),
|
||||
}
|
||||
score = _clamp_score(
|
||||
round(sum(components[key] * RISK_SCORE_WEIGHTS[key] for key in RISK_SCORE_WEIGHTS))
|
||||
)
|
||||
level = risk_level_from_score(score)
|
||||
return {
|
||||
"score": score,
|
||||
"level": level,
|
||||
"level_label": RISK_LEVEL_LABELS[level],
|
||||
"model": RISK_SCORE_MODEL_VERSION,
|
||||
"weights": RISK_SCORE_WEIGHTS,
|
||||
"components": components,
|
||||
"ai_evidence": evidence,
|
||||
"basis": {
|
||||
"template_key": template_key,
|
||||
"field_count": len(field_keys),
|
||||
"condition_count": condition_count,
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": expense_category_label,
|
||||
"requires_attachment": requires_attachment,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_risk_score_to_draft(draft: dict[str, Any], score_result: dict[str, Any]) -> dict[str, Any]:
|
||||
level_label = str(score_result.get("level_label") or RISK_LEVEL_LABELS["medium"]).strip()
|
||||
updated = dict(draft)
|
||||
flow = dict(updated.get("flow") if isinstance(updated.get("flow"), dict) else {})
|
||||
flow["fail"] = _replace_or_append_risk_label(
|
||||
str(flow.get("fail") or "命中风险,进入人工复核"),
|
||||
level_label,
|
||||
)
|
||||
updated["flow"] = flow
|
||||
description = str(updated.get("description") or "").strip()
|
||||
if description:
|
||||
updated["description"] = _replace_or_append_risk_label(description, level_label)
|
||||
updated["risk_scoring_evidence"] = score_result.get("ai_evidence") or {}
|
||||
return updated
|
||||
|
||||
|
||||
def risk_level_from_score(score: int | float) -> str:
|
||||
normalized = _clamp_score(round(float(score or 0)))
|
||||
if normalized <= 30:
|
||||
return "low"
|
||||
if normalized <= 60:
|
||||
return "medium"
|
||||
if normalized <= 80:
|
||||
return "high"
|
||||
return "critical"
|
||||
|
||||
|
||||
def _read_scoring_evidence(draft: dict[str, Any]) -> dict[str, Any]:
|
||||
value = draft.get("risk_scoring_evidence")
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _component_score(value: Any, fallback: int) -> int:
|
||||
normalized = LEVEL_ALIASES.get(str(value or "").strip().lower())
|
||||
if normalized:
|
||||
return LEVEL_SCORE_MAP[normalized]
|
||||
normalized = LEVEL_ALIASES.get(str(value or "").strip())
|
||||
if normalized:
|
||||
return LEVEL_SCORE_MAP[normalized]
|
||||
return _clamp_score(fallback)
|
||||
|
||||
|
||||
def _action_score(value: Any, fallback: int) -> int:
|
||||
normalized = ACTION_ALIASES.get(str(value or "").strip().lower())
|
||||
if normalized:
|
||||
return ACTION_SCORE_MAP[normalized]
|
||||
normalized = ACTION_ALIASES.get(str(value or "").strip())
|
||||
if normalized:
|
||||
return ACTION_SCORE_MAP[normalized]
|
||||
return _clamp_score(fallback)
|
||||
|
||||
|
||||
def _infer_impact_score(text: str, *, template_key: str) -> int:
|
||||
if _contains_any(text, "伪造", "虚假", "重复报销", "骗取", "套取", "假票", "发票重复"):
|
||||
return 94
|
||||
if _contains_any(text, "禁止", "阻断", "退回", "超预算", "超标准", "高风险", "不一致"):
|
||||
return 78
|
||||
if template_key in {"field_compare_v1", "composite_rule_v1"}:
|
||||
return 70
|
||||
if _contains_any(text, "缺少", "未上传", "不完整", "补充"):
|
||||
return 48
|
||||
return 42
|
||||
|
||||
|
||||
def _infer_certainty_score(*, template_key: str, condition_count: int) -> int:
|
||||
if template_key == "composite_rule_v1":
|
||||
return 84 if condition_count >= 2 else 72
|
||||
if template_key == "field_compare_v1":
|
||||
return 80
|
||||
if template_key == "field_required_v1":
|
||||
return 86
|
||||
if template_key == "keyword_match_v1":
|
||||
return 58
|
||||
return 55
|
||||
|
||||
|
||||
def _infer_evidence_score(
|
||||
field_keys: list[str],
|
||||
*,
|
||||
fields: list[Any],
|
||||
requires_attachment: bool,
|
||||
) -> int:
|
||||
keys = set(field_keys)
|
||||
if any(key.startswith("attachment.") for key in keys) or requires_attachment:
|
||||
return 82
|
||||
field_by_key = {str(getattr(field, "key", "") or "").strip(): field for field in fields}
|
||||
if any(str(getattr(field_by_key.get(key), "source", "") or "") == "claim" for key in keys):
|
||||
return 62
|
||||
return 50
|
||||
|
||||
|
||||
def _infer_exception_score(text: str, draft: dict[str, Any]) -> int:
|
||||
exception_keywords = _read_string_list(draft.get("exception_keywords"))
|
||||
if exception_keywords or _contains_any(text, "无说明", "没有说明", "未说明", "补充说明", "合理说明"):
|
||||
return 66
|
||||
if _contains_any(text, "人工判断", "疑似", "可能"):
|
||||
return 74
|
||||
return 35
|
||||
|
||||
|
||||
def _infer_action_score(text: str, draft: dict[str, Any]) -> int:
|
||||
flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {}
|
||||
corpus = _join_text(text, flow.get("fail"), draft.get("message_template"))
|
||||
if _contains_any(corpus, "禁止提交", "阻断", "不允许提交", "拦截"):
|
||||
return 94
|
||||
if _contains_any(corpus, "退回", "驳回", "重新提交"):
|
||||
return 78
|
||||
if _contains_any(corpus, "人工复核", "复核", "审核"):
|
||||
return 65
|
||||
if _contains_any(corpus, "补充", "说明"):
|
||||
return 48
|
||||
return 35
|
||||
|
||||
|
||||
def _infer_sensitivity_score(text: str, *, expense_category: str | None) -> int:
|
||||
if _contains_any(text, "发票", "票据", "虚假", "重复报销", "伪造"):
|
||||
return 88
|
||||
if expense_category in SENSITIVE_CATEGORY_SCORES:
|
||||
return SENSITIVE_CATEGORY_SCORES[expense_category or ""]
|
||||
if _contains_any(text, "差旅", "住宿", "招待", "交通"):
|
||||
return 72
|
||||
return 45
|
||||
|
||||
|
||||
def _replace_or_append_risk_label(value: str, level_label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return f"命中{level_label},进入人工复核"
|
||||
replaced = re.sub(r"(低风险|中风险|高风险|极高风险)", level_label, normalized)
|
||||
if replaced != normalized:
|
||||
return replaced
|
||||
if "风险" in normalized:
|
||||
return normalized
|
||||
return f"{normalized}({level_label})"
|
||||
|
||||
|
||||
def _read_string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item or "").strip() for item in value if str(item or "").strip()]
|
||||
|
||||
|
||||
def _contains_any(text: str, *keywords: str) -> bool:
|
||||
return any(keyword and keyword in text for keyword in keywords)
|
||||
|
||||
|
||||
def _join_text(*parts: Any) -> str:
|
||||
return "\n".join(str(part or "") for part in parts if str(part or "").strip())
|
||||
|
||||
|
||||
def _clamp_score(value: int | float) -> int:
|
||||
return max(0, min(100, int(round(float(value)))))
|
||||
@@ -1,9 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPES = {
|
||||
"travel_city_consistency",
|
||||
"travel_route_city_consistency",
|
||||
}
|
||||
|
||||
|
||||
class RiskRuleTemplateExecutor:
|
||||
@@ -20,9 +27,17 @@ class RiskRuleTemplateExecutor:
|
||||
if template_key == "field_required_v1":
|
||||
return self._evaluate_required_fields(params, claim=claim, contexts=contexts)
|
||||
if template_key == "field_compare_v1":
|
||||
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return self._evaluate_city_consistency_rule(
|
||||
params,
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts)
|
||||
if template_key == "keyword_match_v1":
|
||||
return self._evaluate_keyword_match(params, claim=claim, contexts=contexts)
|
||||
if template_key == COMPOSITE_RULE_TEMPLATE_KEY:
|
||||
return self._evaluate_composite_rule(params, claim=claim, contexts=contexts)
|
||||
return None
|
||||
|
||||
def _evaluate_required_fields(
|
||||
@@ -105,6 +120,13 @@ class RiskRuleTemplateExecutor:
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
if self._looks_like_city_consistency_rule(params):
|
||||
return self._evaluate_city_consistency_rule(
|
||||
params,
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
|
||||
keywords = self._read_string_list(params.get("keywords"))
|
||||
search_fields = self._read_string_list(
|
||||
params.get("search_fields") or params.get("field_keys")
|
||||
@@ -140,6 +162,331 @@ class RiskRuleTemplateExecutor:
|
||||
},
|
||||
}
|
||||
|
||||
def _evaluate_city_consistency_rule(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
field_keys = self._read_string_list(params.get("search_fields") or params.get("field_keys"))
|
||||
reference_keys = self._read_string_list(params.get("reference_city_fields")) or [
|
||||
key for key in field_keys if key in {"claim.location", "item.item_location"}
|
||||
] or ["claim.location", "item.item_location"]
|
||||
attachment_keys = self._read_string_list(params.get("attachment_city_fields")) or [
|
||||
key
|
||||
for key in field_keys
|
||||
if key in {"attachment.route_cities", "attachment.hotel_city"}
|
||||
] or ["attachment.route_cities", "attachment.hotel_city"]
|
||||
home_keys = self._read_string_list(params.get("home_city_fields")) or ["employee.location"]
|
||||
|
||||
reference_values: list[str] = []
|
||||
attachment_values: list[str] = []
|
||||
home_values: list[str] = []
|
||||
route_values: list[str] = []
|
||||
for key in reference_keys:
|
||||
reference_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
|
||||
for key in attachment_keys:
|
||||
resolved = self._resolve_values(key, claim=claim, contexts=contexts)
|
||||
attachment_values.extend(resolved)
|
||||
if key == "attachment.route_cities":
|
||||
route_values.extend(resolved)
|
||||
for key in home_keys:
|
||||
home_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
|
||||
|
||||
reference_values = self._dedupe_values(reference_values)
|
||||
attachment_values = self._dedupe_values(attachment_values)
|
||||
home_values = self._dedupe_values(home_values)
|
||||
route_values = self._dedupe_values(route_values)
|
||||
if not reference_values or not attachment_values:
|
||||
return None
|
||||
|
||||
explanation_keywords = self._read_string_list(
|
||||
params.get("exception_keywords") or params.get("keywords")
|
||||
)
|
||||
exception_fields = self._read_string_list(params.get("exception_fields")) or [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
]
|
||||
explanation_corpus = "\n".join(
|
||||
value
|
||||
for key in exception_fields
|
||||
for value in self._resolve_values(key, claim=claim, contexts=contexts)
|
||||
)
|
||||
keyword_hits = [
|
||||
keyword
|
||||
for keyword in explanation_keywords
|
||||
if keyword and keyword in explanation_corpus
|
||||
]
|
||||
unexpected_route_cities = self._resolve_unexpected_route_cities(
|
||||
route_values,
|
||||
reference_values=reference_values,
|
||||
home_values=home_values,
|
||||
)
|
||||
has_destination_overlap = self._condition_passes(
|
||||
"overlap",
|
||||
attachment_values,
|
||||
reference_values,
|
||||
)
|
||||
if not unexpected_route_cities and (has_destination_overlap or keyword_hits):
|
||||
return None
|
||||
|
||||
reason = (
|
||||
"票据路线包含申报行程和常驻地之外的中转城市。"
|
||||
if unexpected_route_cities
|
||||
else "票据城市与申报目的地或明细地点不一致,且未说明绕行、跨城或改签原因。"
|
||||
)
|
||||
return {
|
||||
"message": self._resolve_message(
|
||||
params,
|
||||
fallback=reason,
|
||||
),
|
||||
"evidence": {
|
||||
"failed_conditions": [
|
||||
{
|
||||
"left": "attachment.city",
|
||||
"operator": "overlap",
|
||||
"right": "claim.location",
|
||||
"left_values": attachment_values[:5],
|
||||
"right_values": reference_values[:5],
|
||||
}
|
||||
],
|
||||
"condition_summary": params.get("condition_summary"),
|
||||
"formula": params.get("formula"),
|
||||
"city_consistency": {
|
||||
"attachment_values": attachment_values[:8],
|
||||
"reference_values": reference_values[:8],
|
||||
"home_values": home_values[:8],
|
||||
"route_values": route_values[:8],
|
||||
"unexpected_route_cities": unexpected_route_cities[:8],
|
||||
"explanation_keywords": explanation_keywords[:8],
|
||||
"explanation_hits": keyword_hits[:8],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _evaluate_composite_rule(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
|
||||
condition_evidence: list[dict[str, Any]] = []
|
||||
condition_results: dict[str, bool] = {}
|
||||
for index, condition in enumerate(conditions):
|
||||
if not isinstance(condition, dict):
|
||||
continue
|
||||
condition_id = str(condition.get("id") or f"condition_{index + 1}").strip()
|
||||
passed, evidence = self._evaluate_composite_condition(
|
||||
condition,
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
condition_results[condition_id] = passed
|
||||
condition_evidence.append({"id": condition_id, **evidence, "passed": passed})
|
||||
|
||||
hit_logic = params.get("hit_logic")
|
||||
hit = (
|
||||
self._evaluate_logic_node(hit_logic, condition_results)
|
||||
if isinstance(hit_logic, (dict, list, str))
|
||||
else bool(condition_results) and all(condition_results.values())
|
||||
)
|
||||
if not hit:
|
||||
return None
|
||||
|
||||
return {
|
||||
"message": self._resolve_message(
|
||||
params,
|
||||
fallback=str(params.get("condition_summary") or "复合规则条件命中,进入人工复核。"),
|
||||
),
|
||||
"evidence": {
|
||||
"condition_summary": params.get("condition_summary"),
|
||||
"formula": params.get("formula"),
|
||||
"semantic_type": params.get("semantic_type"),
|
||||
"conditions": condition_evidence,
|
||||
"condition_results": condition_results,
|
||||
"hit_logic": hit_logic,
|
||||
"rule_ir": params.get("rule_ir") if isinstance(params.get("rule_ir"), dict) else {},
|
||||
},
|
||||
}
|
||||
|
||||
def _evaluate_composite_condition(
|
||||
self,
|
||||
condition: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> tuple[bool, dict[str, Any]]:
|
||||
operator = str(condition.get("operator") or "").strip()
|
||||
fields = self._read_string_list(condition.get("fields"))
|
||||
left_fields = self._read_string_list(condition.get("left_fields"))
|
||||
right_fields = self._read_string_list(condition.get("right_fields"))
|
||||
if operator == "exists_any":
|
||||
values = self._resolve_group_values(fields, claim=claim, contexts=contexts)
|
||||
return bool(values), {"operator": operator, "fields": fields, "values": values[:8]}
|
||||
if operator in {"exists_all", "all_present"}:
|
||||
missing = [
|
||||
key for key in fields if not self._resolve_values(key, claim=claim, contexts=contexts)
|
||||
]
|
||||
return not missing, {"operator": operator, "fields": fields, "missing_fields": missing}
|
||||
if operator in {"not_in_scope", "not_in_set", "not_overlap"}:
|
||||
left_values = self._resolve_group_values(left_fields, claim=claim, contexts=contexts)
|
||||
right_values = self._resolve_group_values(right_fields, claim=claim, contexts=contexts)
|
||||
matched = self._values_overlap(left_values, right_values)
|
||||
return bool(left_values and right_values and not matched), {
|
||||
"operator": operator,
|
||||
"left_fields": left_fields,
|
||||
"right_fields": right_fields,
|
||||
"left_values": left_values[:8],
|
||||
"right_values": right_values[:8],
|
||||
}
|
||||
if operator in {"in_scope", "overlap"}:
|
||||
left_values = self._resolve_group_values(left_fields, claim=claim, contexts=contexts)
|
||||
right_values = self._resolve_group_values(right_fields, claim=claim, contexts=contexts)
|
||||
return self._values_overlap(left_values, right_values), {
|
||||
"operator": operator,
|
||||
"left_fields": left_fields,
|
||||
"right_fields": right_fields,
|
||||
"left_values": left_values[:8],
|
||||
"right_values": right_values[:8],
|
||||
}
|
||||
if operator == "date_outside_range":
|
||||
return self._evaluate_date_outside_range(condition, claim=claim, contexts=contexts)
|
||||
if operator in {"not_contains_any", "contains_any"}:
|
||||
keywords = self._read_string_list(condition.get("keywords"))
|
||||
values = self._resolve_group_values(fields, claim=claim, contexts=contexts)
|
||||
corpus = "\n".join(values)
|
||||
hits = [keyword for keyword in keywords if keyword and keyword in corpus]
|
||||
passed = not hits if operator == "not_contains_any" else bool(hits)
|
||||
return passed, {
|
||||
"operator": operator,
|
||||
"fields": fields,
|
||||
"keyword_hits": hits[:8],
|
||||
"values": values[:8],
|
||||
}
|
||||
left = str(condition.get("left") or "").strip()
|
||||
right = str(condition.get("right") or "").strip()
|
||||
if left:
|
||||
left_values = self._resolve_values(left, claim=claim, contexts=contexts)
|
||||
right_values = self._resolve_values(right, claim=claim, contexts=contexts) if right else []
|
||||
passed = self._condition_passes(operator or "overlap", left_values, right_values)
|
||||
return passed, {
|
||||
"operator": operator or "overlap",
|
||||
"left": left,
|
||||
"right": right,
|
||||
"left_values": left_values[:8],
|
||||
"right_values": right_values[:8],
|
||||
}
|
||||
return False, {"operator": operator or "unknown"}
|
||||
|
||||
def _evaluate_date_outside_range(
|
||||
self,
|
||||
condition: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> tuple[bool, dict[str, Any]]:
|
||||
date_fields = self._read_string_list(condition.get("date_fields"))
|
||||
start_fields = self._read_string_list(condition.get("range_start_fields"))
|
||||
end_fields = self._read_string_list(condition.get("range_end_fields"))
|
||||
tolerance_days = int(condition.get("tolerance_days") or 0)
|
||||
dates = self._resolve_group_dates(date_fields, claim=claim, contexts=contexts)
|
||||
starts = self._resolve_group_dates(start_fields, claim=claim, contexts=contexts)
|
||||
ends = self._resolve_group_dates(end_fields, claim=claim, contexts=contexts)
|
||||
if not dates or not (starts or ends):
|
||||
return False, {
|
||||
"operator": "date_outside_range",
|
||||
"date_fields": date_fields,
|
||||
"range_start_fields": start_fields,
|
||||
"range_end_fields": end_fields,
|
||||
"dates": [item.isoformat() for item in dates],
|
||||
"range_start": None,
|
||||
"range_end": None,
|
||||
}
|
||||
start = min(starts or ends) - timedelta(days=tolerance_days)
|
||||
end = max(ends or starts) + timedelta(days=tolerance_days)
|
||||
outside = [item for item in dates if item < start or item > end]
|
||||
return bool(outside), {
|
||||
"operator": "date_outside_range",
|
||||
"date_fields": date_fields,
|
||||
"range_start_fields": start_fields,
|
||||
"range_end_fields": end_fields,
|
||||
"dates": [item.isoformat() for item in dates],
|
||||
"range_start": start.isoformat(),
|
||||
"range_end": end.isoformat(),
|
||||
"outside_dates": [item.isoformat() for item in outside],
|
||||
}
|
||||
|
||||
def _resolve_group_values(
|
||||
self,
|
||||
field_keys: list[str],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
values: list[str] = []
|
||||
for key in field_keys:
|
||||
values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
|
||||
return self._dedupe_values(values)
|
||||
|
||||
def _resolve_group_dates(
|
||||
self,
|
||||
field_keys: list[str],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> list[date]:
|
||||
values: list[date] = []
|
||||
for key in field_keys:
|
||||
for value in self._resolve_values(key, claim=claim, contexts=contexts):
|
||||
parsed = self._parse_date_value(value)
|
||||
if parsed and parsed not in values:
|
||||
values.append(parsed)
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_logic_node(node: Any, condition_results: dict[str, bool]) -> bool:
|
||||
if isinstance(node, str):
|
||||
return bool(condition_results.get(node))
|
||||
if isinstance(node, list):
|
||||
return all(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in node)
|
||||
if not isinstance(node, dict):
|
||||
return bool(node)
|
||||
if "all" in node:
|
||||
values = node.get("all") if isinstance(node.get("all"), list) else []
|
||||
return all(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in values)
|
||||
if "any" in node:
|
||||
values = node.get("any") if isinstance(node.get("any"), list) else []
|
||||
return any(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in values)
|
||||
if "not" in node:
|
||||
return not RiskRuleTemplateExecutor._evaluate_logic_node(node.get("not"), condition_results)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_city_consistency_rule(params: dict[str, Any]) -> bool:
|
||||
field_keys = RiskRuleTemplateExecutor._read_string_list(
|
||||
params.get("search_fields") or params.get("field_keys")
|
||||
)
|
||||
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return True
|
||||
has_reference = any(key in {"claim.location", "item.item_location"} for key in field_keys)
|
||||
has_attachment_city = any(
|
||||
key in {"attachment.route_cities", "attachment.hotel_city"} for key in field_keys
|
||||
)
|
||||
if not (has_reference and has_attachment_city):
|
||||
return False
|
||||
text = "\n".join(
|
||||
str(params.get(key) or "")
|
||||
for key in ("natural_language", "condition_summary", "message_template")
|
||||
)
|
||||
consistency_terms = ("一致", "不一致", "匹配", "不符", "对应", "出现在")
|
||||
city_terms = ("城市", "地点", "目的地", "行程", "票据", "发票")
|
||||
return any(term in text for term in consistency_terms) and any(
|
||||
term in text for term in city_terms
|
||||
)
|
||||
|
||||
def _resolve_values(
|
||||
self,
|
||||
field_key: str,
|
||||
@@ -150,6 +497,12 @@ class RiskRuleTemplateExecutor:
|
||||
normalized = str(field_key or "").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
if normalized == "claim.trip_start_date":
|
||||
explicit = getattr(claim, "trip_start_date", None)
|
||||
return self._normalize_values([explicit or self._claim_trip_date(claim, start=True)])
|
||||
if normalized == "claim.trip_end_date":
|
||||
explicit = getattr(claim, "trip_end_date", None)
|
||||
return self._normalize_values([explicit or self._claim_trip_date(claim, start=False)])
|
||||
if normalized.startswith("claim."):
|
||||
return self._normalize_values([getattr(claim, normalized.removeprefix("claim."), "")])
|
||||
if normalized.startswith("item."):
|
||||
@@ -157,10 +510,39 @@ class RiskRuleTemplateExecutor:
|
||||
return self._normalize_values(
|
||||
[getattr(item, attr, "") for item in list(claim.items or [])]
|
||||
)
|
||||
if normalized.startswith("employee."):
|
||||
employee = getattr(claim, "employee", None)
|
||||
if employee is None:
|
||||
return []
|
||||
return self._normalize_values(
|
||||
[getattr(employee, normalized.removeprefix("employee."), "")]
|
||||
)
|
||||
if normalized.startswith("attachment."):
|
||||
return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _resolve_unexpected_route_cities(
|
||||
route_values: list[str],
|
||||
*,
|
||||
reference_values: list[str],
|
||||
home_values: list[str],
|
||||
) -> list[str]:
|
||||
if len(route_values) < 2:
|
||||
return []
|
||||
allowed = {value.lower() for value in [*reference_values, *home_values] if value}
|
||||
if not allowed:
|
||||
return []
|
||||
candidates = route_values if home_values else route_values[1:-1]
|
||||
unexpected: list[str] = []
|
||||
for city in candidates:
|
||||
normalized = city.lower()
|
||||
if normalized in allowed:
|
||||
continue
|
||||
if city not in unexpected:
|
||||
unexpected.append(city)
|
||||
return unexpected
|
||||
|
||||
def _resolve_attachment_values(
|
||||
self, field_key: str, contexts: list[dict[str, Any]]
|
||||
) -> list[str]:
|
||||
@@ -171,13 +553,15 @@ class RiskRuleTemplateExecutor:
|
||||
document_info = {}
|
||||
if field_key == "ocr_text":
|
||||
values.extend([context.get("ocr_text"), context.get("ocr_summary")])
|
||||
if field_key in {"hotel_city", "route_cities"}:
|
||||
if field_key == "hotel_city":
|
||||
specific_values = self._scan_document_values(document_info, field_key)
|
||||
values.extend(
|
||||
specific_values
|
||||
if specific_values
|
||||
else self._scan_document_values(document_info, "city")
|
||||
)
|
||||
elif field_key == "route_cities":
|
||||
values.extend(self._scan_document_values(document_info, field_key))
|
||||
else:
|
||||
values.extend(self._scan_document_values(document_info, field_key))
|
||||
return self._normalize_values(values)
|
||||
@@ -207,6 +591,8 @@ class RiskRuleTemplateExecutor:
|
||||
"buyer_name": ("购买方", "抬头", "买方"),
|
||||
"goods_name": ("品名", "商品", "服务名称"),
|
||||
"issue_date": ("日期", "开票日期", "发票日期"),
|
||||
"stay_start_date": ("入住日期", "住宿开始", "入住时间", "开始日期"),
|
||||
"stay_end_date": ("离店日期", "退房日期", "住宿结束", "结束日期"),
|
||||
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
|
||||
"route_cities": ("行程", "路线", "目的地", "出差城市"),
|
||||
"city": ("城市", "地点"),
|
||||
@@ -222,6 +608,17 @@ class RiskRuleTemplateExecutor:
|
||||
) -> bool:
|
||||
return bool(self._resolve_values(field_key, claim=claim, contexts=contexts))
|
||||
|
||||
@staticmethod
|
||||
def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None:
|
||||
item_dates = [
|
||||
item.item_date
|
||||
for item in list(claim.items or [])
|
||||
if getattr(item, "item_date", None) is not None
|
||||
]
|
||||
if item_dates:
|
||||
return min(item_dates) if start else max(item_dates)
|
||||
return getattr(claim, "occurred_at", None)
|
||||
|
||||
@staticmethod
|
||||
def _condition_passes(operator: str, left_values: list[str], right_values: list[str]) -> bool:
|
||||
if operator == "is_empty":
|
||||
@@ -239,6 +636,43 @@ class RiskRuleTemplateExecutor:
|
||||
return any(any(right in left for right in right_set) for left in left_set)
|
||||
return bool(left_set & right_set)
|
||||
|
||||
@staticmethod
|
||||
def _values_overlap(left_values: list[str], right_values: list[str]) -> bool:
|
||||
left_set = [RiskRuleTemplateExecutor._normalize_match_value(value) for value in left_values]
|
||||
right_set = [RiskRuleTemplateExecutor._normalize_match_value(value) for value in right_values]
|
||||
for left in left_set:
|
||||
for right in right_set:
|
||||
if left and right and (left == right or left in right or right in left):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_match_value(value: str) -> str:
|
||||
return re.sub(r"[省市区县\s]+$", "", str(value or "").strip().lower())
|
||||
|
||||
@staticmethod
|
||||
def _parse_date_value(value: Any) -> date | None:
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
iso_match = re.search(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", text)
|
||||
cn_match = re.search(r"(\d{4})年(\d{1,2})月(\d{1,2})日", text)
|
||||
match = iso_match or cn_match
|
||||
if match:
|
||||
year, month, day = (int(part) for part in match.groups())
|
||||
try:
|
||||
return date(year, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(text[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_values(values: list[Any]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
@@ -251,6 +685,15 @@ class RiskRuleTemplateExecutor:
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_values(values: list[str]) -> list[str]:
|
||||
deduped: list[str] = []
|
||||
for value in values:
|
||||
text = str(value or "").strip()
|
||||
if text and text not in deduped:
|
||||
deduped.append(text)
|
||||
return deduped
|
||||
|
||||
@staticmethod
|
||||
def _read_string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
@@ -16,6 +15,11 @@ from app.schemas.user_agent import (
|
||||
UserAgentSuggestedAction,
|
||||
)
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.document_numbering import (
|
||||
build_document_number,
|
||||
generate_unique_expense_claim_no,
|
||||
)
|
||||
from app.services.user_agent_application_locations import normalize_application_location
|
||||
|
||||
APPLICATION_CONTEXT_VALUES = {
|
||||
"application",
|
||||
@@ -31,35 +35,6 @@ APPLICATION_TRANSPORT_KEYWORDS = {
|
||||
"火车": ("火车", "高铁", "动车", "铁路", "列车"),
|
||||
"轮船": ("轮船", "船", "客轮", "邮轮", "坐船"),
|
||||
}
|
||||
APPLICATION_DESTINATION_PREFIXES = (
|
||||
"上海",
|
||||
"北京",
|
||||
"广州",
|
||||
"深圳",
|
||||
"杭州",
|
||||
"南京",
|
||||
"苏州",
|
||||
"成都",
|
||||
"重庆",
|
||||
"武汉",
|
||||
"西安",
|
||||
"天津",
|
||||
"宁波",
|
||||
"青岛",
|
||||
"长沙",
|
||||
"郑州",
|
||||
"济南",
|
||||
"合肥",
|
||||
"福州",
|
||||
"厦门",
|
||||
"昆明",
|
||||
"南昌",
|
||||
"沈阳",
|
||||
"大连",
|
||||
"无锡",
|
||||
"佛山",
|
||||
"东莞",
|
||||
)
|
||||
APPLICATION_REASON_VERBS = (
|
||||
"支撑",
|
||||
"支持",
|
||||
@@ -189,7 +164,7 @@ class UserAgentApplicationMixin:
|
||||
f"申请单号:{application_no}",
|
||||
"申请信息:\n" + self._build_application_summary_table(facts),
|
||||
f"当前状态:{manager_name}审核中。",
|
||||
"预算处理:预计总费用已作为预算占用参考,等待领导审核确认。",
|
||||
"预算处理:用户预估费用已作为预算占用参考,等待领导审核确认。",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -210,6 +185,14 @@ class UserAgentApplicationMixin:
|
||||
"transport_mode": "",
|
||||
"amount": "",
|
||||
"application_type": "",
|
||||
"grade": "",
|
||||
"lodging_daily_cap": "",
|
||||
"subsidy_daily_cap": "",
|
||||
"transport_policy": "",
|
||||
"policy_estimate": "",
|
||||
"matched_city": "",
|
||||
"rule_name": "",
|
||||
"rule_version": "",
|
||||
}
|
||||
for message, is_current in self._iter_application_user_messages(payload):
|
||||
partial = {
|
||||
@@ -225,6 +208,10 @@ class UserAgentApplicationMixin:
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
if not facts["application_type"]:
|
||||
facts["application_type"] = self._infer_application_type(facts)
|
||||
facts["time"] = self._expand_application_time_with_days(
|
||||
@@ -233,6 +220,40 @@ class UserAgentApplicationMixin:
|
||||
)
|
||||
return facts
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
|
||||
preview = context_json.get("application_preview")
|
||||
if not isinstance(preview, dict):
|
||||
return {}
|
||||
fields = preview.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for key in keys:
|
||||
value = str(fields.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
return {
|
||||
"application_type": pick("applicationType", "application_type"),
|
||||
"time": pick("time", "timeRange", "time_range"),
|
||||
"location": pick("location"),
|
||||
"reason": pick("reason"),
|
||||
"days": pick("days"),
|
||||
"transport_mode": pick("transportMode", "transport_mode"),
|
||||
"amount": pick("amount"),
|
||||
"grade": pick("grade"),
|
||||
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
|
||||
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
|
||||
"transport_policy": pick("transportPolicy", "transport_policy"),
|
||||
"policy_estimate": pick("policyEstimate", "policy_estimate"),
|
||||
"matched_city": pick("matchedCity", "matched_city"),
|
||||
"rule_name": pick("ruleName", "rule_name"),
|
||||
"rule_version": pick("ruleVersion", "rule_version"),
|
||||
}
|
||||
|
||||
def _resolve_expense_application_step(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
@@ -335,23 +356,6 @@ class UserAgentApplicationMixin:
|
||||
)
|
||||
return match.group("value").strip() if match else ""
|
||||
|
||||
def _resolve_application_entity_or_label(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
entity_type: str,
|
||||
labels: tuple[str, ...],
|
||||
) -> str:
|
||||
entity_value = next(
|
||||
(
|
||||
str(item.normalized_value or item.value or "").strip()
|
||||
for item in payload.ontology.entities
|
||||
if item.type == entity_type
|
||||
and str(item.normalized_value or item.value or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
return entity_value or self._resolve_application_labeled_value(payload.message, labels)
|
||||
|
||||
def _resolve_application_location(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
@@ -359,12 +363,24 @@ class UserAgentApplicationMixin:
|
||||
message: str,
|
||||
use_entities: bool,
|
||||
) -> str:
|
||||
entity_or_labeled = (
|
||||
self._resolve_application_entity_or_label(payload, "location", ("地点", "业务地点", "发生地点"))
|
||||
if use_entities
|
||||
else self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点"))
|
||||
labeled = self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点"))
|
||||
if labeled:
|
||||
return normalize_application_location(labeled)
|
||||
|
||||
if use_entities:
|
||||
entity_value = next(
|
||||
(
|
||||
str(item.normalized_value or item.value or "").strip()
|
||||
for item in payload.ontology.entities
|
||||
if item.type == "location"
|
||||
and str(item.normalized_value or item.value or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
return entity_or_labeled or self._resolve_application_location_from_text(message)
|
||||
if entity_value:
|
||||
return normalize_application_location(entity_value)
|
||||
|
||||
return self._resolve_application_location_from_text(message)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_location_from_text(message: str) -> str:
|
||||
@@ -380,30 +396,11 @@ class UserAgentApplicationMixin:
|
||||
if not match:
|
||||
continue
|
||||
target = str(match.group("target") or "").strip()
|
||||
location = UserAgentApplicationMixin._normalize_application_location_target(target)
|
||||
location = normalize_application_location(target)
|
||||
if location:
|
||||
return location
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_application_location_target(target: str) -> str:
|
||||
text = str(target or "").strip("::,,。;;")
|
||||
if not text:
|
||||
return ""
|
||||
known = next((item for item in APPLICATION_DESTINATION_PREFIXES if text.startswith(item)), "")
|
||||
if known:
|
||||
return known
|
||||
|
||||
verb_indexes = [
|
||||
index
|
||||
for keyword in APPLICATION_REASON_VERBS
|
||||
for index in [text.find(keyword)]
|
||||
if index > 0
|
||||
]
|
||||
if verb_indexes:
|
||||
return text[: min(verb_indexes)]
|
||||
return text[:12]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_days(message: str) -> str:
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
@@ -445,7 +442,7 @@ class UserAgentApplicationMixin:
|
||||
return ""
|
||||
|
||||
text = re.sub(
|
||||
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|预计总费用|预计费用|预计金额|申请金额|预算|金额)[::]\s*",
|
||||
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)[::]\s*",
|
||||
"",
|
||||
text,
|
||||
)
|
||||
@@ -569,7 +566,7 @@ class UserAgentApplicationMixin:
|
||||
def _resolve_application_amount_from_text(message: str) -> str:
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"),
|
||||
("用户预估费用", "预估费用", "预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"),
|
||||
)
|
||||
if labeled:
|
||||
return UserAgentApplicationMixin._normalize_application_amount(labeled)
|
||||
@@ -625,7 +622,7 @@ class UserAgentApplicationMixin:
|
||||
def _display_application_slot_label(slot: str) -> str:
|
||||
return {
|
||||
"expense_type": "申请类型",
|
||||
"amount": "预计金额/预算",
|
||||
"amount": "用户预估费用",
|
||||
"time_range": "发生时间",
|
||||
"time": "发生时间",
|
||||
"location": "地点",
|
||||
@@ -670,7 +667,7 @@ class UserAgentApplicationMixin:
|
||||
"reason": ("补充申请事由", "事由:"),
|
||||
"days": ("补充天数", "天数:"),
|
||||
"transport_mode": ("补充出行方式", "出行方式:"),
|
||||
"amount": ("补充预计总费用", "预计总费用:"),
|
||||
"amount": ("补充预估费用", "用户预估费用:"),
|
||||
}
|
||||
return config.get(field, ("补充申请信息", ""))
|
||||
|
||||
@@ -718,7 +715,12 @@ class UserAgentApplicationMixin:
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("预计总费用", facts.get("amount", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
||||
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
||||
("交通费用口径", facts.get("transport_policy", "")),
|
||||
("规则测算参考", facts.get("policy_estimate", "")),
|
||||
("用户预估费用", facts.get("amount", "")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -735,7 +737,12 @@ class UserAgentApplicationMixin:
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("预计总费用", facts.get("amount", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
||||
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
||||
("交通费用口径", facts.get("transport_policy", "")),
|
||||
("规则测算参考", facts.get("policy_estimate", "")),
|
||||
("用户预估费用", facts.get("amount", "")),
|
||||
]
|
||||
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
|
||||
if not visible_rows:
|
||||
@@ -790,13 +797,38 @@ class UserAgentApplicationMixin:
|
||||
submitted_at=datetime.now(UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
risk_flags_json=[self._build_application_detail_flag(facts)],
|
||||
)
|
||||
self.db.add(claim)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
|
||||
@staticmethod
|
||||
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
|
||||
return {
|
||||
"source": "application_detail",
|
||||
"severity": "info",
|
||||
"label": "申请详情",
|
||||
"application_detail": {
|
||||
"application_type": str(facts.get("application_type") or "").strip(),
|
||||
"time": str(facts.get("time") or "").strip(),
|
||||
"location": str(facts.get("location") or "").strip(),
|
||||
"reason": str(facts.get("reason") or "").strip(),
|
||||
"days": str(facts.get("days") or "").strip(),
|
||||
"transport_mode": str(facts.get("transport_mode") or "").strip(),
|
||||
"amount": str(facts.get("amount") or "").strip(),
|
||||
"grade": str(facts.get("grade") or "").strip(),
|
||||
"lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(),
|
||||
"subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(),
|
||||
"transport_policy": str(facts.get("transport_policy") or "").strip(),
|
||||
"policy_estimate": str(facts.get("policy_estimate") or "").strip(),
|
||||
"matched_city": str(facts.get("matched_city") or "").strip(),
|
||||
"rule_name": str(facts.get("rule_name") or "").strip(),
|
||||
"rule_version": str(facts.get("rule_version") or "").strip(),
|
||||
},
|
||||
}
|
||||
|
||||
def _resolve_application_manager_name(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
@@ -930,29 +962,15 @@ class UserAgentApplicationMixin:
|
||||
*,
|
||||
fallback_seed: str = "",
|
||||
) -> str:
|
||||
raw_date = str(facts.get("time") or "")
|
||||
match = re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", raw_date)
|
||||
date_text = match.group(0) if match else datetime.now().strftime("%Y-%m-%d")
|
||||
digits = re.sub(r"\D", "", date_text)[:8].ljust(8, "0")
|
||||
seed = re.sub(r"[^A-Za-z0-9]", "", fallback_seed)[-6:] or "SIM001"
|
||||
return f"APP-{digits}-{seed.upper()}"
|
||||
return build_document_number("application", timestamp=datetime.now(UTC))
|
||||
|
||||
def _build_application_claim_no(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> str:
|
||||
context_json = payload.context_json or {}
|
||||
seed_source = "|".join(
|
||||
str(item or "").strip()
|
||||
for item in (
|
||||
context_json.get("conversation_id"),
|
||||
payload.user_id,
|
||||
facts.get("time"),
|
||||
facts.get("location"),
|
||||
facts.get("reason"),
|
||||
facts.get("amount"),
|
||||
return generate_unique_expense_claim_no(
|
||||
self.db,
|
||||
"application",
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
digest = hashlib.sha1(seed_source.encode("utf-8")).hexdigest()[:6]
|
||||
return self._build_simulated_application_no_from_facts(facts, fallback_seed=digest)
|
||||
|
||||
148
server/src/app/services/user_agent_application_locations.py
Normal file
148
server/src/app/services/user_agent_application_locations.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
DIRECT_MUNICIPALITY_DISPLAY = {
|
||||
"北京": "北京市",
|
||||
"北京市": "北京市",
|
||||
"上海": "上海市",
|
||||
"上海市": "上海市",
|
||||
"天津": "天津市",
|
||||
"天津市": "天津市",
|
||||
"重庆": "重庆市",
|
||||
"重庆市": "重庆市",
|
||||
}
|
||||
|
||||
PROVINCE_ALIASES = {
|
||||
"新疆维吾尔自治区": "新疆",
|
||||
"新疆": "新疆",
|
||||
"广东省": "广东",
|
||||
"广东": "广东",
|
||||
"浙江省": "浙江",
|
||||
"浙江": "浙江",
|
||||
"江苏省": "江苏",
|
||||
"江苏": "江苏",
|
||||
"四川省": "四川",
|
||||
"四川": "四川",
|
||||
"湖北省": "湖北",
|
||||
"湖北": "湖北",
|
||||
"陕西省": "陕西",
|
||||
"陕西": "陕西",
|
||||
"山东省": "山东",
|
||||
"山东": "山东",
|
||||
"湖南省": "湖南",
|
||||
"湖南": "湖南",
|
||||
"河南省": "河南",
|
||||
"河南": "河南",
|
||||
"安徽省": "安徽",
|
||||
"安徽": "安徽",
|
||||
"福建省": "福建",
|
||||
"福建": "福建",
|
||||
"云南省": "云南",
|
||||
"云南": "云南",
|
||||
"江西省": "江西",
|
||||
"江西": "江西",
|
||||
"辽宁省": "辽宁",
|
||||
"辽宁": "辽宁",
|
||||
}
|
||||
|
||||
CITY_TO_PROVINCE = {
|
||||
"伊犁": "新疆",
|
||||
"伊犁哈萨克自治州": "新疆",
|
||||
"乌鲁木齐": "新疆",
|
||||
"克拉玛依": "新疆",
|
||||
"喀什": "新疆",
|
||||
"广州": "广东",
|
||||
"深圳": "广东",
|
||||
"佛山": "广东",
|
||||
"东莞": "广东",
|
||||
"杭州": "浙江",
|
||||
"宁波": "浙江",
|
||||
"南京": "江苏",
|
||||
"苏州": "江苏",
|
||||
"无锡": "江苏",
|
||||
"成都": "四川",
|
||||
"武汉": "湖北",
|
||||
"西安": "陕西",
|
||||
"青岛": "山东",
|
||||
"济南": "山东",
|
||||
"长沙": "湖南",
|
||||
"郑州": "河南",
|
||||
"合肥": "安徽",
|
||||
"福州": "福建",
|
||||
"厦门": "福建",
|
||||
"昆明": "云南",
|
||||
"南昌": "江西",
|
||||
"沈阳": "辽宁",
|
||||
"大连": "辽宁",
|
||||
}
|
||||
|
||||
LOCATION_NOISE_PATTERN = re.compile(
|
||||
r"(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$"
|
||||
)
|
||||
|
||||
|
||||
def normalize_application_location(value: str) -> str:
|
||||
text = _cleanup_location_text(value)
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
direct = _resolve_direct_municipality(text)
|
||||
if direct:
|
||||
return direct
|
||||
|
||||
province_city = _resolve_province_city(text)
|
||||
if province_city:
|
||||
return province_city
|
||||
|
||||
return text[:12]
|
||||
|
||||
|
||||
def _cleanup_location_text(value: str) -> str:
|
||||
text = re.sub(r"\s+", "", str(value or ""))
|
||||
text = text.strip("::,,。;;、")
|
||||
text = re.sub(r"^(?:地点|业务地点|发生地点)[::]", "", text)
|
||||
text = re.sub(r"^(?:去|到|赴|前往)", "", text)
|
||||
text = LOCATION_NOISE_PATTERN.sub("", text)
|
||||
return text.strip("::,,。;;、")
|
||||
|
||||
|
||||
def _resolve_direct_municipality(text: str) -> str:
|
||||
for key, display in DIRECT_MUNICIPALITY_DISPLAY.items():
|
||||
if text.startswith(key):
|
||||
return display
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_province_city(text: str) -> str:
|
||||
for province_alias, province_display in PROVINCE_ALIASES.items():
|
||||
if not text.startswith(province_alias):
|
||||
continue
|
||||
remainder = text[len(province_alias) :].strip("省市地区自治州盟,,、")
|
||||
if not remainder:
|
||||
return province_display
|
||||
city = _resolve_city_name(remainder)
|
||||
return f"{province_display},{city}" if city else province_display
|
||||
|
||||
city = _resolve_city_name(text)
|
||||
if city:
|
||||
province = CITY_TO_PROVINCE.get(city)
|
||||
return f"{province},{city}" if province else city
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_city_name(text: str) -> str:
|
||||
normalized = text.strip(",,、")
|
||||
if not normalized:
|
||||
return ""
|
||||
for city in sorted(CITY_TO_PROVINCE, key=len, reverse=True):
|
||||
if normalized.startswith(city):
|
||||
return _display_city_name(city)
|
||||
return ""
|
||||
|
||||
|
||||
def _display_city_name(city: str) -> str:
|
||||
if city == "伊犁哈萨克自治州":
|
||||
return "伊犁"
|
||||
return city.removesuffix("市")
|
||||
Binary file not shown.
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"file_name": "2月20_武汉-上海.pdf",
|
||||
"storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 24995,
|
||||
"uploaded_at": "2026-05-22T05:00:39.043901+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "2月20_武汉-上海.preview.png",
|
||||
"analysis": {
|
||||
"severity": "pass",
|
||||
"label": "AI提示符合条件",
|
||||
"headline": "AI提示:附件符合基础校验条件",
|
||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||
"points": [
|
||||
"票据类型:已识别为火车/高铁票。",
|
||||
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
|
||||
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
|
||||
],
|
||||
"rule_basis": [],
|
||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "train_ticket",
|
||||
"document_type_label": "火车/高铁票",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "354元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "列车出发时间",
|
||||
"value": "2026-02-20 07:55"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "中国铁路"
|
||||
},
|
||||
{
|
||||
"key": "invoice_number",
|
||||
"label": "票据号码",
|
||||
"value": "26429165800002785705"
|
||||
},
|
||||
{
|
||||
"key": "route",
|
||||
"label": "行程",
|
||||
"value": "武汉-上海"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "train_ticket",
|
||||
"current_expense_type_label": "火车票",
|
||||
"allowed_scene_labels": [],
|
||||
"allowed_document_type_labels": [],
|
||||
"recognized_scene_code": "travel",
|
||||
"recognized_scene_label": "差旅票据",
|
||||
"recognized_document_type": "train_ticket",
|
||||
"recognized_document_type_label": "火车/高铁票",
|
||||
"mismatch_severity": "high",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
"ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
|
||||
"ocr_summary": "电子发票;(铁路电子客票);州",
|
||||
"ocr_avg_score": 0.9580968717734019,
|
||||
"ocr_line_count": 24,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"铁路电子客票",
|
||||
"电子客票",
|
||||
"铁路",
|
||||
"二等座"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB |
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"file_name": "酒店3.jpg",
|
||||
"storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg",
|
||||
"media_type": "image/jpeg",
|
||||
"size_bytes": 153582,
|
||||
"uploaded_at": "2026-05-22T06:05:17.947049+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.preview.jpg",
|
||||
"preview_media_type": "image/jpeg",
|
||||
"preview_file_name": "酒店3.preview.jpg",
|
||||
"analysis": {
|
||||
"severity": "pass",
|
||||
"label": "AI提示符合条件",
|
||||
"headline": "AI提示:附件符合基础校验条件",
|
||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||
"points": [
|
||||
"票据类型:已识别为酒店住宿票据。",
|
||||
"附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。",
|
||||
"金额字段:已识别到与当前明细接近的金额 1086.00 元。"
|
||||
],
|
||||
"rule_basis": [
|
||||
"依据《公司差旅费报销规则》(v1.0.17),住宿费按员工职级、出差城市和每晚金额进行差标核算。"
|
||||
],
|
||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "hotel_invoice",
|
||||
"document_type_label": "酒店住宿票据",
|
||||
"scene_code": "hotel",
|
||||
"scene_label": "住宿票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "1086元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "上海喜来登酒店"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "hotel_ticket",
|
||||
"current_expense_type_label": "住宿票",
|
||||
"allowed_scene_labels": [],
|
||||
"allowed_document_type_labels": [],
|
||||
"recognized_scene_code": "hotel",
|
||||
"recognized_scene_label": "住宿票据",
|
||||
"recognized_document_type": "hotel_invoice",
|
||||
"recognized_document_type_label": "酒店住宿票据",
|
||||
"mismatch_severity": "high",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为住宿票,已识别为酒店住宿票据。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
"ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号:SH-SAMPLE-20260223-001\n开单期:2026年223\n宾客姓名:曹笑\n住期:2026年220\n离店期:2026年223\n住晚数:3晚\n房型:豪华床房\n房号:1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写:壹仟零捌拾陆元整\n合计:¥1086\n备注:\n1.如有疑问,请致电前台:021-28958888。\n2.退房时间为中午12:00,超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址:上海市浦东新区银城中路88号 邮编:200120\n样例票据|仅供系统测试|无效凭证",
|
||||
"ocr_summary": "上海喜来登酒店(样例);住宿费用单;单据编号:SH-SAMPLE-20260223-001",
|
||||
"ocr_avg_score": 0.988790222009023,
|
||||
"ocr_line_count": 30,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.71,
|
||||
"ocr_classification_evidence": [
|
||||
"住宿",
|
||||
"离店",
|
||||
"酒店"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB |
Binary file not shown.
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"file_name": "2月23_上海-武汉.pdf",
|
||||
"storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 24940,
|
||||
"uploaded_at": "2026-05-22T05:01:02.605504+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "2月23_上海-武汉.preview.png",
|
||||
"analysis": {
|
||||
"severity": "pass",
|
||||
"label": "AI提示符合条件",
|
||||
"headline": "AI提示:附件符合基础校验条件",
|
||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||
"points": [
|
||||
"票据类型:已识别为火车/高铁票。",
|
||||
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
|
||||
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
|
||||
],
|
||||
"rule_basis": [],
|
||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "train_ticket",
|
||||
"document_type_label": "火车/高铁票",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "354元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "列车出发时间",
|
||||
"value": "2026-02-23 13:54"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "中国铁路"
|
||||
},
|
||||
{
|
||||
"key": "invoice_number",
|
||||
"label": "票据号码",
|
||||
"value": "26319166100006175398"
|
||||
},
|
||||
{
|
||||
"key": "route",
|
||||
"label": "行程",
|
||||
"value": "上海-武汉"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "train_ticket",
|
||||
"current_expense_type_label": "火车票",
|
||||
"allowed_scene_labels": [],
|
||||
"allowed_document_type_labels": [],
|
||||
"recognized_scene_code": "travel",
|
||||
"recognized_scene_label": "差旅票据",
|
||||
"recognized_document_type": "train_ticket",
|
||||
"recognized_document_type_label": "火车/高铁票",
|
||||
"mismatch_severity": "high",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
"ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
|
||||
"ocr_summary": "电子发票;(铁路电子客票);州",
|
||||
"ocr_avg_score": 0.9620026834309101,
|
||||
"ocr_line_count": 24,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"铁路电子客票",
|
||||
"电子客票",
|
||||
"铁路",
|
||||
"二等座"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB |
94
server/tests/test_document_numbering.py
Normal file
94
server/tests/test_document_numbering.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.document_numbering import (
|
||||
build_document_number,
|
||||
generate_unique_expense_claim_no,
|
||||
is_application_claim_no,
|
||||
)
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return factory()
|
||||
|
||||
|
||||
def test_build_document_number_uses_kind_prefix_timestamp_and_token() -> None:
|
||||
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
|
||||
|
||||
assert (
|
||||
build_document_number("application", timestamp=timestamp, token="ABCDEFGH")
|
||||
== "AP-20260525103045-ABCDEFGH"
|
||||
)
|
||||
assert (
|
||||
build_document_number("reimbursement", timestamp=timestamp, token="ABCDEFGH")
|
||||
== "RE-20260525103045-ABCDEFGH"
|
||||
)
|
||||
assert (
|
||||
build_document_number("audit", timestamp=timestamp, token="ABCDEFGH")
|
||||
== "AD-20260525103045-ABCDEFGH"
|
||||
)
|
||||
|
||||
|
||||
def test_build_document_number_rejects_ambiguous_token_chars() -> None:
|
||||
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
build_document_number("application", timestamp=timestamp, token="ABCDEF10")
|
||||
|
||||
|
||||
def test_generate_unique_expense_claim_no_retries_existing_candidate() -> None:
|
||||
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="RE-20260525103045-ABCDEFGH",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code=None,
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="深圳",
|
||||
amount=Decimal("10.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=timestamp,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
tokens = iter(["ABCDEFGH", "HGFEDCBA"])
|
||||
|
||||
assert (
|
||||
generate_unique_expense_claim_no(
|
||||
db,
|
||||
"reimbursement",
|
||||
timestamp=timestamp,
|
||||
token_factory=lambda: next(tokens),
|
||||
)
|
||||
== "RE-20260525103045-HGFEDCBA"
|
||||
)
|
||||
|
||||
|
||||
def test_is_application_claim_no_supports_new_and_legacy_prefixes() -> None:
|
||||
assert is_application_claim_no("AP-20260525103045-ABCDEFGH")
|
||||
assert is_application_claim_no("APP-20260525-ABC123")
|
||||
assert not is_application_claim_no("RE-20260525103045-ABCDEFGH")
|
||||
@@ -15,6 +15,8 @@ from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.schemas.employee import EmployeeUpdate
|
||||
from app.services.employee import EmployeeService
|
||||
from app.services.employee_seed import CANONICAL_DEPARTMENT_CODES
|
||||
from app.services.employee_time import format_history_datetime
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
@@ -49,7 +51,7 @@ def test_employee_directory_seeds_rich_employee_data() -> None:
|
||||
history_count = db.scalar(select(func.count()).select_from(EmployeeChangeLog))
|
||||
|
||||
assert role_count == 6
|
||||
assert org_count == 10
|
||||
assert org_count == 7
|
||||
assert employee_count == 30
|
||||
assert history_count and history_count >= 30
|
||||
|
||||
@@ -194,6 +196,42 @@ def test_employee_meta_includes_organization_options() -> None:
|
||||
|
||||
assert meta.organizationOptions
|
||||
assert all(item.code and item.name for item in meta.organizationOptions)
|
||||
assert [item.name for item in meta.organizationOptions] == [
|
||||
"人力资源部",
|
||||
"市场部",
|
||||
"总裁办",
|
||||
"技术部",
|
||||
"生产部",
|
||||
"财务部",
|
||||
]
|
||||
|
||||
|
||||
def test_employee_directory_normalizes_legacy_departments() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
service.list_employees()
|
||||
|
||||
legacy_department = OrganizationUnit(
|
||||
unit_code="RND-CENTER",
|
||||
name="产品研发中心",
|
||||
unit_type="department",
|
||||
)
|
||||
employee = db.execute(
|
||||
select(Employee).where(Employee.employee_no == "E11745")
|
||||
).scalar_one()
|
||||
employee.organization_unit = legacy_department
|
||||
db.add(legacy_department)
|
||||
db.commit()
|
||||
|
||||
refreshed = next(
|
||||
item for item in service.list_employees() if item.employeeNo == "E11745"
|
||||
)
|
||||
meta = service.get_employee_meta()
|
||||
|
||||
assert refreshed.department == "技术部"
|
||||
assert refreshed.organization is not None
|
||||
assert refreshed.organization.code == "TECH-DEPT"
|
||||
assert "RND-CENTER" not in {item.code for item in meta.organizationOptions}
|
||||
|
||||
|
||||
def test_update_employee_changes_organization() -> None:
|
||||
@@ -202,7 +240,11 @@ def test_update_employee_changes_organization() -> None:
|
||||
employee = service.list_employees()[0]
|
||||
organizations = service.repository.list_organization_units()
|
||||
current_code = employee.organization.code if employee.organization else None
|
||||
target = next(unit for unit in organizations if unit.unit_code != current_code)
|
||||
target = next(
|
||||
unit
|
||||
for unit in organizations
|
||||
if unit.unit_code in CANONICAL_DEPARTMENT_CODES and unit.unit_code != current_code
|
||||
)
|
||||
|
||||
updated = service.update_employee(
|
||||
employee.id,
|
||||
@@ -245,7 +287,7 @@ def test_update_employee_changes_manager() -> None:
|
||||
|
||||
def test_format_history_datetime_uses_local_timezone_without_seconds() -> None:
|
||||
value = datetime(2026, 5, 20, 6, 30, 45, tzinfo=UTC)
|
||||
formatted = EmployeeService._format_history_datetime(value)
|
||||
formatted = format_history_datetime(value)
|
||||
|
||||
assert formatted == "2026年5月20日14时30分"
|
||||
assert "秒" not in formatted
|
||||
|
||||
@@ -94,7 +94,7 @@ def test_import_employees_updates_existing_employee() -> None:
|
||||
"上海",
|
||||
employee.position,
|
||||
employee.grade,
|
||||
"FIN-SSC",
|
||||
"FINANCE-DEPT",
|
||||
"",
|
||||
"华东财务组",
|
||||
"CC-TEST",
|
||||
@@ -132,7 +132,7 @@ def test_import_employees_creates_new_employee() -> None:
|
||||
"上海",
|
||||
"业务专员",
|
||||
"P3",
|
||||
"FIN-SSC",
|
||||
"FINANCE-DEPT",
|
||||
"E10234",
|
||||
"华东财务组",
|
||||
"CC-9001",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -1042,7 +1043,7 @@ def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_
|
||||
assert manual_returns == [return_flag]
|
||||
|
||||
|
||||
def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
|
||||
def test_generate_claim_no_uses_re_prefix_timestamp_and_random_suffix() -> None:
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
@@ -1084,7 +1085,10 @@ def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
assert service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)) == "EXP-202605-004"
|
||||
assert re.fullmatch(
|
||||
r"RE-\d{14}-[A-HJ-NP-Z2-9]{8}",
|
||||
service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)),
|
||||
)
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
@@ -1100,7 +1104,7 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
db.flush()
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-202605-004",
|
||||
claim_no="RE-20260525101010-ABCDEFGH",
|
||||
employee_name="历史单据",
|
||||
department_name="财务部",
|
||||
project_code=None,
|
||||
@@ -1125,7 +1129,9 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
)
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
generated_claim_nos = iter(["EXP-202605-004", "EXP-202605-005"])
|
||||
generated_claim_nos = iter(
|
||||
["RE-20260525101010-ABCDEFGH", "RE-20260525101010-HGFEDCBA"]
|
||||
)
|
||||
service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)
|
||||
|
||||
result = service.upsert_draft_from_ontology(
|
||||
@@ -1141,8 +1147,8 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
|
||||
created_claim = db.get(ExpenseClaim, result["claim_id"])
|
||||
assert created_claim is not None
|
||||
assert created_claim.claim_no == "EXP-202605-005"
|
||||
assert result["claim_no"] == "EXP-202605-005"
|
||||
assert created_claim.claim_no == "RE-20260525101010-HGFEDCBA"
|
||||
assert result["claim_no"] == "RE-20260525101010-HGFEDCBA"
|
||||
|
||||
|
||||
def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None:
|
||||
@@ -2629,17 +2635,53 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525120000-ABCDEFGH",
|
||||
employee_name="丙",
|
||||
department_name="C部",
|
||||
project_code="PRJ-C",
|
||||
expense_type="travel_application",
|
||||
reason="C 申请",
|
||||
location="成都",
|
||||
amount=Decimal("800.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="审批完成",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525123000-HGFEDCBA",
|
||||
employee_name="丁",
|
||||
department_name="D部",
|
||||
project_code="PRJ-D",
|
||||
expense_type="travel_application",
|
||||
reason="D 申请",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 17, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-ARCH-101"
|
||||
assert {claim.claim_no for claim in claims} == {
|
||||
"EXP-ARCH-101",
|
||||
"AP-20260525120000-ABCDEFGH",
|
||||
}
|
||||
|
||||
|
||||
def test_list_archived_claims_is_empty_for_regular_employee() -> None:
|
||||
def test_list_archived_claims_returns_only_own_records_for_regular_employee() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="zhangsan@example.com",
|
||||
name="张三",
|
||||
@@ -2648,7 +2690,8 @@ def test_list_archived_claims_is_empty_for_regular_employee() -> None:
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ARCH-EMP",
|
||||
employee_name="张三",
|
||||
@@ -2665,13 +2708,31 @@ def test_list_archived_claims_is_empty_for_regular_employee() -> None:
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525130000-ABCDEFGH",
|
||||
employee_name="李四",
|
||||
department_name="研发部",
|
||||
project_code="PRJ-EMP",
|
||||
expense_type="travel_application",
|
||||
reason="他人申请",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="审批完成",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
|
||||
assert claims == []
|
||||
assert [claim.claim_no for claim in claims] == ["EXP-ARCH-EMP"]
|
||||
|
||||
|
||||
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
@@ -2760,6 +2821,79 @@ def test_executive_can_delete_submitted_claim() -> None:
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_executive_cannot_delete_archived_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-archive-delete@example.com",
|
||||
name="高管",
|
||||
role_codes=["executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-DEL-ARCHIVE-101",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="已归档单据不能删除"):
|
||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_admin_can_delete_archived_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="superadmin",
|
||||
name="系统管理员",
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-DEL-ARCHIVE-102",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-DEL-ARCHIVE-102"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-return@example.com",
|
||||
@@ -2945,7 +3079,7 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_approve_application_claim_to_completed_stage() -> None:
|
||||
def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-application-approve@example.com",
|
||||
name="李经理",
|
||||
@@ -2998,6 +3132,35 @@ def test_direct_manager_can_approve_application_claim_to_completed_stage() -> No
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "审批完成"
|
||||
archived_claims = ExpenseClaimService(db).list_archived_claims(
|
||||
CurrentUserContext(
|
||||
username="finance-archive@example.com",
|
||||
name="财务归档员",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
)
|
||||
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
assert generated_draft.status == "draft"
|
||||
assert generated_draft.approval_stage == "待提交"
|
||||
assert generated_draft.expense_type == "travel"
|
||||
assert generated_draft.employee_id == employee.id
|
||||
assert generated_draft.employee_name == "张三"
|
||||
assert generated_draft.department_name == "交付部"
|
||||
assert generated_draft.reason == "支撑国网服务器上线部署"
|
||||
assert generated_draft.location == "上海"
|
||||
assert generated_draft.amount == Decimal("12000.00")
|
||||
assert generated_draft.invoice_count == 0
|
||||
assert generated_draft.items == []
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "application_handoff"
|
||||
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
||||
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
|
||||
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
||||
for flag in generated_draft.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
@@ -3006,10 +3169,69 @@ def test_direct_manager_can_approve_application_claim_to_completed_stage() -> No
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
and flag.get("generated_draft_claim_id") == generated_draft.id
|
||||
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_approval_requires_leader_opinion() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-application-required-opinion@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8122",
|
||||
name="李经理",
|
||||
email="manager-application-required-opinion@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8123",
|
||||
name="张三",
|
||||
email="zhangsan-application-required-opinion@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-REQUIRE-OPINION",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网服务器上线部署",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="领导审核意见不能为空"):
|
||||
ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion=" ",
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
|
||||
@@ -260,6 +260,27 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N
|
||||
assert result.time_range.end_date == "2026-04-30"
|
||||
|
||||
|
||||
def test_semantic_ontology_service_extracts_new_document_numbers() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="查询 RE-20260525103045-ABCDEFGH 和 AP-20260525113045-HGFEDCBA 的状态",
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
|
||||
claim_codes = {
|
||||
item.normalized_value
|
||||
for item in result.entities
|
||||
if item.type == "expense_claim"
|
||||
}
|
||||
assert claim_codes == {
|
||||
"RE-20260525103045-ABCDEFGH",
|
||||
"AP-20260525113045-HGFEDCBA",
|
||||
}
|
||||
|
||||
|
||||
def test_semantic_ontology_service_treats_travel_amount_question_as_knowledge_query() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
@@ -687,14 +687,14 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
)
|
||||
|
||||
assert first.status == "blocked"
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in first.result["answer"]
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in first.result["answer"]
|
||||
assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n用户预估费用:"
|
||||
|
||||
assert "当前还需要补充:预计金额/预算" in second.result["answer"]
|
||||
assert "当前还需要补充:用户预估费用" in second.result["answer"]
|
||||
assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer"
|
||||
assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "预计总费用:"
|
||||
assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "用户预估费用:"
|
||||
|
||||
assert "这是模拟的费用申请结果" in third.result["answer"]
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"]
|
||||
@@ -713,7 +713,7 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
application_claims = [
|
||||
claim
|
||||
for claim in db.query(ExpenseClaim).all()
|
||||
if claim.claim_no.startswith("APP-20260525-")
|
||||
if claim.claim_no.startswith("AP-")
|
||||
]
|
||||
assert len(application_claims) == 1
|
||||
assert application_claims[0].status == "submitted"
|
||||
|
||||
361
server/tests/test_risk_rule_composite_generation.py
Normal file
361
server/tests/test_risk_rule_composite_generation.py
Normal file
@@ -0,0 +1,361 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
class NullRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class CompositeRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"name": "招待发票说明校验",
|
||||
"description": "招待报销已取得发票但缺少客户说明时进入复核。",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "entertainment_invoice_reason_check",
|
||||
"field_keys": ["attachment.invoice_no", "claim.reason"],
|
||||
"condition_summary": "D=发票号码,E=报销事由;D存在且E未说明客户名称时命中。",
|
||||
"rule_ir": {
|
||||
"facts": [
|
||||
{"id": "D", "label": "发票号码", "fields": ["attachment.invoice_no"]},
|
||||
{"id": "E", "label": "报销事由", "fields": ["claim.reason"]},
|
||||
],
|
||||
"hit_logic": "D AND NOT CONTAINS(E, 客户)",
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "invoice_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["attachment.invoice_no"],
|
||||
},
|
||||
{
|
||||
"id": "missing_customer_reason",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["客户", "拜访对象"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {"all": ["invoice_present", "missing_customer_reason"]},
|
||||
"formula": "HIT WHEN EXISTS(invoice_no) AND NOT CONTAINS(reason, 客户|拜访对象)",
|
||||
"message_template": "招待发票已上传,但事由缺少客户或拜访对象说明。",
|
||||
"keywords": [],
|
||||
"exception_keywords": [],
|
||||
"flow": {
|
||||
"start": "招待报销提交",
|
||||
"evidence": "读取发票号码和报销事由",
|
||||
"decision": "是否有发票且事由缺少客户说明",
|
||||
"pass": "客户说明完整,继续流转",
|
||||
"fail": "缺少客户说明,进入复核",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
class LodgingSemanticRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"name": "住宿城市日期一致性校验",
|
||||
"description": "住宿票据的城市和日期需要能对应本次差旅行程,缺少合理说明时进入复核。",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "travel_lodging_city_date_consistency",
|
||||
"field_keys": [
|
||||
"attachment.hotel_city",
|
||||
"attachment.stay_start_date",
|
||||
"attachment.stay_end_date",
|
||||
"attachment.issue_date",
|
||||
"claim.location",
|
||||
"item.item_location",
|
||||
"attachment.route_cities",
|
||||
"claim.trip_start_date",
|
||||
"claim.trip_end_date",
|
||||
"item.item_date",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
],
|
||||
"condition_summary": (
|
||||
"D=住宿票据事实,A=住宿城市,B=本次行程城市范围,T=住宿日期或开票日期,"
|
||||
"R=出差起止日期;D存在且[(A不属于B)或(T超出R)]且无合理说明时命中。"
|
||||
),
|
||||
"rule_ir": {
|
||||
"facts": [
|
||||
{"id": "D", "label": "住宿票据事实", "fields": ["attachment.hotel_city", "attachment.ocr_text"]},
|
||||
{"id": "A", "label": "住宿城市", "fields": ["attachment.hotel_city"]},
|
||||
{"id": "B", "label": "本次行程城市范围", "fields": ["claim.location", "item.item_location", "attachment.route_cities"]},
|
||||
{"id": "T", "label": "住宿日期或开票日期", "fields": ["attachment.stay_start_date", "attachment.stay_end_date", "attachment.issue_date"]},
|
||||
{"id": "R", "label": "出差起止日期", "fields": ["claim.trip_start_date", "claim.trip_end_date", "item.item_date"]},
|
||||
{"id": "E", "label": "合理说明", "fields": ["claim.reason", "item.item_reason"]},
|
||||
],
|
||||
"hit_logic": "D AND ((A NOT_IN B) OR DATE_OUTSIDE(T,R)) AND NOT CONTAINS(E, exception_keywords)",
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "lodging_document_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["attachment.hotel_city", "attachment.ocr_text"],
|
||||
},
|
||||
{
|
||||
"id": "lodging_city_outside_trip_scope",
|
||||
"operator": "not_in_scope",
|
||||
"left_fields": ["attachment.hotel_city"],
|
||||
"right_fields": ["claim.location", "item.item_location", "attachment.route_cities"],
|
||||
},
|
||||
{
|
||||
"id": "lodging_date_outside_trip_range",
|
||||
"operator": "date_outside_range",
|
||||
"date_fields": ["attachment.stay_start_date", "attachment.stay_end_date", "attachment.issue_date"],
|
||||
"range_start_fields": ["claim.trip_start_date", "item.item_date"],
|
||||
"range_end_fields": ["claim.trip_end_date", "item.item_date"],
|
||||
},
|
||||
{
|
||||
"id": "missing_reasonable_exception",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason", "item.item_reason"],
|
||||
"keywords": ["延期", "改签", "临时任务"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"lodging_document_present",
|
||||
{"any": ["lodging_city_outside_trip_scope", "lodging_date_outside_trip_range"]},
|
||||
"missing_reasonable_exception",
|
||||
]
|
||||
},
|
||||
"formula": "D AND ((A NOT_IN B) OR DATE_OUTSIDE(T,R)) AND NOT EXCEPTION(E)",
|
||||
"message_template": "住宿票据城市或日期无法与本次差旅行程形成一致关系,且未识别到合理说明。",
|
||||
"keywords": [],
|
||||
"exception_keywords": ["延期", "改签", "临时任务"],
|
||||
"flow": {
|
||||
"start": "差旅住宿报销提交",
|
||||
"evidence": "读取住宿票据事实、行程范围和合理说明",
|
||||
"decision": "住宿城市或日期是否脱离本次行程,且是否缺少合理说明",
|
||||
"pass": "城市和日期均能对应行程,或已有合理说明",
|
||||
"fail": "城市或日期无法对应行程,进入复核",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def _read_payload(manager: AgentAssetRuleLibraryManager, asset: AgentAsset) -> dict:
|
||||
return manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
|
||||
|
||||
def test_lodging_city_date_rule_generates_explainable_composite_json(tmp_path) -> None:
|
||||
text = (
|
||||
"差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、"
|
||||
"住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,"
|
||||
"或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,"
|
||||
"则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。"
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
service = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=LodgingSemanticRuntimeChatService(),
|
||||
)
|
||||
asset_id = service.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="hotel",
|
||||
rule_title="住宿城市日期一致性校验",
|
||||
risk_level="high",
|
||||
natural_language=text,
|
||||
requires_attachment=True,
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
payload = _read_payload(manager, asset)
|
||||
|
||||
assert payload["template_key"] == "composite_rule_v1"
|
||||
assert payload["semantic_type"] == "travel_lodging_city_date_consistency"
|
||||
assert payload["params"]["semantic_type"] == "travel_lodging_city_date_consistency"
|
||||
assert payload["params"]["keywords"] == []
|
||||
assert "风险关键词" not in payload["params"]["condition_summary"]
|
||||
assert "attachment.stay_start_date" in payload["params"]["field_keys"]
|
||||
assert "claim.trip_start_date" in payload["params"]["field_keys"]
|
||||
assert payload["params"]["rule_ir"]["facts"]
|
||||
assert payload["params"]["hit_logic"]["all"][1]["any"] == [
|
||||
"lodging_city_outside_trip_scope",
|
||||
"lodging_date_outside_trip_range",
|
||||
]
|
||||
|
||||
|
||||
def test_composite_lodging_executor_hits_mismatch_and_respects_exception() -> None:
|
||||
manifest = {
|
||||
"template_key": "composite_rule_v1",
|
||||
"params": {
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "travel_lodging_city_date_consistency",
|
||||
"condition_summary": "住宿城市或日期不在本次差旅行程范围内且无合理说明时命中。",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "lodging_document_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["attachment.hotel_city", "attachment.ocr_text"],
|
||||
},
|
||||
{
|
||||
"id": "lodging_city_outside_trip_scope",
|
||||
"operator": "not_in_scope",
|
||||
"left_fields": ["attachment.hotel_city"],
|
||||
"right_fields": ["claim.location", "item.item_location", "attachment.route_cities"],
|
||||
},
|
||||
{
|
||||
"id": "lodging_date_outside_trip_range",
|
||||
"operator": "date_outside_range",
|
||||
"date_fields": ["attachment.stay_start_date", "attachment.stay_end_date"],
|
||||
"range_start_fields": ["claim.trip_start_date", "item.item_date"],
|
||||
"range_end_fields": ["claim.trip_end_date", "item.item_date"],
|
||||
},
|
||||
{
|
||||
"id": "missing_reasonable_exception",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason", "item.item_reason"],
|
||||
"keywords": ["延期", "改签", "临时任务"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"lodging_document_present",
|
||||
{"any": ["lodging_city_outside_trip_scope", "lodging_date_outside_trip_range"]},
|
||||
"missing_reasonable_exception",
|
||||
]
|
||||
},
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "high"}},
|
||||
}
|
||||
claim = _build_claim(reason="去上海出差住宿", location="上海")
|
||||
contexts = [
|
||||
{
|
||||
"document_info": {
|
||||
"hotel_city": "北京",
|
||||
"stay_start_date": "2026-05-11",
|
||||
"stay_end_date": "2026-05-12",
|
||||
"fields": [
|
||||
{"key": "hotel_city", "label": "住宿城市", "value": "北京"},
|
||||
{"key": "stay_start_date", "label": "入住日期", "value": "2026-05-11"},
|
||||
{"key": "stay_end_date", "label": "离店日期", "value": "2026-05-12"},
|
||||
],
|
||||
},
|
||||
"ocr_text": "北京酒店住宿发票",
|
||||
}
|
||||
]
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=contexts)
|
||||
assert result is not None
|
||||
assert result["evidence"]["condition_results"]["lodging_city_outside_trip_scope"] is True
|
||||
|
||||
claim_with_exception = _build_claim(reason="去上海出差住宿,因临时任务改签至北京", location="上海")
|
||||
assert RiskRuleTemplateExecutor().evaluate(
|
||||
manifest, claim=claim_with_exception, contexts=contexts
|
||||
) is None
|
||||
|
||||
|
||||
def test_model_generated_composite_rule_is_preserved_for_other_categories(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
service = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=CompositeRuntimeChatService(),
|
||||
)
|
||||
asset_id = service.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="meal",
|
||||
rule_title="招待发票客户说明校验",
|
||||
risk_level="medium",
|
||||
natural_language="招待报销时,如果已经上传发票但报销事由没有客户或拜访对象说明,则提示中风险。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
payload = _read_payload(manager, asset)
|
||||
|
||||
assert payload["template_key"] == "composite_rule_v1"
|
||||
assert payload["semantic_type"] == "entertainment_invoice_reason_check"
|
||||
assert payload["params"]["conditions"][0]["operator"] == "exists_any"
|
||||
assert payload["params"]["hit_logic"] == {"all": ["invoice_present", "missing_customer_reason"]}
|
||||
assert payload["params"]["message_template"] == "招待发票已上传,但事由缺少客户或拜访对象说明。"
|
||||
|
||||
claim = _build_claim(reason="招待费", location="上海")
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
payload,
|
||||
claim=claim,
|
||||
contexts=[{"document_info": {"invoice_no": "INV-20260526001"}}],
|
||||
)
|
||||
assert result is not None
|
||||
assert result["message"] == "招待发票已上传,但事由缺少客户或拜访对象说明。"
|
||||
|
||||
claim.reason = "招待客户 ACME 的餐费"
|
||||
assert RiskRuleTemplateExecutor().evaluate(
|
||||
payload,
|
||||
claim=claim,
|
||||
contexts=[{"document_info": {"invoice_no": "INV-20260526001"}}],
|
||||
) is None
|
||||
|
||||
|
||||
def _build_claim(*, reason: str, location: str) -> ExpenseClaim:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-COMPOSITE-RISK",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason=reason,
|
||||
location=location,
|
||||
amount=Decimal("680.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, tzinfo=UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.trip_start_date = date(2026, 5, 10)
|
||||
claim.trip_end_date = date(2026, 5, 12)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date(2026, 5, 11),
|
||||
item_type="住宿费",
|
||||
item_reason=reason,
|
||||
item_location=location,
|
||||
item_amount=Decimal("680.00"),
|
||||
)
|
||||
]
|
||||
return claim
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
@@ -11,7 +11,8 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentReviewStatus
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetReviewCreate,
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
@@ -23,11 +24,15 @@ from app.schemas.agent_asset import (
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
RiskRuleFlowDiagramSpec,
|
||||
)
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
class NullRuntimeChatService:
|
||||
@@ -35,6 +40,41 @@ class NullRuntimeChatService:
|
||||
return None
|
||||
|
||||
|
||||
class TravelRouteSemanticRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"name": "差旅票据路线一致性校验",
|
||||
"description": "交通票或住宿票据城市需要与申报行程形成一致关系。",
|
||||
"template_key": "field_compare_v1",
|
||||
"semantic_type": "travel_route_city_consistency",
|
||||
"field_keys": [
|
||||
"attachment.route_cities",
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"item.item_location",
|
||||
"employee.location",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
],
|
||||
"condition_summary": (
|
||||
"A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
||||
"C=员工常驻地;A与B无交集且无合理说明,或A出现B∪C之外城市时命中。"
|
||||
),
|
||||
"keywords": [],
|
||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
"flow": {
|
||||
"start": "差旅报销提交",
|
||||
"evidence": "读取票据城市、申报地点、明细地点和报销事由",
|
||||
"decision": "票据城市是否覆盖申报行程,是否出现额外中转城市",
|
||||
"pass": "票据城市与申报行程一致",
|
||||
"fail": "票据城市与申报行程不一致,进入复核",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
@@ -58,6 +98,7 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅住宿城市一致性校验",
|
||||
risk_level="high",
|
||||
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
||||
),
|
||||
@@ -66,6 +107,7 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.name == "差旅住宿城市一致性校验"
|
||||
assert asset.status == AgentAssetStatus.DRAFT.value
|
||||
assert asset.config_json["detail_mode"] == "json_risk"
|
||||
assert asset.config_json["evaluator"] == "template_rule"
|
||||
@@ -78,17 +120,23 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
||||
rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
|
||||
payload = json.loads(rule_path.read_text(encoding="utf-8"))
|
||||
assert payload["rule_code"] == asset.code
|
||||
assert payload["name"] == "差旅住宿城市一致性校验"
|
||||
assert payload["applies_to"]["expense_categories"] == ["travel"]
|
||||
assert payload["risk_category"] == "差旅费"
|
||||
assert payload["metadata"]["expense_category"] == "travel"
|
||||
assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验"
|
||||
assert payload["outcomes"]["fail"]["severity"] == "high"
|
||||
assert payload["template_key"] == "field_compare_v1"
|
||||
assert payload["metadata"]["natural_language"].startswith("住宿城市")
|
||||
assert payload["inputs"]["fields"]
|
||||
assert payload["flow_diagram_svg"].startswith("<svg")
|
||||
assert 'width="760" height="280"' in payload["flow_diagram_svg"]
|
||||
assert 'width="860" height="360"' in payload["flow_diagram_svg"]
|
||||
assert 'data-risk-flow-style="review-node-only"' in payload["flow_diagram_svg"]
|
||||
assert 'data-risk-flow-detail="logic-v2"' in payload["flow_diagram_svg"]
|
||||
assert "RULE FLOW" in payload["flow_diagram_svg"]
|
||||
assert "字段事实" in payload["flow_diagram_svg"]
|
||||
assert "判断条件" in payload["flow_diagram_svg"]
|
||||
assert "命中逻辑" in payload["flow_diagram_svg"]
|
||||
assert "进入复核" in payload["flow_diagram_svg"]
|
||||
assert "否" in payload["flow_diagram_svg"]
|
||||
assert "是" in payload["flow_diagram_svg"]
|
||||
@@ -99,6 +147,113 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
||||
assert "feDropShadow" not in payload["flow_diagram_svg"]
|
||||
|
||||
|
||||
def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
generator = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
)
|
||||
|
||||
asset_id = generator.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅住宿城市一致性校验",
|
||||
risk_level="high",
|
||||
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
asset_service = AgentAssetService(db)
|
||||
asset_service.rule_library_manager = manager
|
||||
updated = asset_service.set_risk_rule_level(
|
||||
asset_id,
|
||||
risk_level="low",
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
assert updated.config_json["severity"] == "low"
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.config_json["risk_level_label"] == "低风险"
|
||||
file_name = asset.config_json["rule_document"]["file_name"]
|
||||
payload = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
)
|
||||
assert payload["outcomes"]["fail"]["severity"] == "low"
|
||||
assert payload["metadata"]["risk_level"] == "low"
|
||||
assert payload["metadata"]["risk_level_label"] == "低风险"
|
||||
assert "低风险" in payload["metadata"]["flow"]["fail"]
|
||||
assert "#2563eb" in payload["flow_diagram_svg"]
|
||||
assert "#dc2626" not in payload["flow_diagram_svg"]
|
||||
|
||||
version = asset_service.repository.get_version(asset_id, asset.working_version)
|
||||
assert version is not None
|
||||
assert '"severity": "low"' in version.content
|
||||
|
||||
|
||||
def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
service = RiskRuleGenerationJobService(
|
||||
db,
|
||||
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
)
|
||||
request = AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅城市一致性校验",
|
||||
risk_level="high",
|
||||
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
||||
)
|
||||
|
||||
asset_id = service.enqueue_rule_asset_generation(request, actor="pytest")
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.status == AgentAssetStatus.GENERATING.value
|
||||
assert asset.owner == "pytest"
|
||||
assert asset.name == "差旅城市一致性校验"
|
||||
assert asset.config_json["generation_status"] == "generating"
|
||||
assert asset.config_json["expense_category_label"] == "差旅费"
|
||||
assert asset.current_version is None
|
||||
detail = AgentAssetService(db).get_asset(asset_id)
|
||||
assert detail is not None
|
||||
assert detail.status == AgentAssetStatus.GENERATING.value
|
||||
assert detail.latest_test_summary is None
|
||||
|
||||
service.complete_rule_asset_generation(asset_id, request, actor="pytest")
|
||||
db.refresh(asset)
|
||||
|
||||
assert asset.status == AgentAssetStatus.DRAFT.value
|
||||
assert asset.working_version == "v0.1.0"
|
||||
assert asset.config_json["generation_status"] == "completed"
|
||||
assert asset.config_json["expense_category_label"] == "差旅费"
|
||||
assert asset.scenario_json == ["差旅费"]
|
||||
|
||||
|
||||
def test_platform_risk_sync_skips_natural_language_drafts() -> None:
|
||||
assert AgentFoundationRiskRuleMixin._is_user_generated_risk_manifest(
|
||||
{
|
||||
"rule_code": "risk.expense.travel.generated_20260525123000000000",
|
||||
"metadata": {
|
||||
"stability": "generated_draft",
|
||||
"source_ref": "自然语言风险规则",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert not AgentFoundationRiskRuleMixin._is_user_generated_risk_manifest(
|
||||
{
|
||||
"rule_code": "risk.travel.destination_location_mismatch",
|
||||
"metadata": {"source_ref": "平台内置风险规则"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
|
||||
renderer = RiskRuleFlowDiagramRenderer()
|
||||
|
||||
@@ -123,10 +278,380 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
|
||||
assert "#f97316" in render("medium", "中风险")
|
||||
high_svg = render("high", "高风险")
|
||||
assert "#dc2626" in high_svg
|
||||
assert high_svg.count("#dc2626") == 1
|
||||
assert high_svg.count("#dc2626") >= 1
|
||||
assert "#10a37f" not in high_svg
|
||||
|
||||
|
||||
def test_risk_rule_simulation_extracts_ticket_route_cities() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
value = service._find_attachment_field_value(
|
||||
"attachment.route_cities",
|
||||
"行程城市",
|
||||
[
|
||||
{
|
||||
"document_fields": [
|
||||
{"key": "route", "label": "行程路线", "value": "上海虹桥-武汉"}
|
||||
],
|
||||
"ocr_text": "G123 上海虹桥 至 武汉 二等座",
|
||||
"summary": "高铁票 上海虹桥-武汉",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert value == ["上海", "武汉"]
|
||||
|
||||
|
||||
def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> None:
|
||||
manifest = {
|
||||
"template_key": "keyword_match_v1",
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
"item.item_location",
|
||||
],
|
||||
"search_fields": [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
"item.item_location",
|
||||
],
|
||||
"natural_language": "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。",
|
||||
"condition_summary": "检查住宿城市、申报地点、行程城市是否一致",
|
||||
"keywords": ["绕行", "跨城", "改签", "变更"],
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "medium"}},
|
||||
}
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-CURRENT-RISK",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="去北京出差3天",
|
||||
location="北京",
|
||||
amount=Decimal("320.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date.today(),
|
||||
item_type="交通费",
|
||||
item_reason="去北京出差3天",
|
||||
item_location="北京",
|
||||
item_amount=Decimal("320.00"),
|
||||
)
|
||||
]
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[
|
||||
{
|
||||
"document_info": {
|
||||
"route_cities": ["武汉", "上海"],
|
||||
"fields": [
|
||||
{"key": "route_cities", "label": "行程城市", "value": ["武汉", "上海"]}
|
||||
],
|
||||
},
|
||||
"ocr_text": "武汉 到 上海",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["evidence"]["city_consistency"]["attachment_values"] == ["武汉", "上海"]
|
||||
assert result["evidence"]["city_consistency"]["reference_values"] == ["北京"]
|
||||
|
||||
|
||||
def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
|
||||
text = (
|
||||
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
|
||||
"再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。"
|
||||
"若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,"
|
||||
"且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,"
|
||||
"要求补充行程说明或退回修改。"
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
service = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=TravelRouteSemanticRuntimeChatService(),
|
||||
)
|
||||
|
||||
asset_id = service.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅票据路线一致性校验",
|
||||
risk_level="high",
|
||||
natural_language=text,
|
||||
requires_attachment=True,
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
payload = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
assert payload["template_key"] == "field_compare_v1"
|
||||
assert payload["semantic_type"] == "travel_route_city_consistency"
|
||||
assert payload["params"]["semantic_type"] == "travel_route_city_consistency"
|
||||
assert payload["params"]["keywords"] == []
|
||||
assert payload["params"]["exception_keywords"][:3] == ["绕行", "跨城办事", "跨城"]
|
||||
assert "A=交通票行程城市" in payload["params"]["condition_summary"]
|
||||
assert "风险关键词" not in payload["params"]["condition_summary"]
|
||||
assert "employee.location" in payload["params"]["field_keys"]
|
||||
assert "route_anomaly_policy" in payload["params"]
|
||||
|
||||
|
||||
def test_legacy_city_route_keyword_manifest_is_normalized_before_display_and_execution() -> None:
|
||||
manifest = {
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.expense.travel.legacy_city_keyword",
|
||||
"name": "差旅票据路线一致性校验",
|
||||
"description": "差旅报销时读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"risk_category": "差旅费",
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{"key": "attachment.hotel_city", "label": "住宿城市"},
|
||||
{"key": "claim.location", "label": "申报地点"},
|
||||
{"key": "attachment.route_cities", "label": "行程城市"},
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
],
|
||||
"search_fields": [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
],
|
||||
"keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
"condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
|
||||
"natural_language": (
|
||||
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
|
||||
"再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。"
|
||||
"若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,"
|
||||
"且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险。"
|
||||
),
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "high"}},
|
||||
"metadata": {
|
||||
"condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
|
||||
"flow": {
|
||||
"decision": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词"
|
||||
},
|
||||
},
|
||||
"flow_diagram_svg": (
|
||||
'<svg data-risk-flow-style="review-node-only">'
|
||||
"检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词"
|
||||
"</svg>"
|
||||
),
|
||||
}
|
||||
|
||||
normalized = normalize_risk_rule_manifest(manifest)
|
||||
assert normalized["template_key"] == "field_compare_v1"
|
||||
assert normalized["semantic_type"] == "travel_route_city_consistency"
|
||||
assert normalized["params"]["keywords"] == []
|
||||
assert "风险关键词" not in normalized["params"]["condition_summary"]
|
||||
assert "风险关键词" not in normalized["metadata"]["flow"]["decision"]
|
||||
assert "风险关键词" not in normalized["flow_diagram_svg"]
|
||||
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-LEGACY-NORMALIZER",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="去上海办事",
|
||||
location="上海",
|
||||
amount=Decimal("520.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.employee = Employee(
|
||||
employee_no="TEST-EMPLOYEE",
|
||||
name="测试员工",
|
||||
email="legacy-route-risk@example.com",
|
||||
location="武汉",
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date.today(),
|
||||
item_type="交通费",
|
||||
item_reason="去上海办事",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("520.00"),
|
||||
)
|
||||
]
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
normalized,
|
||||
claim=claim,
|
||||
contexts=[{"document_info": {"route_cities": ["上海", "北京", "武汉"]}}],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京"]
|
||||
|
||||
|
||||
def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning_home() -> None:
|
||||
manifest = {
|
||||
"template_key": "field_compare_v1",
|
||||
"params": {
|
||||
"template_key": "field_compare_v1",
|
||||
"semantic_type": "travel_route_city_consistency",
|
||||
"field_keys": [
|
||||
"attachment.route_cities",
|
||||
"claim.location",
|
||||
"item.item_location",
|
||||
"employee.location",
|
||||
"claim.reason",
|
||||
],
|
||||
"attachment_city_fields": ["attachment.route_cities"],
|
||||
"reference_city_fields": ["claim.location", "item.item_location"],
|
||||
"home_city_fields": ["employee.location"],
|
||||
"exception_fields": ["claim.reason"],
|
||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
"condition_summary": "A=票据路线城市,B=申报城市,C=员工常驻地,A中出现B∪C之外城市则命中。",
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "high"}},
|
||||
}
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-ROUTE-ANOMALY",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="去上海办事",
|
||||
location="上海",
|
||||
amount=Decimal("520.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.employee = Employee(
|
||||
employee_no="TEST-EMPLOYEE",
|
||||
name="测试员工",
|
||||
email="route-risk@example.com",
|
||||
location="武汉",
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date.today(),
|
||||
item_type="交通费",
|
||||
item_reason="去上海办事",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("520.00"),
|
||||
)
|
||||
]
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[
|
||||
{
|
||||
"document_info": {
|
||||
"route_cities": ["上海", "北京", "武汉"],
|
||||
"fields": [
|
||||
{"key": "route_cities", "label": "行程城市", "value": ["上海", "北京", "武汉"]}
|
||||
],
|
||||
},
|
||||
"ocr_text": "上海 到 北京 到 武汉",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
evidence = result["evidence"]["city_consistency"]
|
||||
assert evidence["reference_values"] == ["上海"]
|
||||
assert evidence["home_values"] == ["武汉"]
|
||||
assert evidence["unexpected_route_cities"] == ["北京"]
|
||||
|
||||
|
||||
def test_simulation_uses_current_rule_manifest_for_ticket_city_mismatch(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
generator = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
)
|
||||
asset_id = generator.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="当前差旅票据城市一致性规则",
|
||||
risk_level="medium",
|
||||
natural_language="差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。",
|
||||
requires_attachment=True,
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
service = AgentAssetService(db)
|
||||
service.rule_library_manager = manager
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
manifest = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
manifest["template_key"] = "keyword_match_v1"
|
||||
manifest["params"]["template_key"] = "keyword_match_v1"
|
||||
manifest["params"]["keywords"] = ["绕行", "跨城", "改签", "变更"]
|
||||
manifest["params"]["search_fields"] = [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
"item.item_location",
|
||||
]
|
||||
manifest["params"]["field_keys"] = manifest["params"]["search_fields"]
|
||||
manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
payload=manifest,
|
||||
)
|
||||
|
||||
simulation = service.simulate_risk_rule_message(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleSimulationRequest(
|
||||
message="去北京出差3天",
|
||||
attachments=[
|
||||
{
|
||||
"name": "train-ticket.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"ocr_text": "武汉 到 上海",
|
||||
"summary": "高铁票 武汉-上海",
|
||||
"document_fields": [
|
||||
{"key": "route", "label": "行程路线", "value": "武汉-上海"}
|
||||
],
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
assert simulation.ready is True
|
||||
assert simulation.hit is True
|
||||
assert simulation.field_values["claim.location"] == "北京"
|
||||
assert simulation.field_values["attachment.route_cities"] == ["武汉", "上海"]
|
||||
|
||||
|
||||
def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -208,11 +209,11 @@ def test_user_agent_application_context_uses_application_language() -> None:
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||
assert "支持上海国网服务器部署" in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in response.answer
|
||||
assert "请先在下面选择报销场景" not in response.answer
|
||||
assert response.review_payload is None
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None:
|
||||
@@ -222,13 +223,38 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
|
||||
response = build_application_user_agent_response(db, message)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||
assert "| 地点 | 上海 |" in response.answer
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
||||
assert "当前还需要先补充:申请事由" not in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
|
||||
|
||||
def test_user_agent_application_normalizes_location_to_region_city() -> None:
|
||||
session_factory = build_session_factory()
|
||||
yili_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:伊犁\n"
|
||||
"事由:支撑新疆电力仿生产部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
beijing_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:北京\n"
|
||||
"事由:支撑总部系统部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
with session_factory() as db:
|
||||
yili_response = build_application_user_agent_response(db, yili_message)
|
||||
beijing_response = build_application_user_agent_response(db, beijing_message)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in yili_response.answer
|
||||
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
|
||||
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
|
||||
assert "伊犁出差" not in yili_response.answer
|
||||
assert "| 地点 | 北京市 |" in beijing_response.answer
|
||||
|
||||
|
||||
def test_user_agent_application_uses_selected_time_and_natural_language_fields() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = "出差上海,支撑国网服务器上线部署"
|
||||
@@ -262,12 +288,12 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields()
|
||||
)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 地点 | 上海 |" in response.answer
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_asks_amount_after_transport_choice() -> None:
|
||||
@@ -286,10 +312,10 @@ def test_user_agent_application_asks_amount_after_transport_choice() -> None:
|
||||
)
|
||||
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "当前还需要补充:预计金额/预算" in response.answer
|
||||
assert "当前还需要补充:用户预估费用" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "预计总费用:"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "用户预估费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
|
||||
@@ -300,10 +326,10 @@ def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
|
||||
"地点:上海\n事由:支撑国网服务器部署\n天数:3天",
|
||||
)
|
||||
|
||||
assert "当前还需要补充:发生时间、出行方式、预计金额/预算" in response.answer
|
||||
assert "当前还需要补充:发生时间、出行方式、用户预估费用" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "申请时间段:\n出行方式:\n预计总费用:"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "申请时间段:\n出行方式:\n用户预估费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
|
||||
@@ -328,7 +354,7 @@ def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in response.answer
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "| 预计总费用 | 12000元 |" in response.answer
|
||||
assert "| 用户预估费用 | 12000元 |" in response.answer
|
||||
assert "请核对上述信息无误" in response.answer
|
||||
assert "[确认](#application-submit)" in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
@@ -349,11 +375,11 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
"| --- | --- |\n"
|
||||
"| 申请类型 | 差旅费用申请 |\n"
|
||||
"| 发生时间 | 2026-05-25 |\n"
|
||||
"| 地点 | 上海 |\n"
|
||||
"| 地点 | 上海市 |\n"
|
||||
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||
"| 天数 | 3天 |\n"
|
||||
"| 出行方式 | 飞机 |\n"
|
||||
"| 预计总费用 | 12000元 |\n\n"
|
||||
"| 用户预估费用 | 12000元 |\n\n"
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
|
||||
)
|
||||
with session_factory() as db:
|
||||
@@ -372,9 +398,9 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in response.answer
|
||||
assert "当前状态:陈硕审核中" in response.answer
|
||||
assert "预算占用参考" in response.answer
|
||||
assert "APP-20260525-" in response.answer
|
||||
assert re.search(r"AP-\d{14}-[A-HJ-NP-Z2-9]{8}", response.answer)
|
||||
assert response.suggested_actions == []
|
||||
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("APP-20260525-%")).one()
|
||||
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert claim.expense_type == "travel_application"
|
||||
|
||||
BIN
web/UI/预算中心.jpg
Normal file
BIN
web/UI/预算中心.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
@@ -36,6 +36,8 @@
|
||||
.risk-sim-context-panel span,
|
||||
.risk-sim-result-head span,
|
||||
.risk-sim-evidence span,
|
||||
.risk-sim-recognition-debug > span,
|
||||
.risk-sim-recognized-fields > span,
|
||||
.risk-sim-file-strip > span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
@@ -115,6 +117,12 @@
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.risk-sim-meta .tone-critical {
|
||||
border-color: #fca5a5;
|
||||
background: #fff1f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.risk-sim-main {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
@@ -263,6 +271,78 @@
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug article {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug article header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug article header strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug article header em {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.risk-sim-debug-field-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.risk-sim-debug-field-list b {
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-debug-ocr-text {
|
||||
max-height: 112px;
|
||||
overflow: auto;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.risk-sim-result-card {
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
@@ -364,6 +444,53 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields ul {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields li {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 0.75fr) auto minmax(130px, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields strong,
|
||||
.risk-sim-recognized-fields b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields em {
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-evidence {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -730,6 +857,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.risk-sim-foot {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -935,6 +935,7 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-rule-create-form input:not([type='checkbox']),
|
||||
.risk-rule-create-form select,
|
||||
.risk-rule-create-form textarea {
|
||||
width: 100%;
|
||||
@@ -945,6 +946,7 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.risk-rule-create-form input:not([type='checkbox']),
|
||||
.risk-rule-create-form select {
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
@@ -957,6 +959,7 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.risk-rule-create-form input:not([type='checkbox']):focus,
|
||||
.risk-rule-create-form select:focus,
|
||||
.risk-rule-create-form textarea:focus {
|
||||
outline: 0;
|
||||
@@ -964,6 +967,7 @@
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.risk-rule-create-form input:not([type='checkbox'])::placeholder,
|
||||
.risk-rule-create-form textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
@@ -1045,6 +1049,118 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.risk-level-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action b {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.low {
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.low b {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.medium {
|
||||
border-color: rgba(249, 115, 22, 0.26);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.medium b {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.high {
|
||||
border-color: rgba(220, 38, 38, 0.22);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.high b {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.risk-level-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 35;
|
||||
width: 146px;
|
||||
padding: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.risk-level-option {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.risk-level-option:hover,
|
||||
.risk-level-option.active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-level-option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.risk-level-option i {
|
||||
margin-left: auto;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.risk-level-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.risk-level-option.low .risk-level-dot {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.risk-level-option.medium .risk-level-dot {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.risk-level-option.high .risk-level-dot {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.minor-action.danger-action {
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
color: #dc2626;
|
||||
@@ -1174,6 +1290,8 @@
|
||||
.search-filter,
|
||||
.picker-trigger,
|
||||
.picker-filter,
|
||||
.risk-level-switch,
|
||||
.risk-level-switch .minor-action,
|
||||
.toolbar-actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1303,6 +1421,62 @@
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.json-risk-score-ring {
|
||||
--score-ring: #f97316;
|
||||
--score-ring-bg: #fff7ed;
|
||||
flex: 0 0 auto;
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 1px;
|
||||
border: 2px solid var(--score-ring);
|
||||
background: var(--score-ring-bg);
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.json-risk-score-ring strong {
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.json-risk-score-ring span,
|
||||
.json-risk-score-ring em {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.json-risk-score-ring em {
|
||||
color: var(--score-ring);
|
||||
}
|
||||
|
||||
.json-risk-score-ring.low {
|
||||
--score-ring: #2563eb;
|
||||
--score-ring-bg: #eff6ff;
|
||||
}
|
||||
|
||||
.json-risk-score-ring.medium {
|
||||
--score-ring: #f97316;
|
||||
--score-ring-bg: #fff7ed;
|
||||
}
|
||||
|
||||
.json-risk-score-ring.high {
|
||||
--score-ring: #dc2626;
|
||||
--score-ring-bg: #fef2f2;
|
||||
}
|
||||
|
||||
.json-risk-score-ring.critical {
|
||||
--score-ring: #991b1b;
|
||||
--score-ring-bg: #fff1f2;
|
||||
}
|
||||
|
||||
.json-risk-editor-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -1423,6 +1597,55 @@
|
||||
padding: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure {
|
||||
min-height: 360px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 14px;
|
||||
padding: 44px 24px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 12px;
|
||||
background: #fffafa;
|
||||
text-align: center;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure i {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure h3 {
|
||||
margin: 0;
|
||||
color: #991b1b;
|
||||
font-size: 18px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure p {
|
||||
max-width: 520px;
|
||||
margin: 8px auto 0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure small {
|
||||
display: block;
|
||||
max-width: 640px;
|
||||
margin-top: 12px;
|
||||
color: #b91c1c;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.json-risk-main-stage {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1521,14 +1744,19 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.json-risk-meta-badge.critical {
|
||||
background: #fff1f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.json-risk-meta-badge.medium {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.json-risk-meta-badge.low {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.json-risk-meta-badge.test-passed {
|
||||
|
||||
@@ -460,6 +460,15 @@ tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
tbody tr.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
tbody tr.is-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.skill-name-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
@@ -534,6 +543,11 @@ tbody tr:hover {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.status-pill.info {
|
||||
background: #e0f2fe;
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.status-pill.danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
|
||||
499
web/src/assets/styles/views/budget-center-view.css
Normal file
499
web/src/assets/styles/views/budget-center-view.css
Normal file
@@ -0,0 +1,499 @@
|
||||
.budget-center-page {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.budget-local-head {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.budget-local-head h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-summary-card {
|
||||
min-height: 118px;
|
||||
padding: 22px 28px;
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
border-right: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
.budget-summary-card:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.summary-icon.green {
|
||||
background: #e8f7ef;
|
||||
color: #07965f;
|
||||
}
|
||||
|
||||
.summary-icon.blue {
|
||||
background: #edf4ff;
|
||||
color: #2f7fd7;
|
||||
}
|
||||
|
||||
.summary-icon.orange {
|
||||
background: #fff4e5;
|
||||
color: #df9300;
|
||||
}
|
||||
|
||||
.budget-summary-card span:not(.summary-icon) {
|
||||
display: block;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.budget-summary-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.budget-summary-card em {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: #8a94a6;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.budget-filter-bar {
|
||||
min-height: 62px;
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 12px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.budget-filter-bar label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.budget-filter-bar select,
|
||||
.budget-table-foot select {
|
||||
height: 34px;
|
||||
min-width: 150px;
|
||||
border: 1px solid #dbe2ec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
padding: 0 34px 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.budget-primary-btn {
|
||||
margin-left: auto;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: #0aa66f;
|
||||
color: #fff;
|
||||
padding: 0 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-work-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-department-panel,
|
||||
.budget-table-panel,
|
||||
.budget-chart-panel,
|
||||
.budget-alert-panel {
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-department-panel header,
|
||||
.budget-table-panel > header,
|
||||
.budget-card-head {
|
||||
min-height: 48px;
|
||||
padding: 13px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
.budget-department-panel strong,
|
||||
.budget-table-panel > header strong,
|
||||
.budget-card-head strong {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.department-search {
|
||||
position: relative;
|
||||
margin: 12px 14px 8px;
|
||||
}
|
||||
|
||||
.department-search i {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #9aa5b5;
|
||||
}
|
||||
|
||||
.department-search input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
padding: 0 12px 0 34px;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 12px 16px;
|
||||
}
|
||||
|
||||
.department-list button {
|
||||
height: 38px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: #4b5563;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.department-list button.active {
|
||||
background: #e9f7f1;
|
||||
color: #07965f;
|
||||
}
|
||||
|
||||
.budget-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.budget-table-panel table {
|
||||
width: 100%;
|
||||
min-width: 1040px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.budget-table-panel th,
|
||||
.budget-table-panel td {
|
||||
padding: 13px 18px;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
border-right: 1px solid #edf1f6;
|
||||
color: #273142;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.budget-table-panel th:last-child,
|
||||
.budget-table-panel td:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.budget-table-panel th {
|
||||
background: #fafbfd;
|
||||
color: #1f2937;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-rate {
|
||||
width: 96px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.budget-rate span {
|
||||
color: #273142;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.budget-rate div {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #e9edf3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-rate em {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.budget-rate em.ok {
|
||||
background: #13a66b;
|
||||
}
|
||||
|
||||
.budget-rate em.warn {
|
||||
background: #f2a51a;
|
||||
}
|
||||
|
||||
.budget-rate em.danger {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.budget-warning-red {
|
||||
color: #e24b4b !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-warning-yellow {
|
||||
color: #e3a008 !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.budget-row-actions button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1c7ed6;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-table-foot {
|
||||
min-height: 52px;
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-table-foot button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #dbe2ec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.budget-table-foot button.active {
|
||||
border-color: #10a873;
|
||||
color: #10a873;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-table-foot span {
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.budget-bottom-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.82fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-card-head button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1c7ed6;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.budget-chart-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.legend-line {
|
||||
width: 18px;
|
||||
height: 0;
|
||||
border-top: 2px dashed #2f7fd7;
|
||||
}
|
||||
|
||||
.legend-line.used {
|
||||
border-top-style: solid;
|
||||
border-top-color: #13a66b;
|
||||
}
|
||||
|
||||
.budget-chart-panel {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.budget-chart-panel .budget-trend-chart {
|
||||
margin: 12px 18px 0;
|
||||
}
|
||||
|
||||
.budget-alert-list {
|
||||
display: grid;
|
||||
padding: 12px 20px 18px;
|
||||
}
|
||||
|
||||
.budget-alert-row {
|
||||
min-height: 46px;
|
||||
display: grid;
|
||||
grid-template-columns: 12px 120px minmax(0, 1fr) 92px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
.budget-alert-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.budget-alert-row i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.budget-alert-row i.danger {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.budget-alert-row i.warn {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.budget-alert-row i.ok {
|
||||
background: #13a66b;
|
||||
}
|
||||
|
||||
.budget-alert-row strong {
|
||||
color: #273142;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-alert-row span {
|
||||
min-width: 0;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.budget-alert-row time {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.budget-summary-grid,
|
||||
.budget-bottom-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.budget-work-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.budget-department-panel {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.budget-summary-grid,
|
||||
.budget-bottom-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.budget-filter-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.budget-filter-bar label,
|
||||
.budget-filter-bar select,
|
||||
.budget-primary-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.budget-alert-row {
|
||||
grid-template-columns: 12px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.budget-alert-row span,
|
||||
.budget-alert-row time {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
@@ -647,6 +647,10 @@
|
||||
box-shadow: 0 10px 22px rgba(226, 232, 240, 0.48);
|
||||
}
|
||||
|
||||
.message-bubble-application-preview {
|
||||
max-width: min(100%, 980px);
|
||||
}
|
||||
|
||||
.message-bubble-review-risk-low {
|
||||
border-color: rgba(37, 99, 235, 0.72);
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.72), rgba(255, 255, 255, 0.96));
|
||||
@@ -759,6 +763,170 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.application-preview-table {
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d7e4f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
font-size: var(--wb-fs-bubble);
|
||||
}
|
||||
|
||||
.application-preview-footer {
|
||||
margin-top: 12px;
|
||||
color: #334155;
|
||||
font-size: var(--wb-fs-bubble);
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.application-preview-row {
|
||||
display: grid;
|
||||
grid-template-columns: 108px minmax(0, 1fr);
|
||||
min-height: 38px;
|
||||
border-top: 1px solid #e6edf5;
|
||||
}
|
||||
|
||||
.application-preview-row.editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.application-preview-row.editable:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.application-preview-row.editable:hover .application-preview-label,
|
||||
.application-preview-row.editable:hover .application-preview-value {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.application-preview-row.editable.missing:hover .application-preview-value {
|
||||
background: #fff4e6;
|
||||
}
|
||||
|
||||
.application-preview-row.editable:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.application-preview-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.application-preview-row.head {
|
||||
min-height: 34px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: var(--wb-fs-caption);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-preview-row > span {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.application-preview-label {
|
||||
border-right: 1px solid #e6edf5;
|
||||
background: #fbfdff;
|
||||
color: #64748b;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.application-preview-value {
|
||||
position: relative;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.application-preview-row.missing .application-preview-value {
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.application-preview-row.highlight .application-preview-label {
|
||||
background: #f0fdf4;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.application-preview-row.highlight .application-preview-value {
|
||||
background: #f7fee7;
|
||||
color: #166534;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.application-preview-row.highlight.missing .application-preview-value {
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.application-preview-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.application-preview-edit-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.application-preview-edit-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.application-preview-row:hover .application-preview-edit-btn,
|
||||
.application-preview-edit-btn:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.application-preview-edit-btn:hover:not(:disabled),
|
||||
.application-preview-edit-btn:focus-visible {
|
||||
border-color: #bfdbfe;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.application-preview-edit-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.application-preview-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
padding: 0 9px;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.application-preview-select {
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(ul),
|
||||
.message-answer-markdown :deep(ol) {
|
||||
margin: 0;
|
||||
|
||||
@@ -725,12 +725,18 @@
|
||||
}
|
||||
|
||||
.hero-fact-grid {
|
||||
grid-template-columns: repeat(5, minmax(132px, 1fr));
|
||||
overflow-x: auto;
|
||||
grid-template-columns: minmax(280px, 1.4fr) repeat(3, minmax(0, 1fr));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hero-fact {
|
||||
min-width: 132px;
|
||||
min-width: 0;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.hero-fact strong {
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.detail-expense-table {
|
||||
@@ -822,6 +828,18 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.application-detail-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.application-detail-fact {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.application-detail-fact:nth-child(2) {
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
.hero-fact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(240px, 1.25fr) repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@@ -216,13 +216,20 @@
|
||||
}
|
||||
|
||||
.hero-fact strong {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-fact:first-child strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hero-fact strong.amount {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
@@ -489,6 +496,20 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-card-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-card-title-with-icon i {
|
||||
margin-top: 1px;
|
||||
color: #334155;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-card-head p {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
@@ -571,6 +592,70 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.application-detail-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.application-detail-fact {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(96px, 28%) minmax(0, 1fr);
|
||||
min-height: 48px;
|
||||
border-top: 1px solid #edf2f7;
|
||||
border-left: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.application-detail-fact:nth-child(-n + 2) {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.application-detail-fact:nth-child(2n + 1) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.application-detail-fact span,
|
||||
.application-detail-fact strong {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 11px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.application-detail-fact span {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.application-detail-fact strong {
|
||||
border-left: 1px solid #edf2f7;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.application-detail-fact.highlight span {
|
||||
background: #eefcf6;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.application-detail-fact.highlight strong {
|
||||
background: #f6fef9;
|
||||
}
|
||||
|
||||
.application-detail-fact.emphasis strong {
|
||||
color: #047857;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.detail-note-editor {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -640,6 +725,54 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
|
||||
.application-leader-opinion {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.application-leader-opinion-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.application-leader-opinion-head span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
font-weight: 850;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.application-leader-opinion-head span i {
|
||||
margin-top: 1px;
|
||||
color: #334155;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.application-leader-opinion-head strong {
|
||||
color: #047857;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inline-leader-opinion {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.application-leader-opinion-display {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.detail-expense-table {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
|
||||
142
web/src/components/charts/BudgetTrendChart.vue
Normal file
142
web/src/components/charts/BudgetTrendChart.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="budget-trend-chart">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend)
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
budget: { type: Array, required: true },
|
||||
used: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const progress = useAnimationProgress([
|
||||
() => props.labels,
|
||||
() => props.budget,
|
||||
() => props.used
|
||||
], 1000)
|
||||
|
||||
const scaleSeries = (series) =>
|
||||
series.map((value) => Number((Number(value || 0) * progress.value).toFixed(2)))
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '预算',
|
||||
data: scaleSeries(props.budget),
|
||||
borderColor: '#2f7fd7',
|
||||
backgroundColor: 'rgba(47, 127, 215, 0.08)',
|
||||
borderDash: [7, 5],
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: '#ffffff',
|
||||
pointBorderColor: '#2f7fd7',
|
||||
pointBorderWidth: 2,
|
||||
tension: 0.34,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '已发生',
|
||||
data: scaleSeries(props.used),
|
||||
borderColor: '#13a66b',
|
||||
backgroundColor: 'rgba(19, 166, 107, 0.12)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: '#ffffff',
|
||||
pointBorderColor: '#13a66b',
|
||||
pointBorderWidth: 2,
|
||||
tension: 0.34,
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
animation: {
|
||||
duration: 760,
|
||||
easing: 'easeOutQuart'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
bodyColor: '#475569',
|
||||
titleColor: '#0f172a',
|
||||
padding: 12,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label(context) {
|
||||
const value = Number(context.parsed.y || 0)
|
||||
return `${context.dataset.label}: ${value.toLocaleString('zh-CN')} 元`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 12 }
|
||||
},
|
||||
border: { display: false }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 12000000,
|
||||
grid: {
|
||||
color: '#edf2f7',
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 12 },
|
||||
stepSize: 3000000,
|
||||
callback(value) {
|
||||
if (value === 0) return '0'
|
||||
return `${Number(value) / 10000}万`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-trend-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,7 @@
|
||||
>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span class="nav-label">{{ item.displayLabel }}</span>
|
||||
<span v-if="item.hasNewMessage" class="nav-unread-dot" aria-hidden="true"></span>
|
||||
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -83,7 +84,7 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: { type: Array, required: true },
|
||||
@@ -113,19 +114,17 @@ const props = defineProps({
|
||||
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
|
||||
|
||||
const {
|
||||
badgeLabel: approvalBadgeLabel,
|
||||
refreshApprovalInbox,
|
||||
startApprovalInboxPolling,
|
||||
stopApprovalInboxPolling
|
||||
} = useApprovalInbox()
|
||||
hasUnread: documentInboxHasUnread,
|
||||
refreshDocumentInbox,
|
||||
startDocumentInboxPolling,
|
||||
stopDocumentInboxPolling
|
||||
} = useDocumentCenterInbox()
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '财务总览' },
|
||||
workbench: { label: '个人工作台' },
|
||||
documents: { label: '单据中心' },
|
||||
requests: { label: '报销中心' },
|
||||
approval: { label: '审批中心' },
|
||||
archive: { label: '归档中心' },
|
||||
budget: { label: '预算中心' },
|
||||
policies: { label: '知识管理' },
|
||||
audit: { label: '任务规则中心' },
|
||||
logs: { label: '日志管理' },
|
||||
@@ -137,13 +136,14 @@ const decoratedNavItems = computed(() =>
|
||||
props.navItems.map((item) => ({
|
||||
...item,
|
||||
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
||||
badge: item.id === 'approval' ? approvalBadgeLabel.value : sidebarMeta[item.id]?.badge
|
||||
hasNewMessage: item.id === 'documents' ? documentInboxHasUnread.value : false,
|
||||
badge: sidebarMeta[item.id]?.badge
|
||||
}))
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void refreshApprovalInbox()
|
||||
startApprovalInboxPolling()
|
||||
void refreshDocumentInbox()
|
||||
startDocumentInboxPolling()
|
||||
})
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopApprovalInboxPolling()
|
||||
stopDocumentInboxPolling()
|
||||
closeCollapsedUserMenuNow()
|
||||
})
|
||||
</script>
|
||||
@@ -463,6 +463,16 @@ onBeforeUnmount(() => {
|
||||
opacity var(--rail-fade-duration) var(--rail-motion-ease);
|
||||
}
|
||||
|
||||
.nav-unread-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.26);
|
||||
}
|
||||
|
||||
.rail-user {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -668,6 +678,14 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-collapsed .nav-unread-dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.rail-collapsed {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -47,26 +47,11 @@
|
||||
<div
|
||||
v-else
|
||||
class="risk-rule-flow-svg-viewport"
|
||||
@mousedown="onDragStart"
|
||||
@touchstart="onTouchStart"
|
||||
@dblclick="resetZoom"
|
||||
>
|
||||
<div
|
||||
class="risk-rule-flow-svg-canvas"
|
||||
:style="transformStyle"
|
||||
v-html="displaySvg"
|
||||
></div>
|
||||
<div class="diagram-zoom-controls" @mousedown.stop @touchstart.stop>
|
||||
<button class="zoom-btn" @click="zoomIn" title="放大">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
</button>
|
||||
<button class="zoom-btn" @click="zoomOut" title="缩小">
|
||||
<i class="mdi mdi-minus"></i>
|
||||
</button>
|
||||
<button class="zoom-btn" @click="resetZoom" title="重置">
|
||||
<i class="mdi mdi-arrow-expand-all"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,87 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
|
||||
const scale = ref(1)
|
||||
const translateX = ref(0)
|
||||
const translateY = ref(0)
|
||||
const isDragging = ref(false)
|
||||
const dragStart = { x: 0, y: 0 }
|
||||
|
||||
const transformStyle = computed(() => ({
|
||||
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value})`,
|
||||
transformOrigin: 'center center',
|
||||
transition: isDragging.value ? 'none' : 'transform 0.15s ease-out'
|
||||
}))
|
||||
|
||||
function onDragStart(e) {
|
||||
if (e.button !== 0) return
|
||||
isDragging.value = true
|
||||
dragStart.x = e.clientX - translateX.value
|
||||
dragStart.y = e.clientY - translateY.value
|
||||
|
||||
window.addEventListener('mousemove', onDragging)
|
||||
window.addEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
function onDragging(e) {
|
||||
if (!isDragging.value) return
|
||||
translateX.value = e.clientX - dragStart.x
|
||||
translateY.value = e.clientY - dragStart.y
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging.value = false
|
||||
window.removeEventListener('mousemove', onDragging)
|
||||
window.removeEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (e.touches.length !== 1) return
|
||||
isDragging.value = true
|
||||
const touch = e.touches[0]
|
||||
dragStart.x = touch.clientX - translateX.value
|
||||
dragStart.y = touch.clientY - translateY.value
|
||||
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
window.addEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!isDragging.value || e.touches.length !== 1) return
|
||||
e.preventDefault()
|
||||
const touch = e.touches[0]
|
||||
translateX.value = touch.clientX - dragStart.x
|
||||
translateY.value = touch.clientY - dragStart.y
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
isDragging.value = false
|
||||
window.removeEventListener('touchmove', onTouchMove)
|
||||
window.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale.value = Math.min(scale.value + 0.15, 3)
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale.value = Math.max(scale.value - 0.15, 0.4)
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
scale.value = 1
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onDragging)
|
||||
window.removeEventListener('mouseup', onDragEnd)
|
||||
window.removeEventListener('touchmove', onTouchMove)
|
||||
window.removeEventListener('touchend', onTouchEnd)
|
||||
})
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
svg: { type: String, default: '' },
|
||||
@@ -188,6 +93,12 @@ const PALETTES = {
|
||||
accentDark: '#b91c1c',
|
||||
border: '#fecaca',
|
||||
surface: '#fef2f2'
|
||||
},
|
||||
critical: {
|
||||
accent: '#991b1b',
|
||||
accentDark: '#7f1d1d',
|
||||
border: '#fca5a5',
|
||||
surface: '#fff1f2'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +109,8 @@ const DRAWIO_PALETTES = {
|
||||
green: { fill: '#ffffff', stroke: '#e2e8f0' },
|
||||
low: { fill: '#eff6ff', stroke: '#bfdbfe' },
|
||||
medium: { fill: '#fff7ed', stroke: '#fed7aa' },
|
||||
high: { fill: '#fef2f2', stroke: '#fecaca' }
|
||||
high: { fill: '#fef2f2', stroke: '#fecaca' },
|
||||
critical: { fill: '#fff1f2', stroke: '#fca5a5' }
|
||||
}
|
||||
|
||||
function normalizeText(value, fallback = '') {
|
||||
@@ -222,7 +134,11 @@ function isSafeSvg(value) {
|
||||
}
|
||||
|
||||
function isCurrentDisplaySvg(value) {
|
||||
return isSafeSvg(value) && value.includes('data-risk-flow-style="review-node-only"')
|
||||
return (
|
||||
isSafeSvg(value) &&
|
||||
value.includes('data-risk-flow-style="review-node-only"') &&
|
||||
value.includes('data-risk-flow-detail="logic-v2"')
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePalette(severity) {
|
||||
@@ -262,10 +178,15 @@ function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function truncateText(value, length) {
|
||||
const text = normalizeText(value)
|
||||
return text.length <= length ? text : `${text.slice(0, Math.max(0, length - 1))}…`
|
||||
}
|
||||
|
||||
function node(title, body, x, y, width, height, type = 'blue') {
|
||||
const palette = DRAWIO_PALETTES[type] || DRAWIO_PALETTES.blue
|
||||
return `<g class="drawio-node">
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="3" ry="3" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" ry="6" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2"/>
|
||||
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
|
||||
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', '#475569', 11)}
|
||||
</g>`
|
||||
@@ -277,7 +198,7 @@ function diamond(title, body, x, y, width, height) {
|
||||
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
|
||||
const palette = DRAWIO_PALETTES.yellow
|
||||
return `<g class="drawio-node">
|
||||
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
|
||||
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2"/>
|
||||
<text x="${cx}" y="${cy - 8}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
|
||||
${textLines(wrapText(body, 8, 2), cx, cy + 12, 'middle', '#475569', 10.2)}
|
||||
</g>`
|
||||
@@ -291,6 +212,24 @@ function note(body) {
|
||||
</g>`
|
||||
}
|
||||
|
||||
function panel(title, rows, x, y, width, height) {
|
||||
const visibleRows = (Array.isArray(rows) ? rows : [])
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.map((row) => truncateText(row, 34))
|
||||
const renderedRows = visibleRows.length ? visibleRows : ['读取规则字段并归一化为判断事实']
|
||||
return `<g class="drawio-node panel-node">
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" ry="6" fill="#ffffff" stroke="#e2e8f0" stroke-width="1.2"/>
|
||||
<text x="${x + 16}" y="${y + 26}" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="700">${escapeSvg(title)}</text>
|
||||
${renderedRows
|
||||
.map(
|
||||
(row, index) =>
|
||||
`<text x="${x + 16}" y="${y + 48 + index * 18}" fill="#334155" font-family="${FONT}" font-size="11" font-weight="400">${escapeSvg(row)}</text>`
|
||||
)
|
||||
.join('')}
|
||||
</g>`
|
||||
}
|
||||
|
||||
const palette = computed(() => resolvePalette(props.severity))
|
||||
|
||||
const accentStyle = computed(() => ({
|
||||
@@ -323,6 +262,11 @@ const flowModel = computed(() => {
|
||||
evidence: normalizeText(props.flow?.evidence, '读取规则字段'),
|
||||
decision: normalizeText(props.flow?.decision, '判断是否命中风险'),
|
||||
basis: normalizeText(props.flow?.basis || props.flow?.decision, '根据规则字段判断是否命中风险'),
|
||||
facts: Array.isArray(props.flow?.facts) ? props.flow.facts.map(normalizeText).filter(Boolean) : [],
|
||||
conditions: Array.isArray(props.flow?.conditions)
|
||||
? props.flow.conditions.map(normalizeText).filter(Boolean)
|
||||
: [],
|
||||
hitLogic: normalizeText(props.flow?.hitLogic || props.flow?.formula),
|
||||
pass: normalizeText(props.flow?.pass, '未命中风险,继续流转'),
|
||||
fail: normalizeText(props.flow?.fail, `命中${severityLabel},进入人工复核`)
|
||||
}
|
||||
@@ -336,11 +280,12 @@ const flowSteps = computed(() => [
|
||||
{
|
||||
title: '字段取数',
|
||||
text: `读取规则所需字段,并将字段证据送入判断节点。字段:${fieldSummary.value}`,
|
||||
fields: fieldDisplays.value
|
||||
fields: flowModel.value.facts.length ? flowModel.value.facts : fieldDisplays.value
|
||||
},
|
||||
{
|
||||
title: '判断依据',
|
||||
text: flowModel.value.basis || flowModel.value.decision
|
||||
text: flowModel.value.basis || flowModel.value.decision,
|
||||
fields: flowModel.value.conditions
|
||||
}
|
||||
])
|
||||
|
||||
@@ -367,46 +312,39 @@ const displaySvg = computed(() => {
|
||||
|
||||
const flow = flowModel.value
|
||||
const severity = props.severity
|
||||
const facts = flow.facts.length ? flow.facts : fieldDisplays.value.slice(0, 4)
|
||||
const conditions = flow.conditions.length ? flow.conditions : [flow.basis || flow.decision]
|
||||
const hitLogic = flow.hitLogic || flow.basis || flow.decision
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="860" height="360" viewBox="0 0 860 360" data-risk-flow-style="review-node-only" data-risk-flow-detail="logic-v2" role="img" aria-label="风险规则流程说明">
|
||||
<defs>
|
||||
<pattern id="grid" width="16" height="16" patternUnits="userSpaceOnUse">
|
||||
<path d="M 16 0 L 0 0 0 16" fill="none" stroke="#e8ecef" stroke-width="0.75"/>
|
||||
</pattern>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#666666"/>
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#cbd5e1"/>
|
||||
</marker>
|
||||
<marker id="arrow-risk" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="${palette.value.accent}"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.08" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="760" height="280" fill="#ffffff"/>
|
||||
<rect width="760" height="280" fill="url(#grid)"/>
|
||||
<rect x="0.5" y="0.5" width="759.5" height="279.5" rx="6" fill="none" stroke="#cbd5e1" stroke-width="1"/>
|
||||
<rect width="860" height="360" fill="#ffffff"/>
|
||||
<rect x="18" y="18" width="824" height="324" rx="8" fill="none" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
|
||||
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700" letter-spacing="0.05em">RULE FLOW CANVAS</text>
|
||||
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700">RULE FLOW</text>
|
||||
|
||||
${node('业务输入', flow.start, 48, 118, 124, 60, 'neutral')}
|
||||
${node('字段取数', '读取字段证据', 214, 118, 132, 60, 'blue')}
|
||||
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
|
||||
${node('继续流转', flow.pass, 562, 74, 126, 60, 'green')}
|
||||
${node('进入复核', flow.fail, 562, 190, 126, 62, severity)}
|
||||
${note(flow.basis)}
|
||||
${node('业务输入', flow.start, 38, 142, 120, 62, 'neutral')}
|
||||
${panel('字段事实', facts, 196, 64, 240, 128)}
|
||||
${panel('判断条件', conditions, 196, 216, 382, 104)}
|
||||
${diamond('命中逻辑', hitLogic, 494, 80, 122, 122)}
|
||||
${node('继续流转', flow.pass, 688, 76, 122, 60, 'neutral')}
|
||||
${node('进入复核', flow.fail, 688, 226, 122, 68, severity)}
|
||||
|
||||
<line x1="172" y1="148" x2="214" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<line x1="346" y1="148" x2="392" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<g>
|
||||
<rect x="521" y="108" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
|
||||
<text x="532" y="120" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">否</text>
|
||||
</g>
|
||||
|
||||
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<g>
|
||||
<rect x="521" y="187" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
|
||||
<text x="532" y="199" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">是</text>
|
||||
</g>
|
||||
<path d="M 158 173 H 176 V 128 H 196" fill="none" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<line x1="316" y1="192" x2="316" y2="216" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<path d="M 436 128 H 466 V 141 H 494" fill="none" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<line x1="555" y1="216" x2="555" y2="202" stroke="#cbd5e1" stroke-width="1.35" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<path d="M 616 125 H 648 V 106 H 688" fill="none" stroke="#cbd5e1" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<text x="651" y="119" text-anchor="middle" fill="#64748b" font-family="${FONT}" font-size="10.5" font-weight="500">否</text>
|
||||
<path d="M 616 166 H 648 V 260 H 688" fill="none" stroke="${palette.value.accent}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-risk)"/>
|
||||
<text x="651" y="214" text-anchor="middle" fill="${palette.value.accentDark}" font-family="${FONT}" font-size="10.5" font-weight="700">是</text>
|
||||
</svg>`
|
||||
})
|
||||
</script>
|
||||
@@ -566,7 +504,7 @@ const displaySvg = computed(() => {
|
||||
.risk-rule-flow-svg-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
@@ -575,16 +513,11 @@ const displaySvg = computed(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.risk-rule-flow-svg-viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.risk-rule-flow-svg-canvas {
|
||||
width: 760px;
|
||||
height: 280px;
|
||||
width: 860px;
|
||||
height: 360px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -633,7 +566,7 @@ const displaySvg = computed(() => {
|
||||
}
|
||||
|
||||
.risk-rule-flow-image {
|
||||
width: min(760px, 100%);
|
||||
width: min(860px, 100%);
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
@@ -49,6 +49,31 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="message.recognitionDocuments?.length" class="risk-sim-recognition-debug">
|
||||
<span>单据识别明细</span>
|
||||
<article
|
||||
v-for="document in message.recognitionDocuments"
|
||||
:key="`${message.id}-${document.filename}`"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ document.filename || '临时单据' }}</strong>
|
||||
<em>{{ formatDocumentMeta(document) }}</em>
|
||||
</header>
|
||||
<p v-if="document.summary">摘要:{{ document.summary }}</p>
|
||||
<div v-if="document.document_fields?.length" class="risk-sim-debug-field-list">
|
||||
<b
|
||||
v-for="field in document.document_fields"
|
||||
:key="`${document.filename}-${field.key}-${field.value}`"
|
||||
>
|
||||
{{ field.label }}[{{ field.key }}]:{{ field.value }}
|
||||
</b>
|
||||
</div>
|
||||
<p v-if="document.text" class="risk-sim-debug-ocr-text">
|
||||
OCR原文:{{ trimDebugText(document.text, 800) }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="message.result" class="risk-sim-result-card" :class="message.result.severity">
|
||||
<div class="risk-sim-result-head">
|
||||
<div>
|
||||
@@ -75,6 +100,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="buildRecognizedFieldRows(message.result).length"
|
||||
class="risk-sim-recognized-fields"
|
||||
>
|
||||
<span>规则实际取用字段</span>
|
||||
<ul>
|
||||
<li v-for="field in buildRecognizedFieldRows(message.result)" :key="field.key">
|
||||
<strong>{{ field.label }}</strong>
|
||||
<em>{{ field.source }}</em>
|
||||
<b>{{ field.value }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="buildEvidenceItems(message.result).length" class="risk-sim-evidence">
|
||||
<span>判断依据</span>
|
||||
<ul>
|
||||
@@ -262,6 +301,16 @@ import {
|
||||
formatTestError,
|
||||
formatTime
|
||||
} from './riskRuleTestDialogUtils.js'
|
||||
import {
|
||||
buildDocumentBrief,
|
||||
buildEvidenceItems as buildEvidenceItemsModel,
|
||||
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
|
||||
buildResultFields as buildResultFieldsModel,
|
||||
formatDocumentMeta,
|
||||
formatFieldLabel,
|
||||
resolveFileStatusLabel,
|
||||
trimDebugText
|
||||
} from './riskRuleTestDialogDisplay.js'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -568,8 +617,9 @@ async function recognizeTemporaryFiles(files, activeSessionId) {
|
||||
messages.value.push(buildMessage(
|
||||
'assistant',
|
||||
recognizedCount
|
||||
? `已完成 ${recognizedCount} 份临时单据识别。请核对右侧识别字段,字段不足时可以直接在输入框补充。`
|
||||
: '上传文件没有提取到足够字段,暂不能直接执行规则。请在输入框补充票据城市、金额、发票号等关键信息。'
|
||||
? `已完成 ${recognizedCount} 份临时单据识别。下面会展示 OCR 结构化字段和原文片段,请先核对这些信息;字段不足时可以直接在输入框补充。`
|
||||
: '上传文件没有提取到足够字段。下面仍会展示 OCR 返回内容,方便判断是票据质量问题还是字段映射问题。请在输入框补充城市、金额、发票号等关键信息。',
|
||||
{ recognitionDocuments: documents }
|
||||
))
|
||||
} catch (error) {
|
||||
if (!isActiveSession(activeSessionId)) return
|
||||
@@ -601,52 +651,15 @@ function buildMessage(role, text, extra = {}) {
|
||||
}
|
||||
|
||||
function buildResultFields(result) {
|
||||
const values = result?.field_values && typeof result.field_values === 'object'
|
||||
? result.field_values
|
||||
: {}
|
||||
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
|
||||
key,
|
||||
label: formatFieldLabel(fields.value.find((field) => field.key === key) || { key }),
|
||||
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
|
||||
}))
|
||||
return buildResultFieldsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function buildRecognizedFieldRows(result) {
|
||||
return buildRecognizedFieldRowsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function buildEvidenceItems(result) {
|
||||
const evidence = result?.evidence && typeof result.evidence === 'object'
|
||||
? result.evidence
|
||||
: {}
|
||||
const items = []
|
||||
if (Array.isArray(evidence.failed_conditions)) {
|
||||
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
|
||||
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
|
||||
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
|
||||
items.push(`${formatFieldName(condition.left)}:${left};${formatFieldName(condition.right)}:${right}`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.missing_fields)) {
|
||||
evidence.missing_fields.slice(0, 5).forEach((field) => {
|
||||
items.push(`${formatFieldName(field)} 缺失`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.keyword_hits)) {
|
||||
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
|
||||
}
|
||||
if (evidence.condition_summary) {
|
||||
items.push(String(evidence.condition_summary))
|
||||
}
|
||||
return [...new Set(items)].slice(0, 5)
|
||||
}
|
||||
|
||||
function formatFieldLabel(field) {
|
||||
const key = String(field?.key || '').trim()
|
||||
const label = String(field?.display || field?.label || '').trim()
|
||||
if (!key) return label || '-'
|
||||
if (!label || label === key) return key
|
||||
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
|
||||
}
|
||||
|
||||
function formatFieldName(key) {
|
||||
return formatFieldLabel(fields.value.find((field) => field.key === key) || { key })
|
||||
return buildEvidenceItemsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function toAttachmentPayload(file) {
|
||||
@@ -713,23 +726,6 @@ function documentHasMeaningfulText(document) {
|
||||
)
|
||||
}
|
||||
|
||||
function buildDocumentBrief(document) {
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
if (fields.length) {
|
||||
return fields.slice(0, 4).map((field) => `${field.label}:${field.value}`).join(';')
|
||||
}
|
||||
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
|
||||
}
|
||||
|
||||
function resolveFileStatusLabel(file) {
|
||||
return file.statusText || {
|
||||
pending: '待发送',
|
||||
recognizing: '识别中',
|
||||
recognized: '已识别',
|
||||
failed: '识别失败'
|
||||
}[file.status] || '待识别'
|
||||
}
|
||||
|
||||
function buildRecognitionStepDescription() {
|
||||
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
|
||||
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'
|
||||
|
||||
110
web/src/components/shared/riskRuleTestDialogDisplay.js
Normal file
110
web/src/components/shared/riskRuleTestDialogDisplay.js
Normal file
@@ -0,0 +1,110 @@
|
||||
export function formatFieldLabel(field) {
|
||||
const key = String(field?.key || '').trim()
|
||||
const label = String(field?.display || field?.label || '').trim()
|
||||
if (!key) return label || '-'
|
||||
if (!label || label === key) return key
|
||||
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
|
||||
}
|
||||
|
||||
export function buildResultFields(result, fields = []) {
|
||||
const values = result?.field_values && typeof result.field_values === 'object'
|
||||
? result.field_values
|
||||
: {}
|
||||
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
|
||||
key,
|
||||
label: formatFieldLabel(fields.find((field) => field.key === key) || { key }),
|
||||
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildRecognizedFieldRows(result, fields = []) {
|
||||
const rows = Array.isArray(result?.recognized_fields) ? result.recognized_fields : []
|
||||
return rows.slice(0, 12).map((field, index) => ({
|
||||
key: String(field?.key || `field-${index}`),
|
||||
label: formatFieldLabel(
|
||||
fields.find((item) => item.key === field?.key) || {
|
||||
key: field?.key,
|
||||
label: field?.label
|
||||
}
|
||||
),
|
||||
source: formatRecognitionSource(field?.source),
|
||||
value: formatDebugValue(field?.value)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildEvidenceItems(result, fields = []) {
|
||||
const evidence = result?.evidence && typeof result.evidence === 'object'
|
||||
? result.evidence
|
||||
: {}
|
||||
const items = []
|
||||
if (Array.isArray(evidence.failed_conditions)) {
|
||||
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
|
||||
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
|
||||
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
|
||||
items.push(`${formatFieldName(condition.left, fields)}:${left};${formatFieldName(condition.right, fields)}:${right}`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.missing_fields)) {
|
||||
evidence.missing_fields.slice(0, 5).forEach((field) => {
|
||||
items.push(`${formatFieldName(field, fields)} 缺失`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.keyword_hits)) {
|
||||
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
|
||||
}
|
||||
if (evidence.condition_summary) {
|
||||
items.push(String(evidence.condition_summary))
|
||||
}
|
||||
return [...new Set(items)].slice(0, 5)
|
||||
}
|
||||
|
||||
export function buildDocumentBrief(document) {
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
if (fields.length) {
|
||||
return fields.slice(0, 6).map((field) => `${field.label}:${field.value}`).join(';')
|
||||
}
|
||||
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
|
||||
}
|
||||
|
||||
export function formatDocumentMeta(document) {
|
||||
const labels = [
|
||||
document?.document_type_label || '',
|
||||
document?.scene_label || '',
|
||||
document?.avg_score ? `置信度 ${Math.round(Number(document.avg_score) * 100)}%` : ''
|
||||
].filter(Boolean)
|
||||
return labels.join(' · ') || '未分类'
|
||||
}
|
||||
|
||||
export function resolveFileStatusLabel(file) {
|
||||
return file.statusText || {
|
||||
pending: '待发送',
|
||||
recognizing: '识别中',
|
||||
recognized: '已识别',
|
||||
failed: '识别失败'
|
||||
}[file.status] || '待识别'
|
||||
}
|
||||
|
||||
export function trimDebugText(text, maxLength = 800) {
|
||||
const value = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!value) return ''
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
|
||||
}
|
||||
|
||||
function formatRecognitionSource(source) {
|
||||
return {
|
||||
manual: '手动输入',
|
||||
ocr: 'OCR结构字段',
|
||||
inferred: '文本推断',
|
||||
model_refined: '模型过滤'
|
||||
}[String(source || '').trim()] || '未标注来源'
|
||||
}
|
||||
|
||||
function formatDebugValue(value) {
|
||||
if (Array.isArray(value)) return value.map((item) => String(item ?? '')).filter(Boolean).join('、') || '-'
|
||||
if (value && typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value ?? '-')
|
||||
}
|
||||
|
||||
function formatFieldName(key, fields) {
|
||||
return formatFieldLabel(fields.find((field) => field.key === key) || { key })
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useApprovalInbox } from './useApprovalInbox.js'
|
||||
import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
@@ -23,13 +22,14 @@ export function useAppShell() {
|
||||
const smartEntryOpen = ref(false)
|
||||
const smartEntryContext = ref({
|
||||
prompt: '',
|
||||
source: 'requests',
|
||||
source: 'documents',
|
||||
request: null,
|
||||
files: [],
|
||||
conversation: null,
|
||||
scope: null
|
||||
})
|
||||
const smartEntrySessionId = ref(0)
|
||||
const smartEntryRevealToken = ref(0)
|
||||
const smartEntryInvalidatedDraftClaimId = ref('')
|
||||
const selectedRequestSnapshot = ref(null)
|
||||
|
||||
@@ -49,7 +49,6 @@ export function useAppShell() {
|
||||
} = useRequests()
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const { refreshApprovalInbox } = useApprovalInbox()
|
||||
|
||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||
|
||||
@@ -83,20 +82,13 @@ export function useAppShell() {
|
||||
return null
|
||||
})
|
||||
|
||||
const detailMode = computed(() => ['app-request-detail', 'app-document-detail'].includes(route.name))
|
||||
const detailMode = computed(() => route.name === 'app-document-detail')
|
||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||
|
||||
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
|
||||
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
|
||||
const workbenchActive = computed(() => activeView.value === 'workbench')
|
||||
|
||||
watch(requestsListActive, (isActive, wasActive) => {
|
||||
if (isActive && !wasActive) {
|
||||
void reloadRequests()
|
||||
}
|
||||
})
|
||||
|
||||
watch(documentsListActive, (isActive, wasActive) => {
|
||||
if (isActive && !wasActive) {
|
||||
void reloadRequests()
|
||||
@@ -178,6 +170,10 @@ export function useAppShell() {
|
||||
}
|
||||
|
||||
function openFinancialAssistantCreate(source) {
|
||||
if (smartEntryOpen.value) {
|
||||
smartEntryRevealToken.value += 1
|
||||
return
|
||||
}
|
||||
smartEntryOpen.value = true
|
||||
smartEntryContext.value = {
|
||||
prompt: '',
|
||||
@@ -237,6 +233,7 @@ export function useAppShell() {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| normalizedClaimNo.startsWith('AP-')
|
||||
|| normalizedClaimNo.startsWith('APP-')
|
||||
)
|
||||
}
|
||||
@@ -267,6 +264,10 @@ export function useAppShell() {
|
||||
}
|
||||
|
||||
async function openSmartEntry(payload = {}) {
|
||||
if (smartEntryOpen.value) {
|
||||
smartEntryRevealToken.value += 1
|
||||
return
|
||||
}
|
||||
const conversation = await resolveSmartEntryConversation(payload)
|
||||
const scope = resolveSmartEntryClaimScope(payload)
|
||||
smartEntryOpen.value = true
|
||||
@@ -294,13 +295,12 @@ export function useAppShell() {
|
||||
await reloadRequests()
|
||||
if (status === 'submitted') {
|
||||
smartEntryOpen.value = false
|
||||
void refreshApprovalInbox()
|
||||
toast(
|
||||
isApplicationDocument
|
||||
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
||||
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`
|
||||
)
|
||||
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||
router.push({ name: 'app-documents' })
|
||||
return
|
||||
}
|
||||
toast(
|
||||
@@ -312,20 +312,18 @@ export function useAppShell() {
|
||||
|
||||
function openRequestDetail(request) {
|
||||
selectedRequestSnapshot.value = request || null
|
||||
const routeName = activeView.value === 'documents' ? 'app-document-detail' : 'app-request-detail'
|
||||
router.push({
|
||||
name: routeName,
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: request.claimId || request.id }
|
||||
})
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||
router.push({ name: 'app-documents' })
|
||||
}
|
||||
|
||||
async function handleRequestUpdated() {
|
||||
await reloadRequests()
|
||||
void refreshApprovalInbox()
|
||||
}
|
||||
|
||||
async function handleRequestDeleted(payload = {}) {
|
||||
@@ -336,9 +334,8 @@ export function useAppShell() {
|
||||
}
|
||||
|
||||
await reloadRequests()
|
||||
void refreshApprovalInbox()
|
||||
selectedRequestSnapshot.value = null
|
||||
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||
router.push({ name: 'app-documents' })
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -376,6 +373,7 @@ export function useAppShell() {
|
||||
smartEntryContext,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntryRevealToken,
|
||||
smartEntrySessionId,
|
||||
detailAlerts,
|
||||
toast,
|
||||
|
||||
176
web/src/composables/useDocumentCenterInbox.js
Normal file
176
web/src/composables/useDocumentCenterInbox.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
import {
|
||||
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
|
||||
countNewDocuments,
|
||||
readViewedDocumentKeys,
|
||||
resolveDocumentNewKey
|
||||
} from '../utils/documentCenterNewState.js'
|
||||
import { mapExpenseClaimToRequest } from './useRequests.js'
|
||||
|
||||
const SOURCE_PRIORITY = {
|
||||
owned: 1,
|
||||
approval: 2,
|
||||
archive: 3
|
||||
}
|
||||
|
||||
const documentRows = ref([])
|
||||
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
||||
const loading = ref(false)
|
||||
let refreshTimer = null
|
||||
let viewedKeysListenerAttached = false
|
||||
|
||||
function normalizeClaimText(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildDocumentInboxRow(claim, source) {
|
||||
const request = mapExpenseClaimToRequest(claim)
|
||||
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
|
||||
const documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
|
||||
const documentKey = normalizeClaimText(claimId, documentNo)
|
||||
|
||||
return documentKey
|
||||
? {
|
||||
source,
|
||||
claimId: claimId || documentKey,
|
||||
documentNo,
|
||||
documentKey: `${source}:${documentKey}`
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
function sourcePriority(row) {
|
||||
return SOURCE_PRIORITY[row?.source] || 0
|
||||
}
|
||||
|
||||
function mergeNonArchivedRows(rows) {
|
||||
const rowMap = new Map()
|
||||
|
||||
rows.filter(Boolean).forEach((row) => {
|
||||
const key = normalizeClaimText(row.claimId, row.documentNo, row.documentKey)
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = rowMap.get(key)
|
||||
if (!current || sourcePriority(row) >= sourcePriority(current)) {
|
||||
rowMap.set(key, row)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(rowMap.values())
|
||||
}
|
||||
|
||||
function uniqueRowsByNewKey(rows) {
|
||||
const seenKeys = new Set()
|
||||
|
||||
return rows.filter((row) => {
|
||||
const key = resolveDocumentNewKey(row)
|
||||
if (!key || seenKeys.has(key)) {
|
||||
return false
|
||||
}
|
||||
|
||||
seenKeys.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function mapClaimsToRows(claims, source) {
|
||||
return Array.isArray(claims)
|
||||
? claims.map((claim) => buildDocumentInboxRow(claim, source)).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
export function buildDocumentInboxRows({ ownedClaims = [], approvalClaims = [], archivedClaims = [] } = {}) {
|
||||
const ownedRows = mapClaimsToRows(ownedClaims, 'owned')
|
||||
const approvalRows = mapClaimsToRows(approvalClaims, 'approval')
|
||||
const archiveRows = mapClaimsToRows(archivedClaims, 'archive')
|
||||
|
||||
return uniqueRowsByNewKey([
|
||||
...mergeNonArchivedRows([...ownedRows, ...approvalRows]),
|
||||
...archiveRows
|
||||
])
|
||||
}
|
||||
|
||||
function refreshViewedDocumentKeys() {
|
||||
viewedDocumentKeys.value = readViewedDocumentKeys()
|
||||
}
|
||||
|
||||
function attachViewedKeysListener() {
|
||||
if (typeof window === 'undefined' || viewedKeysListenerAttached) {
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys)
|
||||
viewedKeysListenerAttached = true
|
||||
}
|
||||
|
||||
async function readClaimList(fetcher) {
|
||||
const result = await fetcher()
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
export function useDocumentCenterInbox() {
|
||||
attachViewedKeysListener()
|
||||
|
||||
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
|
||||
const hasUnread = computed(() => unreadCount.value > 0)
|
||||
|
||||
async function refreshDocumentInbox() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [ownedResult, approvalResult, archiveResult] = await Promise.allSettled([
|
||||
readClaimList(fetchExpenseClaims),
|
||||
readClaimList(fetchApprovalExpenseClaims),
|
||||
readClaimList(fetchArchivedExpenseClaims)
|
||||
])
|
||||
|
||||
documentRows.value = buildDocumentInboxRows({
|
||||
ownedClaims: ownedResult.status === 'fulfilled' ? ownedResult.value : [],
|
||||
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
|
||||
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
|
||||
})
|
||||
refreshViewedDocumentKeys()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startDocumentInboxPolling(intervalMs = 45000) {
|
||||
stopDocumentInboxPolling()
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
refreshTimer = window.setInterval(() => {
|
||||
void refreshDocumentInbox()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stopDocumentInboxPolling() {
|
||||
if (refreshTimer && typeof window !== 'undefined') {
|
||||
window.clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasUnread,
|
||||
loading,
|
||||
refreshDocumentInbox,
|
||||
startDocumentInboxPolling,
|
||||
stopDocumentInboxPolling,
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { icons } from '../data/icons.js'
|
||||
|
||||
export const appViews = ['overview', 'workbench', 'documents', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
|
||||
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'policies', 'audit', 'employees', 'logs', 'settings']
|
||||
|
||||
export const navItems = [
|
||||
{
|
||||
@@ -31,28 +31,12 @@ export const navItems = [
|
||||
desc: '统一查看申请、报销、审批与归档。'
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
label: '报销中心',
|
||||
navHint: '查看和管理报销单据',
|
||||
icon: icons.list,
|
||||
title: '报销中心',
|
||||
desc: '集中查看草稿、审批进度、票据状态与风险提示。'
|
||||
},
|
||||
{
|
||||
id: 'approval',
|
||||
label: '审批中心',
|
||||
navHint: '处理审批任务',
|
||||
icon: icons.approval,
|
||||
title: '审批中心',
|
||||
desc: '按优先级处理待审批事项,控制时效与风险。'
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
label: '归档中心',
|
||||
navHint: '查阅公司已归档财务数据',
|
||||
icon: icons.archive,
|
||||
title: '归档中心',
|
||||
desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。'
|
||||
id: 'budget',
|
||||
label: '预算中心',
|
||||
navHint: '管理预算额度、预算占用与超预算预警',
|
||||
icon: icons.budget,
|
||||
title: '预算中心',
|
||||
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
|
||||
},
|
||||
{
|
||||
id: 'policies',
|
||||
@@ -100,9 +84,7 @@ const viewRouteNames = {
|
||||
overview: 'app-overview',
|
||||
workbench: 'app-workbench',
|
||||
documents: 'app-documents',
|
||||
requests: 'app-requests',
|
||||
approval: 'app-approval',
|
||||
archive: 'app-archive',
|
||||
budget: 'app-budget',
|
||||
policies: 'app-policies',
|
||||
audit: 'app-audit',
|
||||
logs: 'app-logs',
|
||||
@@ -114,7 +96,7 @@ const routeNameViews = Object.fromEntries(
|
||||
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
|
||||
)
|
||||
|
||||
routeNameViews['app-request-detail'] = 'requests'
|
||||
routeNameViews['app-request-detail'] = 'documents'
|
||||
routeNameViews['app-document-detail'] = 'documents'
|
||||
routeNameViews['app-log-detail'] = 'logs'
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ function resolveDocumentTypeMeta(claim, typeCode) {
|
||||
const isApplication =
|
||||
explicitType === DOCUMENT_TYPE_APPLICATION
|
||||
|| explicitType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| normalizedType === 'application'
|
||||
|| normalizedType.endsWith('_application')
|
||||
|
||||
@@ -5,6 +5,7 @@ export const icons = {
|
||||
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
|
||||
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
|
||||
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
|
||||
budget: iconPath('<path d="M4 19V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14"/><path d="M4 19h16"/><path d="M8 15v-4"/><path d="M12 15V8"/><path d="M16 15v-6"/>'),
|
||||
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
|
||||
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
|
||||
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
||||
|
||||
@@ -10,7 +10,7 @@ import LoginRouteView from '../views/LoginRouteView.vue'
|
||||
import SetupRouteView from '../views/SetupRouteView.vue'
|
||||
|
||||
const appChildRoutes = appViews
|
||||
.filter((view) => view !== 'requests' && view !== 'documents')
|
||||
.filter((view) => view !== 'documents')
|
||||
.map((view) => ({
|
||||
path: view,
|
||||
name: `app-${view}`,
|
||||
@@ -71,21 +71,24 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/app/requests',
|
||||
name: 'app-requests',
|
||||
component: AppShellRouteView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
appView: 'requests'
|
||||
}
|
||||
redirect: { name: 'app-documents' }
|
||||
},
|
||||
{
|
||||
path: '/app/requests/:requestId',
|
||||
name: 'app-request-detail',
|
||||
component: AppShellRouteView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
appView: 'requests'
|
||||
}
|
||||
redirect: (to) => ({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: to.params.requestId },
|
||||
query: to.query,
|
||||
hash: to.hash
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/app/approval',
|
||||
redirect: { name: 'app-documents' }
|
||||
},
|
||||
{
|
||||
path: '/app/archive',
|
||||
redirect: { name: 'app-documents' }
|
||||
},
|
||||
{
|
||||
path: '/app/logs/:logKind/:logId',
|
||||
|
||||
@@ -220,6 +220,14 @@ export function setRiskRuleAssetEnabled(assetId, enabled, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function setRiskRuleAssetLevel(assetId, riskLevel, options = {}) {
|
||||
return apiRequest(`/agent-assets/${assetId}/risk-rule-level`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ risk_level: riskLevel }),
|
||||
headers: buildWriteHeaders(options)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
|
||||
return apiRequest(
|
||||
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchOntologyParse(payload) {
|
||||
export function fetchOntologyParse(payload, options = {}) {
|
||||
return apiRequest('/ontology/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(payload),
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'overview',
|
||||
'workbench',
|
||||
'documents',
|
||||
'requests',
|
||||
'approval',
|
||||
'archive',
|
||||
'budget',
|
||||
'policies',
|
||||
'audit',
|
||||
'logs',
|
||||
@@ -12,11 +10,10 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'settings'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'requests', 'policies'])
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver', 'finance', 'executive'],
|
||||
archive: ['finance', 'executive', 'auditor'],
|
||||
budget: ['finance', 'executive'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
@@ -56,6 +53,10 @@ export function canManageExpenseClaims(user) {
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canDeleteArchivedExpenseClaims(user) {
|
||||
return Boolean(user?.isAdmin)
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
@@ -77,6 +78,10 @@ export function canAccessAppView(user, viewId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isManagerUser(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
105
web/src/utils/applicationApproval.js
Normal file
105
web/src/utils/applicationApproval.js
Normal file
@@ -0,0 +1,105 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isEmailLike(value) {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
|
||||
}
|
||||
|
||||
function resolveDisplayName(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = normalizeText(value)
|
||||
if (normalized && !isEmailLike(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const nextDate = new Date(value)
|
||||
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = toDate(value)
|
||||
if (!date) {
|
||||
return ''
|
||||
}
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function getRiskFlags(request) {
|
||||
const flags = request?.riskFlags || request?.risk_flags_json || []
|
||||
return Array.isArray(flags) ? flags : []
|
||||
}
|
||||
|
||||
function getLatestEvent(events) {
|
||||
const sortedEvents = events
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
|
||||
.filter((item) => item.eventDate)
|
||||
.sort((left, right) => left.eventDate.getTime() - right.eventDate.getTime())
|
||||
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
|
||||
}
|
||||
|
||||
export function findLeaderApprovalEvent(request) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(request).filter((flag) => {
|
||||
const source = normalizeText(flag?.source)
|
||||
const eventType = normalizeText(flag?.event_type || flag?.eventType)
|
||||
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
|
||||
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
|
||||
return (
|
||||
source === 'manual_approval'
|
||||
&& (
|
||||
eventType === 'expense_application_approval'
|
||||
|| previousStage.includes('直属领导')
|
||||
|| previousStage.includes('领导审批')
|
||||
|| nextStage.includes('财务')
|
||||
|| nextStage.includes('审批完成')
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function buildLeaderApprovalInfo(request) {
|
||||
const event = findLeaderApprovalEvent(request)
|
||||
if (!event) {
|
||||
return {
|
||||
opinion: '',
|
||||
operator: '',
|
||||
time: '',
|
||||
generatedDraftClaimNo: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
opinion: normalizeText(event.opinion) || normalizeText(event.message),
|
||||
operator: resolveDisplayName(
|
||||
event.operator,
|
||||
event.operator_name,
|
||||
event.operatorName,
|
||||
request?.profileManager,
|
||||
request?.managerName
|
||||
),
|
||||
time: formatDateTime(event.created_at || event.createdAt),
|
||||
generatedDraftClaimNo: normalizeText(event.generated_draft_claim_no || event.generatedDraftClaimNo)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGeneratedDraftClaimNo(responsePayload) {
|
||||
const event = findLeaderApprovalEvent({
|
||||
riskFlags: responsePayload?.risk_flags_json || responsePayload?.riskFlags || []
|
||||
})
|
||||
return normalizeText(event?.generated_draft_claim_no || event?.generatedDraftClaimNo)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const APPLICATION_FIELD_PREFILLS = {
|
||||
reason: '事由:',
|
||||
days: '天数:',
|
||||
transport_mode: '出行方式:',
|
||||
amount: '预计总费用:'
|
||||
amount: '用户预估费用:'
|
||||
}
|
||||
|
||||
export function resolveSuggestedActionPrefill(action = {}) {
|
||||
|
||||
@@ -34,6 +34,7 @@ function isApplicationDocumentRequest(request) {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const STORAGE_KEY = 'x-financial.documents.viewed'
|
||||
const SCOPE_STORAGE_KEY = 'x-financial.documents.scope'
|
||||
export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT = 'x-financial.documents.viewed-change'
|
||||
|
||||
function getStorage() {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
@@ -30,6 +31,10 @@ export function writeViewedDocumentKeys(keys, storage = getStorage()) {
|
||||
}
|
||||
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(Array.from(keys).filter(Boolean)))
|
||||
|
||||
if (typeof window !== 'undefined' && storage === window.localStorage) {
|
||||
window.dispatchEvent(new CustomEvent(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT))
|
||||
}
|
||||
}
|
||||
|
||||
export function readDocumentScope(fallback, allowedScopes = [], storage = getStorage()) {
|
||||
|
||||
47
web/src/utils/documentCenterRows.js
Normal file
47
web/src/utils/documentCenterRows.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
const ARCHIVED_CLAIM_STATUSES = new Set(['approved', 'completed', 'paid'])
|
||||
|
||||
function isArchivedRequestPayload(request) {
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedStatus = String(request.status || '').trim().toLowerCase()
|
||||
const stage = String(request.approval_stage || request.approvalStage || '').trim()
|
||||
|
||||
if (stage === '归档入账' || stage === 'completed') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (stage.includes('归档') || stage.includes('入账')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
isApplicationRequestLike(request)
|
||||
&& ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
|
||||
&& ['审批完成', '申请归档'].includes(stage)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
|
||||
&& (stage === '' || stage === '归档入账' || stage === 'completed')
|
||||
}
|
||||
|
||||
export function isArchivedDocumentRow(row) {
|
||||
if (!row) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (row.archived === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isArchivedRequestPayload(row.rawRequest || row)
|
||||
}
|
||||
|
||||
export function excludeArchivedDocumentRows(rows) {
|
||||
return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row))
|
||||
}
|
||||
22
web/src/utils/documentClassification.js
Normal file
22
web/src/utils/documentClassification.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export function isApplicationRequestLike(value) {
|
||||
const explicitType = String(
|
||||
value?.documentTypeCode
|
||||
|| value?.document_type_code
|
||||
|| value?.documentType
|
||||
|| value?.document_type
|
||||
|| ''
|
||||
).trim()
|
||||
const claimNo = String(value?.claim_no || value?.claimNo || value?.documentNo || value?.id || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const typeCode = String(value?.typeCode || value?.expense_type || value?.expenseType || '').trim()
|
||||
|
||||
return (
|
||||
explicitType === 'application'
|
||||
|| explicitType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
)
|
||||
}
|
||||
106
web/src/utils/expenseApplicationDetail.js
Normal file
106
web/src/utils/expenseApplicationDetail.js
Normal file
@@ -0,0 +1,106 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isProvided(value) {
|
||||
const text = normalizeText(value)
|
||||
return Boolean(text) && !['待补充', '暂无', '无', '未知'].includes(text)
|
||||
}
|
||||
|
||||
function resolveApplicationDetailPayload(request = {}) {
|
||||
const flags = Array.isArray(request.risk_flags_json)
|
||||
? request.risk_flags_json
|
||||
: Array.isArray(request.riskFlags)
|
||||
? request.riskFlags
|
||||
: []
|
||||
const detailFlag = flags.find((flag) =>
|
||||
flag &&
|
||||
typeof flag === 'object' &&
|
||||
normalizeText(flag.source) === 'application_detail'
|
||||
)
|
||||
const detail = detailFlag?.application_detail || detailFlag?.applicationDetail || {}
|
||||
return detail && typeof detail === 'object' ? detail : {}
|
||||
}
|
||||
|
||||
function pickDetailValue(detail, request, keys = [], fallback = '') {
|
||||
for (const key of keys) {
|
||||
const value = normalizeText(detail[key] ?? request[key])
|
||||
if (isProvided(value)) return value
|
||||
}
|
||||
return normalizeText(fallback)
|
||||
}
|
||||
|
||||
export function buildApplicationDetailFactItems(request = {}) {
|
||||
const detail = resolveApplicationDetailPayload(request)
|
||||
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
|
||||
const rows = [
|
||||
{
|
||||
key: 'application_type',
|
||||
label: '申请类型',
|
||||
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: '发生时间',
|
||||
value: pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: '地点',
|
||||
value: pickDetailValue(detail, request, ['location', 'sceneTarget', 'city'], request.sceneTarget)
|
||||
},
|
||||
{
|
||||
key: 'reason',
|
||||
label: '事由',
|
||||
value: pickDetailValue(detail, request, ['reason'], request.reason)
|
||||
},
|
||||
{
|
||||
key: 'days',
|
||||
label: '天数',
|
||||
value: pickDetailValue(detail, request, ['days'])
|
||||
},
|
||||
{
|
||||
key: 'transport_mode',
|
||||
label: '出行方式',
|
||||
value: pickDetailValue(detail, request, ['transport_mode'])
|
||||
},
|
||||
{
|
||||
key: 'grade',
|
||||
label: '职级',
|
||||
value: pickDetailValue(detail, request, ['grade', 'profileGrade', 'employee_grade'], request.profileGrade),
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'lodging_daily_cap',
|
||||
label: '住宿上限/天',
|
||||
value: pickDetailValue(detail, request, ['lodging_daily_cap']),
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'subsidy_daily_cap',
|
||||
label: '补贴标准/天',
|
||||
value: pickDetailValue(detail, request, ['subsidy_daily_cap']),
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'transport_policy',
|
||||
label: '交通费用口径',
|
||||
value: pickDetailValue(detail, request, ['transport_policy'], '车票、机票暂无实时价格接口,按真实票据实报实销')
|
||||
},
|
||||
{
|
||||
key: 'policy_estimate',
|
||||
label: '规则测算参考',
|
||||
value: pickDetailValue(detail, request, ['policy_estimate']),
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '用户预估费用',
|
||||
value: pickDetailValue(detail, request, ['amount'], amountDisplay),
|
||||
highlight: true,
|
||||
emphasis: true
|
||||
}
|
||||
]
|
||||
|
||||
return rows.filter((row) => isProvided(row.value))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user