feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选 - 引入 expense_claim_status_registry 统一报销状态流转 - 完善报销草稿流程、Item Sync 与本体解析器 - 优化总览页趋势图、分页组件与请求进度步骤 - 增强报销申请快速预览、本体工具与详情展示 - 新增半年报销模拟数据种子脚本与状态审计工具 - 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
154
document/development/半年报销模拟数据/CONCEPT.md
Normal file
154
document/development/半年报销模拟数据/CONCEPT.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# 半年报销模拟数据概念文档
|
||||||
|
|
||||||
|
## 功能一句话
|
||||||
|
|
||||||
|
为本地演示环境生成 2026 年上半年公司报销、预算和员工组织样本,让财务看板与预算中心能直接呈现半年经营分析效果。
|
||||||
|
|
||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
当前容器数据库已有员工与预算基础表,但报销样本很少,无法观察半年维度的费用趋势、部门支出结构、预算使用率和风险预警效果。用户希望把公司人数扩充到 100 人,并模拟半年报销数据,用于查看整体分析和预算管控效果。
|
||||||
|
|
||||||
|
现状只读检查结果:
|
||||||
|
|
||||||
|
- `employees=82`
|
||||||
|
- `expense_claims=3`
|
||||||
|
- `budget_allocations=240`
|
||||||
|
- `budget_transactions=241`
|
||||||
|
- `risk_observations=0`
|
||||||
|
- 尚无 `SIM2026` 员工、`SIM-EXP-2026` 报销单和 `SIM-BUD-2026` 预算数据。
|
||||||
|
|
||||||
|
## 目标与非目标
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把本地演示公司员工补齐到 100 人,不删除已有员工。
|
||||||
|
- 生成 2026 年 1 月到 6 月的报销单、报销明细和风险观察样本。
|
||||||
|
- 生成或复用预算额度,并写入预算核销台账,让预算中心能看到真实使用率、预警和超支。
|
||||||
|
- 保证脚本默认 dry-run,只有显式 `--apply` 才写数据库。
|
||||||
|
- 生成完成后能用容器内 DB 统计和真实 API 返回值验证。
|
||||||
|
|
||||||
|
非目标:
|
||||||
|
|
||||||
|
- 不接入真实生产 API,不导入真实个人敏感数据。
|
||||||
|
- 不删除或重置用户已有数据;如未来需要清理模拟数据,应另走显式确认。
|
||||||
|
- 不改造预算中心、财务看板和报销审批页面结构。
|
||||||
|
- 不把模拟数据写入启动流程,避免每次启动自动膨胀数据。
|
||||||
|
|
||||||
|
## 用户与场景
|
||||||
|
|
||||||
|
- 财务负责人:查看半年费用趋势、待审批金额、风险数量和 SLA。
|
||||||
|
- 预算管理者:查看部门和费用科目的预算使用率、预警线和剩余额度。
|
||||||
|
- 产品演示者:用 100 人组织规模演示智能费控、预算中心和分析看板的联动。
|
||||||
|
|
||||||
|
## 功能能力
|
||||||
|
|
||||||
|
### 输入
|
||||||
|
|
||||||
|
- 目标员工数:默认 100。
|
||||||
|
- 模拟窗口:默认 `2026-01-01` 到 `2026-06-30`。
|
||||||
|
- 随机种子:固定值,确保样本可复现。
|
||||||
|
- 执行模式:默认 dry-run,`--apply` 写入数据库。
|
||||||
|
|
||||||
|
### 输出
|
||||||
|
|
||||||
|
- 新增员工:只补齐缺口,员工编号前缀 `SIM2026`。
|
||||||
|
- 新增报销单:编号前缀 `SIM-EXP-2026`。
|
||||||
|
- 新增明细:按报销单生成 1 到 3 条费用明细。
|
||||||
|
- 新增预算额度:编号前缀 `SIM-BUD-2026`,按部门、季度、费用科目覆盖差旅、招待、办公和通信。
|
||||||
|
- 新增预算交易:编号前缀 `SIM-BTX-2026`,对已通过、待付款、已付款和完成状态写入 `consume` 台账,对待审批状态写入 `reserve` 台账。
|
||||||
|
- 新增风险观察:编号前缀 `SIM-RISK-2026`,用于财务看板风险混合和异常数统计。
|
||||||
|
|
||||||
|
### 边界
|
||||||
|
|
||||||
|
- 如果员工数已经大于等于 100,只新增 0 人,不删除已有员工。
|
||||||
|
- 如果同编号模拟数据已存在,脚本跳过,保证重复执行不重复膨胀。
|
||||||
|
- 预算使用率通过交易台账计算,不直接改写预算余额字段。
|
||||||
|
- 预算超支样本允许存在,用于展示预算效果和预警,但需要控制比例,避免所有部门都显示异常。
|
||||||
|
|
||||||
|
## 方案设计
|
||||||
|
|
||||||
|
### 后端脚本
|
||||||
|
|
||||||
|
新增独立服务模块:
|
||||||
|
|
||||||
|
- `demo_company_simulation_seed.py`:封装模拟数据规划、dry-run 统计和 apply 写入。
|
||||||
|
|
||||||
|
新增命令脚本:
|
||||||
|
|
||||||
|
- `seed_half_year_expense_demo.py`:解析参数并调用服务模块。
|
||||||
|
|
||||||
|
### 数据策略
|
||||||
|
|
||||||
|
- 组织:复用现有 `OrganizationUnit`,优先使用部门节点和成本中心。
|
||||||
|
- 员工:补齐到 100 人,按部门规模权重分配,职级覆盖 P3-P8。
|
||||||
|
- 报销单:按员工、月份、费用类型生成,低频员工 1-2 单,高频角色 4-8 单。
|
||||||
|
- 风险:约 12%-18% 的报销单带风险标记和 `RiskObservation`。
|
||||||
|
- 预算:按部门、季度、科目创建模拟预算额度,Q2 相比 Q1 有 8%-18% 增长,部分市场、技术部门科目接近 80% 预警线。
|
||||||
|
|
||||||
|
### 运行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main \
|
||||||
|
/tmp/x-financial-server-venv/bin/python server/scripts/seed_half_year_expense_demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
写入时使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main \
|
||||||
|
/tmp/x-financial-server-venv/bin/python server/scripts/seed_half_year_expense_demo.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
## 算法与公式
|
||||||
|
|
||||||
|
### 员工缺口
|
||||||
|
|
||||||
|
$$
|
||||||
|
new\_employees = \max(target\_employees - current\_employees,\ 0)
|
||||||
|
$$
|
||||||
|
|
||||||
|
### 报销金额
|
||||||
|
|
||||||
|
每类费用按基础金额、部门系数、职级系数和月度季节系数生成:
|
||||||
|
|
||||||
|
$$
|
||||||
|
claim\_amount = base\_amount(type) \times dept\_factor \times grade\_factor \times month\_factor \times noise
|
||||||
|
$$
|
||||||
|
|
||||||
|
### 预算使用率
|
||||||
|
|
||||||
|
预算中心沿用现有计算口径:
|
||||||
|
|
||||||
|
$$
|
||||||
|
usage\_rate = \frac{reserved\_amount + consumed\_amount}{original\_amount + adjusted\_amount} \times 100
|
||||||
|
$$
|
||||||
|
|
||||||
|
### 风险样本概率
|
||||||
|
|
||||||
|
风险概率按金额分位和预算压力提升:
|
||||||
|
|
||||||
|
$$
|
||||||
|
risk\_probability = base\_risk + amount\_boost + budget\_pressure\_boost
|
||||||
|
$$
|
||||||
|
|
||||||
|
## 测试方案
|
||||||
|
|
||||||
|
- 单元测试:在 SQLite 内存库里验证 dry-run、员工补齐、幂等写入和预算交易统计。
|
||||||
|
- 容器验证:在 `x-financial-main` 内运行定向测试,单次不超过 60s。
|
||||||
|
- 运行时验证:执行 dry-run 后检查计划数量;执行 apply 前必须人工确认。
|
||||||
|
- API 验证:写入后请求财务看板和预算汇总接口,确认 JSON 中员工、报销、预算使用率和风险指标有数据。
|
||||||
|
|
||||||
|
## 指标与验收
|
||||||
|
|
||||||
|
- 员工总数达到 100。
|
||||||
|
- `SIM-EXP-2026` 半年报销单不少于 300 单。
|
||||||
|
- 预算汇总接口返回 Q1、Q2 趋势,且至少有 1 条预算预警。
|
||||||
|
- 财务看板 `has_real_data=true`,风险数、费用分类、部门排行和预算摘要均非空。
|
||||||
|
- 重复执行脚本不会新增重复模拟数据。
|
||||||
|
|
||||||
|
## 风险与开放问题
|
||||||
|
|
||||||
|
- 批量写入数据库属于高风险操作,执行 `--apply` 前必须获得用户明确确认。
|
||||||
|
- 如果当前数据库已有大量非模拟员工,脚本不会删除员工来凑精确 100 人,只保证不少于目标数。
|
||||||
|
- 财务看板趋势接口当前最多按 90 天标签解析;半年分析主要依赖预算中心 Q1/Q2 趋势和自定义日期范围。
|
||||||
|
- 如果后续要支持页面一键生成,需要另行设计权限、审计和清理机制。
|
||||||
23
document/development/半年报销模拟数据/TODO.md
Normal file
23
document/development/半年报销模拟数据/TODO.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 半年报销模拟数据 TODO
|
||||||
|
|
||||||
|
## 调研与契约
|
||||||
|
|
||||||
|
- [x] [CONCEPT: 背景与问题] 读取员工、报销、预算和财务看板现有模型,确认模拟数据要写入 `employees`、`expense_claims`、`expense_claim_items`、`budget_allocations`、`budget_transactions`、`budget_reservations` 和 `risk_observations`。
|
||||||
|
- [x] [CONCEPT: 背景与问题] 在 `x-financial-main` 容器内完成只读规模检查,当前员工 82 人、报销单 3 单、模拟前缀数据为 0。
|
||||||
|
- [x] [CONCEPT: 方案设计] 明确脚本默认 dry-run,批量写入必须使用 `--apply` 并先得到用户确认。
|
||||||
|
|
||||||
|
## 数据生成
|
||||||
|
|
||||||
|
- [x] [CONCEPT: 数据策略] 新增模拟数据服务模块,封装员工、预算、报销、明细、风险观察的生成逻辑。证据:`demo_company_simulation_seed.py` 与 `demo_company_simulation_catalog.py`。
|
||||||
|
- [x] [CONCEPT: 输入] 新增命令脚本,支持 `--target-employees`、`--start-date`、`--months`、`--seed`、`--apply`。证据:`seed_half_year_expense_demo.py`。
|
||||||
|
- [x] [CONCEPT: 边界] 实现幂等逻辑:已存在的 `SIM2026`、`SIM-EXP-2026` 和 `SIM-BUD-2026` 数据不重复创建。证据:`test_half_year_simulation_preview_and_apply_are_idempotent`。
|
||||||
|
- [x] [CONCEPT: 预算使用率] 通过 `BudgetTransaction` 和 `BudgetReservation` 形成预算使用效果,不直接改余额。证据:`test_half_year_simulation_feeds_budget_summary`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- [x] [CONCEPT: 测试方案] 新增定向单元测试,覆盖 dry-run、apply、员工补齐和幂等性。证据:`server/tests/test_demo_company_simulation_seed.py`。
|
||||||
|
- [x] [CONCEPT: 测试方案] 在容器中以 60s 超时运行定向测试。证据:`pytest -q server/tests/test_demo_company_simulation_seed.py` 通过,2 passed。
|
||||||
|
- [x] [CONCEPT: 运行命令] 执行 dry-run,输出计划写入规模。证据:dry-run 计划新增 18 名员工、495 张报销单、855 条明细、34 个预算池、459 条预算交易、83 条预占、55 条风险观察。
|
||||||
|
- [x] [CONCEPT: 风险与开放问题] 获得用户确认后执行 `--apply` 写入本地数据库。证据:`seed_half_year_expense_demo.py --apply` 成功写入。
|
||||||
|
- [x] [CONCEPT: 指标与验收] 用容器内 DB 统计确认员工数、模拟报销单、预算交易和风险观察。证据:员工 100 人,模拟报销 495 单、预算交易 459 条、风险观察 55 条。
|
||||||
|
- [x] [CONCEPT: 指标与验收] 用真实 API 验证财务看板与预算汇总 JSON 已出现半年模拟数据效果。证据:预算汇总 API 返回 `warning_count=10`、`over_budget_count=3`;财务看板 API 返回 `has_real_data=true`、`riskCount=57`。
|
||||||
167
document/development/财务看板口径重构与画像模拟/CONCEPT.md
Normal file
167
document/development/财务看板口径重构与画像模拟/CONCEPT.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# 财务看板口径重构与画像模拟概念文档
|
||||||
|
|
||||||
|
## 功能一句话
|
||||||
|
|
||||||
|
把财务看板从“审批过程展示”调整为“财务费用经营分析”,并让半年模拟数据自然形成部门、预算、风险和员工画像。
|
||||||
|
|
||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
当前财务看板存在三类偏差:
|
||||||
|
|
||||||
|
- 费用结构里直接展示 `travel_application` 等技术枚举,业务用户无法理解,且申请类口径不应混入报销费用结构。
|
||||||
|
- 风险异常分布缺少完整中文映射,`missing_material`、`budget_pressure` 等风险信号以英文或半翻译方式泄露到页面。
|
||||||
|
- 趋势图和底部卡片仍围绕审批量、审批时长展开,不符合财务看板的核心诉求。
|
||||||
|
|
||||||
|
半年模拟数据也需要服务于看板分析,不能只堆单据。它必须能支撑多部门费用排行、预算消耗、风险分布和员工画像。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 费用结构只展示费用科目中文名称,申请类技术值不裸露。
|
||||||
|
- 风险异常分布统一中文化,并覆盖预算压力、材料缺失、预算超支等常见信号。
|
||||||
|
- 趋势图改为每日报销数量和每日报销金额。
|
||||||
|
- “审批瓶颈”改为财务关注项,展示预算、待付款、材料待补、风险金额等财务指标。
|
||||||
|
- 部门排行按费用金额统计,而不是只看待处理审批金额。
|
||||||
|
- 模拟数据在写入后可生成员工行为画像快照,画像与报销单据、预算压力和风险观察一致。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不重做财务看板整体视觉框架。
|
||||||
|
- 不新增一套独立画像算法。
|
||||||
|
- 不修改生产环境数据;所有批量修复只作用于 `SIM2026`、`SIM-EXP-2026`、`SIM-BUD-2026` 等模拟前缀数据。
|
||||||
|
|
||||||
|
## 用户与场景
|
||||||
|
|
||||||
|
- 财务经理:查看半年费用趋势、部门费用结构、预算执行和风险异常。
|
||||||
|
- 部门负责人:理解本部门费用消耗和预算压力。
|
||||||
|
- 审批人:查看员工画像时,能看到基于半年模拟数据形成的费用和流程质量画像。
|
||||||
|
- 系统演示人员:用 100 人规模的模拟数据演示端到端效果。
|
||||||
|
|
||||||
|
## 功能能力
|
||||||
|
|
||||||
|
### 费用结构
|
||||||
|
|
||||||
|
输入为当前时间范围内有效报销单。
|
||||||
|
|
||||||
|
输出为费用科目金额占比:
|
||||||
|
|
||||||
|
- 排除草稿、退回、驳回、删除等非有效支出状态。
|
||||||
|
- `travel_application` 等申请类值不直接展示;若历史数据仍存在,则归一为“差旅费”或从费用结构中排除申请类虚拟项。
|
||||||
|
- 所有展示名称必须是中文。
|
||||||
|
|
||||||
|
### 风险异常分布
|
||||||
|
|
||||||
|
输入为风险观察和报销单风险标记。
|
||||||
|
|
||||||
|
输出为中文风险类型分布:
|
||||||
|
|
||||||
|
- `missing_material`:材料不完整
|
||||||
|
- `budget_pressure`:预算压力偏高
|
||||||
|
- `budget_overrun`:预算超支
|
||||||
|
- `duplicate_invoice`:重复发票
|
||||||
|
- `split_billing`:拆分报销
|
||||||
|
- `amount_outlier`:金额异常
|
||||||
|
|
||||||
|
未知枚举用“风险观察”兜底,不能把英文下划线文案直接展示给用户。
|
||||||
|
|
||||||
|
### 每日报销趋势
|
||||||
|
|
||||||
|
趋势图按天返回:
|
||||||
|
|
||||||
|
- `claimCount`:每日有效报销单数量
|
||||||
|
- `claimAmount`:每日有效报销金额
|
||||||
|
|
||||||
|
前端使用柱线组合图展示,左轴为单量,右轴为金额。
|
||||||
|
|
||||||
|
### 财务关注项
|
||||||
|
|
||||||
|
替代原“审批瓶颈”:
|
||||||
|
|
||||||
|
- 预算超支:超支预算池数量和金额。
|
||||||
|
- 预算预警:预算使用率接近上限的池数量。
|
||||||
|
- 材料待补:材料不完整风险数量。
|
||||||
|
- 风险金额:当前范围内风险单据金额。
|
||||||
|
- 待付款:已审批待付款金额。
|
||||||
|
|
||||||
|
### 员工画像
|
||||||
|
|
||||||
|
模拟数据写入后触发现有 `EmployeeBehaviorProfileService`:
|
||||||
|
|
||||||
|
- 生成 30、90、180 天画像快照。
|
||||||
|
- 画像类型沿用费用支出、流程质量、AI 使用和审批行为。
|
||||||
|
- 不伪造画像结果,只用模拟报销单、审批记录和风险数据驱动算法。
|
||||||
|
|
||||||
|
## 方案设计
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- 在 `FinanceDashboardService` 中新增费用类型与风险信号归一化方法。
|
||||||
|
- 将 `_trend` 改为统计每日有效报销数量和金额,同时保留旧字段兼容前端灰度。
|
||||||
|
- 将 `_department_ranking` 改为按有效费用金额统计。
|
||||||
|
- 将 `_bottlenecks` 的返回语义改为财务关注项,字段名暂保留,降低接口破坏面。
|
||||||
|
- 模拟数据脚本增加画像刷新入口,调用现有画像服务生成快照。
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- `TrendChart` 文案改为“报销单量”和“报销金额”。
|
||||||
|
- `OverviewView` 标题改为:
|
||||||
|
- 报销数量与金额趋势
|
||||||
|
- 部门报销排行(费用金额)
|
||||||
|
- 财务关注项
|
||||||
|
- 底部列表继续复用现有紧凑卡片样式,不引入新视觉体系。
|
||||||
|
|
||||||
|
### 数据
|
||||||
|
|
||||||
|
- 部门分布按业务权重分配,避免只有市场部或技术部。
|
||||||
|
- 近 10 日和本月窗口保证各核心部门都有可见费用。
|
||||||
|
- 风险样本覆盖材料缺失、预算压力、重复发票、金额异常等类型。
|
||||||
|
- 预算台账与报销单金额一致,能体现预警和超支。
|
||||||
|
|
||||||
|
## 算法与公式
|
||||||
|
|
||||||
|
费用金额:
|
||||||
|
|
||||||
|
$$
|
||||||
|
amount_d = \sum_{c \in C_d} claimAmount(c)
|
||||||
|
$$
|
||||||
|
|
||||||
|
其中 \(C_d\) 为某日有效状态报销单集合。
|
||||||
|
|
||||||
|
部门费用排行:
|
||||||
|
|
||||||
|
$$
|
||||||
|
deptSpend_i = \sum_{c \in C_i} claimAmount(c)
|
||||||
|
$$
|
||||||
|
|
||||||
|
预算使用率:
|
||||||
|
|
||||||
|
$$
|
||||||
|
usageRate = \frac{reservedAmount + consumedAmount}{totalAmount} \times 100\%
|
||||||
|
$$
|
||||||
|
|
||||||
|
风险金额:
|
||||||
|
|
||||||
|
$$
|
||||||
|
riskAmount = \sum_{c \in C, hasRisk(c)=true} claimAmount(c)
|
||||||
|
$$
|
||||||
|
|
||||||
|
## 测试方案
|
||||||
|
|
||||||
|
- 后端单元测试:验证费用类型中文化、风险信号中文化、趋势字段、部门排行和财务关注项。
|
||||||
|
- 容器接口测试:在 `x-financial-main:/app` 调用 `/api/v1/analytics/finance-dashboard`。
|
||||||
|
- 前端构建:使用项目现有 `npm.cmd` 构建路径。
|
||||||
|
- 数据脚本 dry-run:确认模拟修复仅作用于 `SIM` 前缀数据。
|
||||||
|
- 画像验证:确认 `employee_behavior_profile_snapshots` 生成模拟员工的快照。
|
||||||
|
|
||||||
|
## 指标与验收
|
||||||
|
|
||||||
|
- 财务看板接口不再返回 `travel_application`、`missing material`、`budget pressure` 等裸英文展示名。
|
||||||
|
- 趋势字段包含 `claimCount` 和 `claimAmount`,前端标题不再出现“审批趋势”。
|
||||||
|
- 部门排行至少覆盖 6 个核心部门的有效费用金额。
|
||||||
|
- 财务关注项不再显示审批节点或平均处理时长。
|
||||||
|
- 半年模拟数据可生成 100 人规模下的员工画像快照。
|
||||||
|
|
||||||
|
## 风险与开放问题
|
||||||
|
|
||||||
|
- 历史非模拟数据可能仍有 `待补充` 部门,当前方案只保证模拟数据合理,不强行修复历史数据。
|
||||||
|
- 批量修复模拟数据涉及数据库更新和重建模拟预算台账,执行 `--apply` 前需要用户明确确认。
|
||||||
|
- 前端浏览器验证若环境不稳定,可降级为接口 JSON、构建和容器内测试证据。
|
||||||
99
document/development/财务看板口径重构与画像模拟/STATUS_AUDIT.md
Normal file
99
document/development/财务看板口径重构与画像模拟/STATUS_AUDIT.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# 数据库状态字段审查
|
||||||
|
|
||||||
|
## 审查范围
|
||||||
|
|
||||||
|
- 容器:`x-financial-main`
|
||||||
|
- 数据库:当前运行时 PostgreSQL
|
||||||
|
- 字段范围:所有 `status`、`stage`、`approval`、`state` 相关列
|
||||||
|
- 审查方式:只读查询 `information_schema` 与各表状态值分布
|
||||||
|
|
||||||
|
## 总体结论
|
||||||
|
|
||||||
|
- 当前数据库没有 `status_code`、`state_code`、`stage_code` 这类数字状态码字段。
|
||||||
|
- 所有匹配到的状态字段类型都是 `character varying`。
|
||||||
|
- 非业务运行态表,例如 agent 运行、工具调用、预算池、风险观察,主要使用英文机器码。
|
||||||
|
- 报销主表 `expense_claims` 是当前最需要修复的表:`status` 使用英文码,`approval_stage` 同时混入英文码和中文节点名。
|
||||||
|
|
||||||
|
## 报销主表现状
|
||||||
|
|
||||||
|
`expense_claims` 当前共 498 条。
|
||||||
|
|
||||||
|
按单据类型拆分:
|
||||||
|
|
||||||
|
- 申请类单据:2 条,阶段为 `审批完成`、`直属领导审批`。
|
||||||
|
- 普通报销单:1 条,阶段为 `待提交`。
|
||||||
|
- 半年模拟报销单:495 条,主要问题都集中在这里。
|
||||||
|
|
||||||
|
`expense_claims.status` 当前值:
|
||||||
|
|
||||||
|
- `paid`:212
|
||||||
|
- `approved`:98
|
||||||
|
- `pending_payment`:67
|
||||||
|
- `finance_review`:43
|
||||||
|
- `submitted`:41
|
||||||
|
- `returned`:17
|
||||||
|
- `rejected`:13
|
||||||
|
- `draft`:7
|
||||||
|
|
||||||
|
`expense_claims.approval_stage` 当前值:
|
||||||
|
|
||||||
|
- `payment`:279
|
||||||
|
- `completed`:97
|
||||||
|
- `finance_review`:43
|
||||||
|
- `manager_review`:40
|
||||||
|
- `supplement`:17
|
||||||
|
- `rejected`:13
|
||||||
|
- `draft`:6
|
||||||
|
- `审批完成`:1
|
||||||
|
- `待提交`:1
|
||||||
|
- `直属领导审批`:1
|
||||||
|
|
||||||
|
## 问题判断
|
||||||
|
|
||||||
|
现在不是单纯中文显示问题,而是字段职责混乱:
|
||||||
|
|
||||||
|
- `status` 被当作流程机器状态使用。
|
||||||
|
- `approval_stage` 既被当作流程节点,也被历史模拟数据写成英文状态码。
|
||||||
|
- 单据中心和审批权限逻辑依赖 `submitted + 中文审批阶段`。
|
||||||
|
- 旧模拟数据中的 `finance_review/manager_review/payment/completed` 会导致审核、归档、报销单分类偏差。
|
||||||
|
|
||||||
|
## 建议契约
|
||||||
|
|
||||||
|
短期先采用当前代码最接近的契约:
|
||||||
|
|
||||||
|
- `status`:稳定机器码,继续使用英文枚举。
|
||||||
|
- `approval_stage`:当前流程节点,统一使用中文节点名。
|
||||||
|
- 前端和接口展示层:只展示中文标签,不直接暴露机器码。
|
||||||
|
|
||||||
|
中期如要数字状态码,需要单独迁移:
|
||||||
|
|
||||||
|
- 增加 `status_code`、`approval_stage_code` 或独立状态字典表。
|
||||||
|
- 保留现有字符串字段作为兼容层,避免一次性改动所有查询、权限、看板和智能体逻辑。
|
||||||
|
- 完成迁移后再逐步让业务代码改读数字码。
|
||||||
|
|
||||||
|
## 报销主表修复映射
|
||||||
|
|
||||||
|
建议先只修 `expense_claims` 的模拟数据和历史异常阶段:
|
||||||
|
|
||||||
|
- `status=finance_review` → `status=submitted`,`approval_stage=财务审批`
|
||||||
|
- `approval_stage=manager_review` → `直属领导审批`
|
||||||
|
- `approval_stage=budget_review` → `预算管理者审批`
|
||||||
|
- `approval_stage=finance_review` → `财务审批`
|
||||||
|
- `status=pending_payment` → `approval_stage=待付款`
|
||||||
|
- `status=paid` → `approval_stage=已付款`
|
||||||
|
- `status=approved` 且为报销单 → `approval_stage=归档入账`
|
||||||
|
- `status=approved` 且为申请单 → `approval_stage=审批完成`
|
||||||
|
- `status=returned` → `approval_stage=待补充`
|
||||||
|
- `status=rejected` → `approval_stage=已驳回`
|
||||||
|
- `status=draft` → `approval_stage=待提交`
|
||||||
|
|
||||||
|
## 后续动作
|
||||||
|
|
||||||
|
- 已完成:只读审查数据库状态字段。
|
||||||
|
- 已完成:模拟数据修复脚本支持 dry-run 和中文阶段归一化。
|
||||||
|
- 已完成:新增报销状态注册表,统一状态码、标签、阶段别名与历史值归一化。
|
||||||
|
- 已完成:新增只读审计脚本 `audit_expense_claim_statuses.py`,用于修复前后核对状态一致性。
|
||||||
|
- 已验证:当前 498 张单据中 495 张模拟报销单需要归一化,集中在 `payment`、`completed`、`finance_review`、`manager_review` 等历史阶段值。
|
||||||
|
- 待确认:执行模拟数据修复脚本 `--apply --refresh-profiles`。
|
||||||
|
- 待确认:执行 mock 附件脚本 `--apply`。
|
||||||
|
- 待开发:如确认要数字状态码,新增状态字典/状态码迁移方案。
|
||||||
47
document/development/财务看板口径重构与画像模拟/TODO.md
Normal file
47
document/development/财务看板口径重构与画像模拟/TODO.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 财务看板口径重构与画像模拟开发 TODO
|
||||||
|
|
||||||
|
## 调研
|
||||||
|
|
||||||
|
- [x] 核对财务看板接口字段和页面消费位置。[CONCEPT: 背景与问题] 证据:`FinanceDashboardService`、`TrendChart`、`OverviewView` 已确认。
|
||||||
|
- [x] 核对员工画像现有服务是否可复用。[CONCEPT: 员工画像] 证据:`EmployeeBehaviorProfileService` 已支持批量扫描和按员工刷新。
|
||||||
|
|
||||||
|
## 契约
|
||||||
|
|
||||||
|
- [x] 将趋势字段调整为 `claimCount`、`claimAmount`,并保留旧字段兼容。[CONCEPT: 每日报销趋势] 证据:`FinanceDashboardService._trend` 已返回新字段,定向测试通过。
|
||||||
|
- [x] 将底部 `bottlenecks` 展示替换为预算指标。[CONCEPT: 财务关注项] 证据:页面展示预算池数量、总预算、已用预算、预占预算、可用预算、预警预算池。
|
||||||
|
- [x] 补齐费用类型和风险类型中文归一化规则。[CONCEPT: 费用结构] 证据:接口 JSON 不再包含 `travel_application`、`missing_material`、`budget_pressure`。
|
||||||
|
- [x] 建立报销状态注册表,集中管理状态码、中文标签、阶段别名和历史值归一化。[CONCEPT: 数据] 证据:`expense_claim_status_registry.py` 已新增。
|
||||||
|
- [x] 将财务看板主指标改为财务口径,移除风险异常展示。[CONCEPT: 指标与验收] 证据:KPI 改为本期报销金额、报销单数、待付款金额、单均金额、预算使用率、付款完成率。
|
||||||
|
|
||||||
|
## 后端
|
||||||
|
|
||||||
|
- [x] 修改 `FinanceDashboardService` 的费用结构、趋势、部门排行、个人排行、高额单据和预算指标计算。[CONCEPT: 方案设计] 证据:`server/src/app/services/finance_dashboard.py` 已更新。
|
||||||
|
- [x] 补充后端定向测试,覆盖英文枚举不外露和趋势字段。[CONCEPT: 测试方案] 证据:`test_finance_dashboard_uses_financial_terms_instead_of_approval_terms` 已新增。
|
||||||
|
|
||||||
|
## 前端
|
||||||
|
|
||||||
|
- [x] 修改 `TrendChart` 为报销单量和报销金额图。[CONCEPT: 前端] 证据:`TrendChart.vue` 已改为双轴单量/金额。
|
||||||
|
- [x] 修改财务看板标题和底部列表文案。[CONCEPT: 前端] 证据:`OverviewView.vue` 标题已更新。
|
||||||
|
- [x] 确认页面不再出现审批趋势、审批瓶颈文案。[CONCEPT: 指标与验收] 证据:`rg` 检查财务看板相关文案已清理。
|
||||||
|
- [x] 将趋势拆为“每日报销金额”和“每日报销数量”两个单指标图。[CONCEPT: 每日报销趋势] 证据:`OverviewView.vue` 和 `TrendChart.vue` 已更新。
|
||||||
|
- [x] 新增个人报销排行和本月高额单据列表。[CONCEPT: 指标与验收] 证据:财务看板模板已新增 `个人报销排行(本月)`、`本月高额单据`。
|
||||||
|
- [x] 移除财务页“财务关注项”卡片,新增预算指标网格。[CONCEPT: 指标与验收] 证据:财务页模板已展示 `预算指标`,不再展示 `财务关注项`。
|
||||||
|
|
||||||
|
## 数据与画像
|
||||||
|
|
||||||
|
- [x] 修复半年模拟数据部门分布脚本,保持 dry-run 可审计。[CONCEPT: 数据] 证据:`repair_half_year_expense_demo_distribution.py` dry-run 返回六部门重分布计划。
|
||||||
|
- [x] 为模拟数据写入脚本增加画像刷新入口。[CONCEPT: 员工画像] 证据:seed 与 repair 脚本均支持 `--refresh-profiles`。
|
||||||
|
- [x] 将模拟数据修复脚本中的审批阶段规范为中文业务阶段。[CONCEPT: 数据] 证据:待审单统一为 `submitted + 财务审批/直属领导审批`,归档/付款阶段写入中文阶段。
|
||||||
|
- [x] 增加报销状态只读审计脚本。[CONCEPT: 指标与验收] 证据:`audit_expense_claim_statuses.py` 可输出需要归一化的状态组合。
|
||||||
|
- [x] 提高半年模拟数据单据密度。[CONCEPT: 数据] 证据:seed dry-run 计划在现有 495 单基础上新增 690 单,总量约 1185 单。
|
||||||
|
- [ ] 在用户确认后执行模拟数据修复 `--apply`。[CONCEPT: 风险与开放问题]
|
||||||
|
- [ ] 验证模拟员工画像快照已形成。[CONCEPT: 指标与验收]
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- [x] 在 `x-financial-main` 容器内运行后端定向测试,超时不超过 60s。[CONCEPT: 测试方案] 证据:`pytest -q server/tests/test_finance_dashboard_service.py server/tests/test_demo_company_simulation_seed.py`,4 passed。
|
||||||
|
- [x] 运行前端构建或等价静态验证。[CONCEPT: 测试方案] 证据:`npm.cmd run build` 成功。
|
||||||
|
- [x] 调用财务看板 API,确认 JSON 中不再泄露英文枚举并包含新指标。[CONCEPT: 指标与验收] 证据:容器内服务调用返回 `claimCount`、`claimAmount`,英文枚举检查为 false。
|
||||||
|
- [x] 验证单据中心财务角色可以看到公司报销单与归档单。[CONCEPT: 测试方案] 证据:`test_list_claims_returns_company_reimbursements_for_finance_document_center` 与归档测试通过。
|
||||||
|
- [x] 验证财务看板真实 payload 不含风险展示文案,部门排行不含“待补充”。[CONCEPT: 指标与验收] 证据:容器内服务调用 `contains_risk_text=false`、`contains_pending_fill_department=false`。
|
||||||
|
- [x] 验证预算指标真实 payload。[CONCEPT: 指标与验收] 证据:容器内服务调用返回 6 个 `budget_metrics`,且 `contains_focus_label=false`。
|
||||||
120
server/scripts/audit_expense_claim_statuses.py
Normal file
120
server/scripts/audit_expense_claim_statuses.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIR = SERVER_DIR / "src"
|
||||||
|
if str(SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
from app.db.session import get_session_factory # noqa: E402
|
||||||
|
from app.models.financial_record import ExpenseClaim # noqa: E402
|
||||||
|
from app.services.expense_claim_status_registry import ( # noqa: E402
|
||||||
|
is_known_approval_stage,
|
||||||
|
is_known_claim_status,
|
||||||
|
normalize_expense_claim_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Audit expense claim status consistency.")
|
||||||
|
parser.add_argument("--sample-limit", type=int, default=20)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
claims = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ExpenseClaim).order_by(
|
||||||
|
ExpenseClaim.claim_no.asc(),
|
||||||
|
ExpenseClaim.created_at.asc(),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
payload = audit_claims(claims, sample_limit=max(args.sample_limit, 0))
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_claims(claims: list[ExpenseClaim], *, sample_limit: int) -> dict[str, Any]:
|
||||||
|
status_counts: Counter[str] = Counter()
|
||||||
|
stage_counts: Counter[str] = Counter()
|
||||||
|
status_stage_counts: Counter[str] = Counter()
|
||||||
|
doc_type_counts: Counter[str] = Counter()
|
||||||
|
unknown_statuses: Counter[str] = Counter()
|
||||||
|
unknown_stages: Counter[str] = Counter()
|
||||||
|
normalization_counts: Counter[str] = Counter()
|
||||||
|
samples: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for claim in claims:
|
||||||
|
status = str(claim.status or "").strip()
|
||||||
|
stage = str(claim.approval_stage or "").strip()
|
||||||
|
doc_type = _doc_type(claim)
|
||||||
|
status_counts[status or "<empty>"] += 1
|
||||||
|
stage_counts[stage or "<empty>"] += 1
|
||||||
|
status_stage_counts[f"{status or '<empty>'} | {stage or '<empty>'}"] += 1
|
||||||
|
doc_type_counts[doc_type] += 1
|
||||||
|
|
||||||
|
if not is_known_claim_status(status):
|
||||||
|
unknown_statuses[status or "<empty>"] += 1
|
||||||
|
if not is_known_approval_stage(stage):
|
||||||
|
unknown_stages[stage or "<empty>"] += 1
|
||||||
|
|
||||||
|
normalized = normalize_expense_claim_state(
|
||||||
|
status,
|
||||||
|
stage,
|
||||||
|
claim_no=claim.claim_no,
|
||||||
|
expense_type=claim.expense_type,
|
||||||
|
)
|
||||||
|
if normalized.changed:
|
||||||
|
key = (
|
||||||
|
f"{status or '<empty>'}/{stage or '<empty>'}"
|
||||||
|
f" -> {normalized.status}/{normalized.approval_stage}"
|
||||||
|
)
|
||||||
|
normalization_counts[key] += 1
|
||||||
|
if len(samples) < sample_limit:
|
||||||
|
samples.append(
|
||||||
|
{
|
||||||
|
"claim_no": claim.claim_no,
|
||||||
|
"doc_type": doc_type,
|
||||||
|
"status": status,
|
||||||
|
"approval_stage": stage,
|
||||||
|
"normalized_status": normalized.status,
|
||||||
|
"normalized_approval_stage": normalized.approval_stage,
|
||||||
|
"status_code": normalized.status_code,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"claim_count": len(claims),
|
||||||
|
"doc_type_counts": dict(doc_type_counts),
|
||||||
|
"status_counts": dict(status_counts),
|
||||||
|
"approval_stage_counts": dict(stage_counts),
|
||||||
|
"status_stage_counts": dict(status_stage_counts),
|
||||||
|
"unknown_statuses": dict(unknown_statuses),
|
||||||
|
"unknown_approval_stages": dict(unknown_stages),
|
||||||
|
"normalization_needed": sum(normalization_counts.values()),
|
||||||
|
"normalization_counts": dict(normalization_counts),
|
||||||
|
"normalization_samples": samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_type(claim: ExpenseClaim) -> str:
|
||||||
|
claim_no = str(claim.claim_no or "").strip().upper()
|
||||||
|
expense_type = str(claim.expense_type or "").strip().lower()
|
||||||
|
if claim_no.startswith(("AP-", "APP-")) or expense_type.endswith("_application"):
|
||||||
|
return "application"
|
||||||
|
if claim_no.startswith("SIM-EXP-2026"):
|
||||||
|
return "sim_reimbursement"
|
||||||
|
return "reimbursement"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
396
server/scripts/mock_half_year_expense_demo_attachments.py
Normal file
396
server/scripts/mock_half_year_expense_demo_attachments.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIR = SERVER_DIR / "src"
|
||||||
|
if str(SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
from app.db.session import get_session_factory # noqa: E402
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem # noqa: E402
|
||||||
|
from app.services.demo_company_simulation_catalog import SIM_CLAIM_PREFIX # noqa: E402
|
||||||
|
from app.services.expense_claim_attachment_storage import ( # noqa: E402
|
||||||
|
ExpenseClaimAttachmentStorage,
|
||||||
|
)
|
||||||
|
|
||||||
|
DOCUMENT_BY_ITEM_TYPE = {
|
||||||
|
"hotel": ("hotel_invoice", "酒店住宿票据", "hotel", "住宿票据"),
|
||||||
|
"hotel_ticket": ("hotel_invoice", "酒店住宿票据", "hotel", "住宿票据"),
|
||||||
|
"transport": ("transport_receipt", "乘车票据", "transport", "交通票据"),
|
||||||
|
"train_ticket": ("train_ticket", "火车/高铁票", "travel", "差旅票据"),
|
||||||
|
"flight_ticket": ("flight_itinerary", "航空行程单", "travel", "差旅票据"),
|
||||||
|
"ride_ticket": ("taxi_receipt", "出租车/网约车票据", "transport", "交通票据"),
|
||||||
|
"meal": ("meal_receipt", "餐饮发票", "meal", "餐饮票据"),
|
||||||
|
"entertainment": ("meal_receipt", "餐饮发票", "meal", "餐饮票据"),
|
||||||
|
"office": ("office_invoice", "办公用品发票", "office", "办公票据"),
|
||||||
|
"communication": ("telecom_invoice", "通信服务发票", "communication", "通信票据"),
|
||||||
|
"travel_allowance": ("allowance_sheet", "差旅补贴测算单", "travel", "差旅测算"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MockAttachmentSummary:
|
||||||
|
mode: str
|
||||||
|
sim_claims: int
|
||||||
|
sim_items: int
|
||||||
|
attachments_to_mock: int
|
||||||
|
missing_material_items: int
|
||||||
|
compliant_attachments: int
|
||||||
|
violation_attachments: int
|
||||||
|
already_mocked: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Mock attachment files and OCR metadata for half-year simulated claims."
|
||||||
|
)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Write mock attachment files.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
try:
|
||||||
|
summary = mock_attachments(db, apply=args.apply)
|
||||||
|
if args.apply:
|
||||||
|
db.commit()
|
||||||
|
print(json.dumps(summary.to_dict(), ensure_ascii=False, indent=2))
|
||||||
|
if not args.apply:
|
||||||
|
print("dry-run only; pass --apply after confirmation to write mock attachments.")
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def mock_attachments(db, *, apply: bool) -> MockAttachmentSummary:
|
||||||
|
claims = _sim_claims(db)
|
||||||
|
storage = ExpenseClaimAttachmentStorage()
|
||||||
|
attachments_to_mock = 0
|
||||||
|
missing_material_items = 0
|
||||||
|
compliant_attachments = 0
|
||||||
|
violation_attachments = 0
|
||||||
|
already_mocked = 0
|
||||||
|
sim_items = 0
|
||||||
|
|
||||||
|
for claim_index, claim in enumerate(claims, start=1):
|
||||||
|
items = list(claim.items or [])
|
||||||
|
sim_items += len(items)
|
||||||
|
for item_index, item in enumerate(items, start=1):
|
||||||
|
if _has_existing_mock(storage, item):
|
||||||
|
already_mocked += 1
|
||||||
|
continue
|
||||||
|
if _should_leave_missing(claim_index, item_index, claim):
|
||||||
|
missing_material_items += 1
|
||||||
|
if apply:
|
||||||
|
item.invoice_id = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
violated = _is_violation_sample(claim_index, item_index, claim)
|
||||||
|
attachments_to_mock += 1
|
||||||
|
violation_attachments += int(violated)
|
||||||
|
compliant_attachments += int(not violated)
|
||||||
|
if apply:
|
||||||
|
_write_mock_attachment(
|
||||||
|
storage=storage,
|
||||||
|
claim=claim,
|
||||||
|
item=item,
|
||||||
|
claim_index=claim_index,
|
||||||
|
item_index=item_index,
|
||||||
|
violated=violated,
|
||||||
|
)
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
claim.invoice_count = sum(
|
||||||
|
1 for item in items if str(item.invoice_id or "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
return MockAttachmentSummary(
|
||||||
|
mode="apply" if apply else "dry-run",
|
||||||
|
sim_claims=len(claims),
|
||||||
|
sim_items=sim_items,
|
||||||
|
attachments_to_mock=attachments_to_mock,
|
||||||
|
missing_material_items=missing_material_items,
|
||||||
|
compliant_attachments=compliant_attachments,
|
||||||
|
violation_attachments=violation_attachments,
|
||||||
|
already_mocked=already_mocked,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sim_claims(db) -> list[ExpenseClaim]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.options(selectinload(ExpenseClaim.items))
|
||||||
|
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||||
|
.order_by(ExpenseClaim.claim_no.asc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_existing_mock(storage: ExpenseClaimAttachmentStorage, item: ExpenseClaimItem) -> bool:
|
||||||
|
file_path = storage.resolve_item_path(item)
|
||||||
|
if file_path is None or not file_path.exists():
|
||||||
|
return False
|
||||||
|
metadata = storage.read_meta(file_path)
|
||||||
|
return str(metadata.get("source") or "") == "half_year_expense_demo_mock"
|
||||||
|
|
||||||
|
|
||||||
|
def _should_leave_missing(claim_index: int, item_index: int, claim: ExpenseClaim) -> bool:
|
||||||
|
if str(claim.status or "").strip().lower() in {"draft", "returned"}:
|
||||||
|
return (claim_index + item_index) % 4 == 0
|
||||||
|
return (claim_index + item_index) % 19 == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_violation_sample(claim_index: int, item_index: int, claim: ExpenseClaim) -> bool:
|
||||||
|
if claim.hermes_risk_flag or claim.risk_flags_json:
|
||||||
|
return True
|
||||||
|
return (claim_index * 7 + item_index * 3) % 11 == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _write_mock_attachment(
|
||||||
|
*,
|
||||||
|
storage: ExpenseClaimAttachmentStorage,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
claim_index: int,
|
||||||
|
item_index: int,
|
||||||
|
violated: bool,
|
||||||
|
) -> None:
|
||||||
|
document_type, document_label, scene_code, scene_label = _document_meta(item.item_type)
|
||||||
|
filename = f"{claim.claim_no}-{item_index:02d}-{document_type}.txt"
|
||||||
|
attachment_dir = storage.build_item_dir(claim.id, item.id)
|
||||||
|
attachment_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path = attachment_dir / filename
|
||||||
|
ocr_text = _ocr_text(
|
||||||
|
claim=claim,
|
||||||
|
item=item,
|
||||||
|
document_label=document_label,
|
||||||
|
claim_index=claim_index,
|
||||||
|
item_index=item_index,
|
||||||
|
violated=violated,
|
||||||
|
)
|
||||||
|
file_path.write_text(ocr_text, encoding="utf-8")
|
||||||
|
item.invoice_id = storage.to_storage_key(file_path)
|
||||||
|
storage.write_meta(
|
||||||
|
file_path,
|
||||||
|
_meta_payload(
|
||||||
|
storage_key=item.invoice_id,
|
||||||
|
filename=filename,
|
||||||
|
file_path=file_path,
|
||||||
|
claim=claim,
|
||||||
|
item=item,
|
||||||
|
document_type=document_type,
|
||||||
|
document_label=document_label,
|
||||||
|
scene_code=scene_code,
|
||||||
|
scene_label=scene_label,
|
||||||
|
ocr_text=ocr_text,
|
||||||
|
violated=violated,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_meta(item_type: str) -> tuple[str, str, str, str]:
|
||||||
|
return DOCUMENT_BY_ITEM_TYPE.get(
|
||||||
|
str(item_type or "").strip().lower(),
|
||||||
|
("invoice", "费用发票", "other", "其他票据"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ocr_text(
|
||||||
|
*,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
document_label: str,
|
||||||
|
claim_index: int,
|
||||||
|
item_index: int,
|
||||||
|
violated: bool,
|
||||||
|
) -> str:
|
||||||
|
invoice_no = f"MOCK{claim_index:04d}{item_index:02d}"
|
||||||
|
amount = _display_amount(item.item_amount)
|
||||||
|
merchant = _merchant_name(item.item_type, violated)
|
||||||
|
violation_line = (
|
||||||
|
"校验提示:票据金额或场景需要人工复核。"
|
||||||
|
if violated
|
||||||
|
else "校验提示:票据字段与报销明细一致。"
|
||||||
|
)
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
f"票据类型:{document_label}",
|
||||||
|
f"发票号码:{invoice_no}",
|
||||||
|
f"开票方:{merchant}",
|
||||||
|
f"购买方:{claim.department_name}",
|
||||||
|
f"发生日期:{item.item_date.isoformat()}",
|
||||||
|
f"发生地点:{item.item_location}",
|
||||||
|
f"金额:{amount}",
|
||||||
|
f"关联报销单:{claim.claim_no}",
|
||||||
|
violation_line,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merchant_name(item_type: str, violated: bool) -> str:
|
||||||
|
normalized = str(item_type or "").strip().lower()
|
||||||
|
if violated:
|
||||||
|
return {
|
||||||
|
"hotel": "上海云栖酒店有限公司",
|
||||||
|
"transport": "跨城交通服务商",
|
||||||
|
"office": "综合采购供应商",
|
||||||
|
"meal": "高端商务餐饮有限公司",
|
||||||
|
}.get(normalized, "异常样本供应商")
|
||||||
|
return {
|
||||||
|
"hotel": "合规住宿服务有限公司",
|
||||||
|
"transport": "合规出行服务有限公司",
|
||||||
|
"travel_allowance": "系统差旅补贴测算",
|
||||||
|
"office": "合规办公用品有限公司",
|
||||||
|
"communication": "合规通信服务有限公司",
|
||||||
|
"meal": "合规餐饮服务有限公司",
|
||||||
|
}.get(normalized, "合规票据供应商")
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_payload(
|
||||||
|
*,
|
||||||
|
storage_key: str,
|
||||||
|
filename: str,
|
||||||
|
file_path: Path,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
document_type: str,
|
||||||
|
document_label: str,
|
||||||
|
scene_code: str,
|
||||||
|
scene_label: str,
|
||||||
|
ocr_text: str,
|
||||||
|
violated: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
amount_text = _display_amount(item.item_amount)
|
||||||
|
document_info = {
|
||||||
|
"document_type": document_type,
|
||||||
|
"document_type_label": document_label,
|
||||||
|
"scene_code": scene_code,
|
||||||
|
"scene_label": scene_label,
|
||||||
|
"fields": [
|
||||||
|
{"key": "invoice_no", "label": "发票号码", "value": _invoice_no(filename)},
|
||||||
|
{"key": "invoice_date", "label": "开票日期", "value": item.item_date.isoformat()},
|
||||||
|
{"key": "amount", "label": "金额", "value": amount_text},
|
||||||
|
{"key": "location", "label": "地点", "value": str(item.item_location or "")},
|
||||||
|
{
|
||||||
|
"key": "merchant",
|
||||||
|
"label": "开票方",
|
||||||
|
"value": _merchant_name(item.item_type, violated),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
requirement_check = _requirement_payload(
|
||||||
|
violated,
|
||||||
|
item,
|
||||||
|
document_type,
|
||||||
|
document_label,
|
||||||
|
scene_code,
|
||||||
|
scene_label,
|
||||||
|
)
|
||||||
|
ocr_summary = f"{document_label},金额 {amount_text},{'需复核' if violated else '字段匹配'}。"
|
||||||
|
return {
|
||||||
|
"source": "half_year_expense_demo_mock",
|
||||||
|
"file_name": filename,
|
||||||
|
"storage_key": storage_key,
|
||||||
|
"media_type": "text/plain",
|
||||||
|
"size_bytes": file_path.stat().st_size,
|
||||||
|
"uploaded_at": datetime.now(UTC).isoformat(),
|
||||||
|
"previewable": False,
|
||||||
|
"preview_kind": "",
|
||||||
|
"preview_storage_key": "",
|
||||||
|
"preview_media_type": "",
|
||||||
|
"preview_file_name": "",
|
||||||
|
"analysis": _analysis_payload(violated, claim, item),
|
||||||
|
"document_info": document_info,
|
||||||
|
"requirement_check": requirement_check,
|
||||||
|
"ocr_status": "mocked",
|
||||||
|
"ocr_error": "",
|
||||||
|
"ocr_text": ocr_text,
|
||||||
|
"ocr_summary": ocr_summary,
|
||||||
|
"ocr_avg_score": 0.97 if not violated else 0.81,
|
||||||
|
"ocr_line_count": len(ocr_text.splitlines()),
|
||||||
|
"ocr_classification_source": "mock_rule",
|
||||||
|
"ocr_classification_confidence": 0.96 if not violated else 0.78,
|
||||||
|
"ocr_classification_evidence": [document_label, scene_label],
|
||||||
|
"ocr_warnings": ["mock违规样本"] if violated else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _analysis_payload(
|
||||||
|
violated: bool,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if violated:
|
||||||
|
return {
|
||||||
|
"severity": "warning",
|
||||||
|
"label": "需复核",
|
||||||
|
"headline": "票据字段存在合规疑点",
|
||||||
|
"summary": "系统 mock 的 OCR 字段与报销场景存在偏差,用于演示违规样本。",
|
||||||
|
"points": [
|
||||||
|
f"报销单 {claim.claim_no} 金额或场景需要人工复核。",
|
||||||
|
f"费用明细:{item.item_reason},金额 {_display_amount(item.item_amount)}。",
|
||||||
|
],
|
||||||
|
"rule_basis": ["票据金额与费用明细一致性", "票据场景与费用科目匹配"],
|
||||||
|
"suggestion": "请核对票据原件、业务事由和费用归口后再提交或付款。",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"severity": "success",
|
||||||
|
"label": "合规",
|
||||||
|
"headline": "票据字段与报销明细一致",
|
||||||
|
"summary": "系统 mock 的 OCR 字段已覆盖金额、日期、地点和票据类型。",
|
||||||
|
"points": [
|
||||||
|
f"金额 {_display_amount(item.item_amount)} 与费用明细一致。",
|
||||||
|
f"票据类型匹配 {item.item_reason}。",
|
||||||
|
],
|
||||||
|
"rule_basis": ["基础票据完整性", "金额一致性"],
|
||||||
|
"suggestion": "当前材料可作为演示合规样本。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _requirement_payload(
|
||||||
|
violated: bool,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
document_type: str,
|
||||||
|
document_label: str,
|
||||||
|
scene_code: str,
|
||||||
|
scene_label: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"matches": not violated,
|
||||||
|
"current_expense_type": str(item.item_type or "other"),
|
||||||
|
"current_expense_type_label": str(item.item_reason or "费用明细"),
|
||||||
|
"allowed_scene_labels": [scene_label],
|
||||||
|
"recognized_scene_code": scene_code,
|
||||||
|
"recognized_scene_label": scene_label,
|
||||||
|
"recognized_document_type": document_type,
|
||||||
|
"recognized_document_type_label": document_label,
|
||||||
|
"message": "材料匹配,可继续处理。" if not violated else "材料存在疑点,建议人工复核。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice_no(filename: str) -> str:
|
||||||
|
return Path(filename).stem.replace("-", "").upper()[-20:]
|
||||||
|
|
||||||
|
|
||||||
|
def _display_amount(value: Decimal | float | int | str | None) -> str:
|
||||||
|
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||||
|
return f"{amount:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
570
server/scripts/repair_half_year_expense_demo_distribution.py
Normal file
570
server/scripts/repair_half_year_expense_demo_distribution.py
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIR = SERVER_DIR / "src"
|
||||||
|
if str(SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
from app.db.session import get_session_factory # noqa: E402
|
||||||
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction # noqa: E402
|
||||||
|
from app.models.employee import Employee # noqa: E402
|
||||||
|
from app.models.financial_record import ExpenseClaim # noqa: E402
|
||||||
|
from app.models.organization import OrganizationUnit # noqa: E402
|
||||||
|
from app.services.demo_company_simulation_catalog import ( # noqa: E402
|
||||||
|
BUDGETED_STATUSES,
|
||||||
|
PENDING_STATUSES,
|
||||||
|
SIM_BUDGET_PREFIX,
|
||||||
|
SIM_CLAIM_PREFIX,
|
||||||
|
SIM_EMPLOYEE_PREFIX,
|
||||||
|
SIM_PROJECT_CODE,
|
||||||
|
SIM_RESERVATION_PREFIX,
|
||||||
|
SIM_TRANSACTION_PREFIX,
|
||||||
|
SUBJECT_LABELS,
|
||||||
|
SUCCESS_STATUSES,
|
||||||
|
target_budget_usage,
|
||||||
|
)
|
||||||
|
from app.services.demo_company_simulation_filters import is_admin_employee_like # noqa: E402
|
||||||
|
from app.services.employee_behavior_profile_service import ( # noqa: E402
|
||||||
|
EmployeeBehaviorProfileService,
|
||||||
|
)
|
||||||
|
from app.services.expense_claim_status_registry import ( # noqa: E402
|
||||||
|
normalize_expense_claim_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEPARTMENT_PLAN = (
|
||||||
|
("TECH-DEPT", Decimal("0.30")),
|
||||||
|
("MARKET-DEPT", Decimal("0.24")),
|
||||||
|
("PRODUCTION-DEPT", Decimal("0.18")),
|
||||||
|
("FINANCE-DEPT", Decimal("0.12")),
|
||||||
|
("HR-DEPT", Decimal("0.10")),
|
||||||
|
("PRESIDENT-OFFICE", Decimal("0.06")),
|
||||||
|
)
|
||||||
|
RECENT_PENDING_PER_DEPARTMENT = 3
|
||||||
|
RECENT_DATES = (
|
||||||
|
datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||||
|
datetime(2026, 6, 1, 15, 0, tzinfo=UTC),
|
||||||
|
datetime(2026, 6, 2, 6, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RepairSummary:
|
||||||
|
mode: str
|
||||||
|
sim_employees: int
|
||||||
|
sim_claims: int
|
||||||
|
employee_department_plan: dict[str, int]
|
||||||
|
claim_department_plan: dict[str, int]
|
||||||
|
recent_pending_plan: dict[str, int]
|
||||||
|
rebuilt_budget_allocations: int
|
||||||
|
rebuilt_budget_transactions: int
|
||||||
|
rebuilt_budget_reservations: int
|
||||||
|
before_all_department_amounts: dict[str, str]
|
||||||
|
before_recent_pending_amounts: dict[str, str]
|
||||||
|
after_all_department_amounts: dict[str, str]
|
||||||
|
after_recent_pending_amounts: dict[str, str]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Repair simulated half-year demo data distribution."
|
||||||
|
)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Apply repair. Default is dry-run.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--refresh-profiles",
|
||||||
|
action="store_true",
|
||||||
|
help="After --apply, refresh employee behavior profile snapshots for simulated employees.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--profile-limit", type=int, default=120)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
try:
|
||||||
|
summary = repair_distribution(db, apply=args.apply)
|
||||||
|
profile_refresh = None
|
||||||
|
if args.apply and args.refresh_profiles:
|
||||||
|
profile_refresh = _refresh_company_profiles(db, limit=args.profile_limit)
|
||||||
|
if args.apply:
|
||||||
|
db.commit()
|
||||||
|
payload = summary.to_dict()
|
||||||
|
if profile_refresh is not None:
|
||||||
|
payload["profile_refresh"] = profile_refresh
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
if not args.apply:
|
||||||
|
print("dry-run only; pass --apply after confirmation to repair simulated data.")
|
||||||
|
elif not args.refresh_profiles:
|
||||||
|
print("pass --refresh-profiles to generate employee behavior profile snapshots.")
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def repair_distribution(db, *, apply: bool) -> RepairSummary:
|
||||||
|
departments = _canonical_departments(db)
|
||||||
|
if len(departments) < len(DEPARTMENT_PLAN):
|
||||||
|
missing = [code for code, _ in DEPARTMENT_PLAN if code not in departments]
|
||||||
|
raise RuntimeError(f"missing canonical departments: {missing}")
|
||||||
|
|
||||||
|
sim_employees = _sim_employees(db)
|
||||||
|
sim_claims = _sim_claims(db)
|
||||||
|
before_all = _department_amounts(sim_claims)
|
||||||
|
before_recent = _recent_pending_amounts(sim_claims)
|
||||||
|
|
||||||
|
employee_plan = _counts_by_weight(len(sim_employees))
|
||||||
|
claim_plan = _counts_by_weight(len(sim_claims))
|
||||||
|
recent_claims = _recent_claims(sim_claims)
|
||||||
|
fixed_recent_plan = {code: RECENT_PENDING_PER_DEPARTMENT for code, _ in DEPARTMENT_PLAN}
|
||||||
|
regular_plan = {
|
||||||
|
code: max(claim_plan.get(code, 0) - fixed_recent_plan.get(code, 0), 0)
|
||||||
|
for code, _ in DEPARTMENT_PLAN
|
||||||
|
}
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
_normalize_sim_claim_workflow(sim_claims)
|
||||||
|
_redistribute_employees(sim_employees, departments, employee_plan)
|
||||||
|
db.flush()
|
||||||
|
employees_by_dept = _employees_by_department(db)
|
||||||
|
_redistribute_regular_claims(
|
||||||
|
[claim for claim in sim_claims if claim not in set(recent_claims)],
|
||||||
|
departments,
|
||||||
|
employees_by_dept,
|
||||||
|
regular_plan,
|
||||||
|
)
|
||||||
|
_repair_recent_pending_claims(recent_claims, departments, employees_by_dept)
|
||||||
|
db.flush()
|
||||||
|
_rebuild_sim_budget(db, sim_claims, departments)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
after_claims = (
|
||||||
|
_sim_claims(db)
|
||||||
|
if apply
|
||||||
|
else _preview_claims(sim_claims, departments, claim_plan)
|
||||||
|
)
|
||||||
|
after_all = _department_amounts(after_claims)
|
||||||
|
after_recent = _recent_pending_amounts(after_claims)
|
||||||
|
allocation_count, transaction_count, reservation_count = _planned_budget_counts(after_claims)
|
||||||
|
|
||||||
|
return RepairSummary(
|
||||||
|
mode="apply" if apply else "dry-run",
|
||||||
|
sim_employees=len(sim_employees),
|
||||||
|
sim_claims=len(sim_claims),
|
||||||
|
employee_department_plan=employee_plan,
|
||||||
|
claim_department_plan=claim_plan,
|
||||||
|
recent_pending_plan=fixed_recent_plan,
|
||||||
|
rebuilt_budget_allocations=allocation_count,
|
||||||
|
rebuilt_budget_transactions=transaction_count,
|
||||||
|
rebuilt_budget_reservations=reservation_count,
|
||||||
|
before_all_department_amounts=before_all,
|
||||||
|
before_recent_pending_amounts=before_recent,
|
||||||
|
after_all_department_amounts=after_all,
|
||||||
|
after_recent_pending_amounts=after_recent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_company_profiles(db, *, limit: int) -> dict[str, object]:
|
||||||
|
capped_limit = max(1, min(int(limit or 120), 500))
|
||||||
|
employees = list(
|
||||||
|
db.scalars(select(Employee).order_by(Employee.employee_no.asc())).all()
|
||||||
|
)
|
||||||
|
employee_ids = [
|
||||||
|
employee.id
|
||||||
|
for employee in employees
|
||||||
|
if not is_admin_employee_like(employee)
|
||||||
|
][:capped_limit]
|
||||||
|
service = EmployeeBehaviorProfileService(db)
|
||||||
|
snapshot_count = 0
|
||||||
|
for employee_id in employee_ids:
|
||||||
|
snapshots = service.refresh_employee_profiles(
|
||||||
|
employee_id=employee_id,
|
||||||
|
window_days=(30, 90, 180),
|
||||||
|
expense_type_scope="overall",
|
||||||
|
source_task_type="half_year_expense_demo_repair",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
snapshot_count += len(snapshots)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"target_employee_count": len(employee_ids),
|
||||||
|
"snapshot_count": snapshot_count,
|
||||||
|
"window_days": [30, 90, 180],
|
||||||
|
"source_task_type": "half_year_expense_demo_repair",
|
||||||
|
"scope": "all_non_admin_employees",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_departments(db) -> dict[str, OrganizationUnit]:
|
||||||
|
department_codes = [code for code, _weight in DEPARTMENT_PLAN]
|
||||||
|
rows = db.scalars(
|
||||||
|
select(OrganizationUnit).where(OrganizationUnit.unit_code.in_(department_codes))
|
||||||
|
).all()
|
||||||
|
return {row.unit_code: row for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _sim_employees(db) -> list[Employee]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(Employee)
|
||||||
|
.options(selectinload(Employee.organization_unit))
|
||||||
|
.where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
|
||||||
|
.order_by(Employee.employee_no.asc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sim_claims(db) -> list[ExpenseClaim]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.options(selectinload(ExpenseClaim.items))
|
||||||
|
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||||
|
.order_by(ExpenseClaim.claim_no.asc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sim_claim_workflow(claims: list[ExpenseClaim]) -> None:
|
||||||
|
for claim in claims:
|
||||||
|
normalized = normalize_expense_claim_state(
|
||||||
|
claim.status,
|
||||||
|
claim.approval_stage,
|
||||||
|
claim_no=claim.claim_no,
|
||||||
|
expense_type=claim.expense_type,
|
||||||
|
is_application_claim=False,
|
||||||
|
)
|
||||||
|
claim.status = normalized.status
|
||||||
|
claim.approval_stage = normalized.approval_stage
|
||||||
|
|
||||||
|
|
||||||
|
def _counts_by_weight(total: int) -> dict[str, int]:
|
||||||
|
raw = [(code, total * weight) for code, weight in DEPARTMENT_PLAN]
|
||||||
|
counts = {code: int(value) for code, value in raw}
|
||||||
|
remainder = total - sum(counts.values())
|
||||||
|
remainder_order = sorted(
|
||||||
|
raw,
|
||||||
|
key=lambda item: item[1] - int(item[1]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for code, _value in remainder_order[:remainder]:
|
||||||
|
counts[code] += 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _redistribute_employees(
|
||||||
|
employees: list[Employee],
|
||||||
|
departments: dict[str, OrganizationUnit],
|
||||||
|
plan: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
index = 0
|
||||||
|
for code, _weight in DEPARTMENT_PLAN:
|
||||||
|
department = departments[code]
|
||||||
|
for employee in employees[index : index + plan.get(code, 0)]:
|
||||||
|
employee.organization_unit = department
|
||||||
|
employee.cost_center = department.cost_center
|
||||||
|
employee.location = department.location
|
||||||
|
employee.finance_owner_name = f"{department.name}财务BP"
|
||||||
|
index += plan.get(code, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _employees_by_department(db) -> dict[str, list[Employee]]:
|
||||||
|
rows = db.scalars(
|
||||||
|
select(Employee)
|
||||||
|
.options(selectinload(Employee.organization_unit))
|
||||||
|
.where(Employee.organization_unit_id.is_not(None))
|
||||||
|
.order_by(Employee.employee_no.asc())
|
||||||
|
).all()
|
||||||
|
grouped: dict[str, list[Employee]] = defaultdict(list)
|
||||||
|
for employee in rows:
|
||||||
|
unit = employee.organization_unit
|
||||||
|
if unit is not None and unit.unit_code:
|
||||||
|
grouped[unit.unit_code].append(employee)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def _redistribute_regular_claims(
|
||||||
|
claims: list[ExpenseClaim],
|
||||||
|
departments: dict[str, OrganizationUnit],
|
||||||
|
employees_by_dept: dict[str, list[Employee]],
|
||||||
|
plan: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
index = 0
|
||||||
|
for code, _weight in DEPARTMENT_PLAN:
|
||||||
|
department = departments[code]
|
||||||
|
employees = employees_by_dept.get(code) or []
|
||||||
|
for offset, claim in enumerate(claims[index : index + plan.get(code, 0)]):
|
||||||
|
employee = employees[offset % len(employees)] if employees else None
|
||||||
|
_assign_claim_department(claim, department, employee)
|
||||||
|
index += plan.get(code, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_recent_pending_claims(
|
||||||
|
claims: list[ExpenseClaim],
|
||||||
|
departments: dict[str, OrganizationUnit],
|
||||||
|
employees_by_dept: dict[str, list[Employee]],
|
||||||
|
) -> None:
|
||||||
|
index = 0
|
||||||
|
for code, _weight in DEPARTMENT_PLAN:
|
||||||
|
department = departments[code]
|
||||||
|
employees = employees_by_dept.get(code) or []
|
||||||
|
for offset in range(RECENT_PENDING_PER_DEPARTMENT):
|
||||||
|
claim = claims[index]
|
||||||
|
employee = employees[offset % len(employees)] if employees else None
|
||||||
|
_assign_claim_department(claim, department, employee)
|
||||||
|
claim.status = "submitted"
|
||||||
|
claim.approval_stage = "财务审批" if offset % 2 == 0 else "直属领导审批"
|
||||||
|
claim.occurred_at = RECENT_DATES[offset] - _hours(2)
|
||||||
|
claim.submitted_at = RECENT_DATES[offset]
|
||||||
|
claim.updated_at = RECENT_DATES[offset] + _hours(1)
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_claim_department(
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
department: OrganizationUnit,
|
||||||
|
employee: Employee | None,
|
||||||
|
) -> None:
|
||||||
|
claim.department_id = department.id
|
||||||
|
claim.department_name = department.name
|
||||||
|
if employee is not None:
|
||||||
|
claim.employee_id = employee.id
|
||||||
|
claim.employee_name = employee.name
|
||||||
|
claim.location = department.location or claim.location
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_sim_budget(
|
||||||
|
db,
|
||||||
|
claims: list[ExpenseClaim],
|
||||||
|
departments: dict[str, OrganizationUnit],
|
||||||
|
) -> None:
|
||||||
|
for model, field, prefix in (
|
||||||
|
(BudgetTransaction, BudgetTransaction.transaction_no, SIM_TRANSACTION_PREFIX),
|
||||||
|
(BudgetReservation, BudgetReservation.reservation_no, SIM_RESERVATION_PREFIX),
|
||||||
|
(BudgetAllocation, BudgetAllocation.budget_no, SIM_BUDGET_PREFIX),
|
||||||
|
):
|
||||||
|
for row in db.scalars(select(model).where(field.like(f"{prefix}%"))).all():
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
groups: dict[tuple[int, str, str, str, str], list[ExpenseClaim]] = defaultdict(list)
|
||||||
|
for claim in claims:
|
||||||
|
if claim.status not in BUDGETED_STATUSES:
|
||||||
|
continue
|
||||||
|
subject_code = "meal" if claim.expense_type == "entertainment" else claim.expense_type
|
||||||
|
quarter = ((claim.occurred_at.month - 1) // 3) + 1
|
||||||
|
period_key = f"{claim.occurred_at.year}Q{quarter}"
|
||||||
|
cost_center = _claim_cost_center(claim, departments)
|
||||||
|
key = (claim.occurred_at.year, period_key, claim.department_id, cost_center, subject_code)
|
||||||
|
groups[key].append(claim)
|
||||||
|
|
||||||
|
allocation_index = 1
|
||||||
|
transaction_index = 1
|
||||||
|
for key, group_claims in sorted(groups.items()):
|
||||||
|
year, period_key, department_id, cost_center, subject_code = key
|
||||||
|
total_used = sum((Decimal(claim.amount or 0) for claim in group_claims), Decimal("0.00"))
|
||||||
|
original_amount = (
|
||||||
|
total_used / target_budget_usage(period_key, subject_code, allocation_index)
|
||||||
|
).quantize(Decimal("0.01"))
|
||||||
|
allocation = BudgetAllocation(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{SIM_BUDGET_PREFIX}:{key}")),
|
||||||
|
budget_no=f"{SIM_BUDGET_PREFIX}-R{allocation_index:04d}",
|
||||||
|
fiscal_year=year,
|
||||||
|
period_type="quarter",
|
||||||
|
period_key=period_key,
|
||||||
|
department_id=department_id,
|
||||||
|
department_name=group_claims[0].department_name,
|
||||||
|
cost_center=cost_center,
|
||||||
|
project_code=SIM_PROJECT_CODE,
|
||||||
|
subject_code=subject_code,
|
||||||
|
subject_name=SUBJECT_LABELS.get(subject_code, subject_code),
|
||||||
|
original_amount=max(original_amount, Decimal("3000.00")),
|
||||||
|
adjusted_amount=Decimal("0.00"),
|
||||||
|
status="active",
|
||||||
|
warning_threshold=Decimal("80.00"),
|
||||||
|
control_action="warn",
|
||||||
|
description="半年报销模拟数据部门分布修复预算池",
|
||||||
|
created_by="simulation",
|
||||||
|
updated_by="simulation",
|
||||||
|
)
|
||||||
|
db.add(allocation)
|
||||||
|
db.flush()
|
||||||
|
for claim in group_claims:
|
||||||
|
db.add(_budget_transaction(allocation.id, claim, transaction_index))
|
||||||
|
if claim.status in PENDING_STATUSES:
|
||||||
|
db.add(_budget_reservation(allocation.id, claim, transaction_index))
|
||||||
|
transaction_index += 1
|
||||||
|
allocation_index += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_transaction(allocation_id: str, claim: ExpenseClaim, index: int) -> BudgetTransaction:
|
||||||
|
transaction_no = f"{SIM_TRANSACTION_PREFIX}-R{index:04d}"
|
||||||
|
transaction_type = "consume" if claim.status in SUCCESS_STATUSES else "reserve"
|
||||||
|
return BudgetTransaction(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{transaction_no}")),
|
||||||
|
transaction_no=transaction_no,
|
||||||
|
allocation_id=allocation_id,
|
||||||
|
source_type="claim",
|
||||||
|
source_id=claim.id,
|
||||||
|
source_no=claim.claim_no,
|
||||||
|
transaction_type=transaction_type,
|
||||||
|
amount=Decimal(claim.amount or 0),
|
||||||
|
before_available_amount=Decimal("0.00"),
|
||||||
|
after_available_amount=Decimal("0.00"),
|
||||||
|
operator="simulation",
|
||||||
|
reason="修复后模拟数据预算台账",
|
||||||
|
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True, "repair": True},
|
||||||
|
created_at=claim.submitted_at or claim.occurred_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_reservation(allocation_id: str, claim: ExpenseClaim, index: int) -> BudgetReservation:
|
||||||
|
reservation_no = f"{SIM_RESERVATION_PREFIX}-R{index:04d}"
|
||||||
|
return BudgetReservation(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{reservation_no}")),
|
||||||
|
reservation_no=reservation_no,
|
||||||
|
allocation_id=allocation_id,
|
||||||
|
source_type="claim",
|
||||||
|
source_id=claim.id,
|
||||||
|
source_no=claim.claim_no,
|
||||||
|
source_status="active",
|
||||||
|
amount=Decimal(claim.amount or 0),
|
||||||
|
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True, "repair": True},
|
||||||
|
created_at=claim.submitted_at or claim.occurred_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_claims(claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
|
||||||
|
needed = RECENT_PENDING_PER_DEPARTMENT * len(DEPARTMENT_PLAN)
|
||||||
|
return sorted(claims, key=lambda claim: Decimal(claim.amount or 0), reverse=True)[:needed]
|
||||||
|
|
||||||
|
|
||||||
|
def _department_amounts(claims: list[ExpenseClaim]) -> dict[str, str]:
|
||||||
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
for claim in claims:
|
||||||
|
buckets[claim.department_name or "待补充"] += Decimal(claim.amount or 0)
|
||||||
|
return _format_amounts(buckets)
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_pending_amounts(claims: list[ExpenseClaim]) -> dict[str, str]:
|
||||||
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
for claim in claims:
|
||||||
|
if claim.status not in PENDING_STATUSES:
|
||||||
|
continue
|
||||||
|
submitted_at = claim.submitted_at or claim.occurred_at
|
||||||
|
if not submitted_at:
|
||||||
|
continue
|
||||||
|
day = submitted_at.date()
|
||||||
|
if date(2026, 6, 1) <= day <= date(2026, 6, 2):
|
||||||
|
buckets[claim.department_name or "待补充"] += Decimal(claim.amount or 0)
|
||||||
|
return _format_amounts(buckets)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_claims(
|
||||||
|
claims: list[ExpenseClaim],
|
||||||
|
departments: dict[str, OrganizationUnit],
|
||||||
|
claim_plan: dict[str, int],
|
||||||
|
) -> list[ExpenseClaim]:
|
||||||
|
preview: list[ExpenseClaim] = []
|
||||||
|
recent_claims = _recent_claims(claims)
|
||||||
|
recent_claim_set = set(recent_claims)
|
||||||
|
regular_claims = [claim for claim in claims if claim not in recent_claim_set]
|
||||||
|
index = 0
|
||||||
|
for code, _weight in DEPARTMENT_PLAN:
|
||||||
|
department = departments[code]
|
||||||
|
count = max(claim_plan.get(code, 0) - RECENT_PENDING_PER_DEPARTMENT, 0)
|
||||||
|
for claim in regular_claims[index : index + count]:
|
||||||
|
preview.append(_clone_claim(claim, department.name, claim.status, claim.submitted_at))
|
||||||
|
index += count
|
||||||
|
recent_index = 0
|
||||||
|
for code, _weight in DEPARTMENT_PLAN:
|
||||||
|
department = departments[code]
|
||||||
|
for offset in range(RECENT_PENDING_PER_DEPARTMENT):
|
||||||
|
preview.append(
|
||||||
|
_clone_claim(
|
||||||
|
recent_claims[recent_index],
|
||||||
|
department.name,
|
||||||
|
"submitted",
|
||||||
|
RECENT_DATES[offset],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recent_index += 1
|
||||||
|
return preview
|
||||||
|
|
||||||
|
|
||||||
|
def _clone_claim(
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
department_name: str,
|
||||||
|
status: str,
|
||||||
|
submitted_at: datetime | None,
|
||||||
|
) -> Any:
|
||||||
|
return type(
|
||||||
|
"ClaimPreview",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"department_name": department_name,
|
||||||
|
"status": status,
|
||||||
|
"submitted_at": submitted_at,
|
||||||
|
"occurred_at": claim.occurred_at,
|
||||||
|
"expense_type": claim.expense_type,
|
||||||
|
"amount": claim.amount,
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_budget_counts(claims: list[Any]) -> tuple[int, int, int]:
|
||||||
|
allocation_keys = set()
|
||||||
|
transaction_count = 0
|
||||||
|
reservation_count = 0
|
||||||
|
for claim in claims:
|
||||||
|
if claim.status not in BUDGETED_STATUSES:
|
||||||
|
continue
|
||||||
|
submitted_at = claim.submitted_at or claim.occurred_at
|
||||||
|
period_key = f"{submitted_at.year}Q{((submitted_at.month - 1) // 3) + 1}"
|
||||||
|
allocation_keys.add((period_key, claim.department_name, getattr(claim, "expense_type", "")))
|
||||||
|
transaction_count += 1
|
||||||
|
reservation_count += int(claim.status in PENDING_STATUSES)
|
||||||
|
return len(allocation_keys), transaction_count, reservation_count
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_cost_center(
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
departments: dict[str, OrganizationUnit],
|
||||||
|
) -> str | None:
|
||||||
|
for department in departments.values():
|
||||||
|
if department.id == claim.department_id:
|
||||||
|
return department.cost_center
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_amounts(buckets: dict[str, Decimal]) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
key: str(value.quantize(Decimal("0.01")))
|
||||||
|
for key, value in sorted(buckets.items(), key=lambda item: item[1], reverse=True)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hours(value: int):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
return timedelta(hours=value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
111
server/scripts/seed_half_year_expense_demo.py
Normal file
111
server/scripts/seed_half_year_expense_demo.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIR = SERVER_DIR / "src"
|
||||||
|
if str(SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
from app.db.session import get_session_factory # noqa: E402
|
||||||
|
from app.models.employee import Employee # noqa: E402
|
||||||
|
from app.services.demo_company_simulation_filters import is_admin_employee_like # noqa: E402
|
||||||
|
from app.services.demo_company_simulation_seed import ( # noqa: E402
|
||||||
|
HalfYearExpenseSimulationSeeder,
|
||||||
|
SimulationConfig,
|
||||||
|
)
|
||||||
|
from app.services.employee_behavior_profile_service import ( # noqa: E402
|
||||||
|
EmployeeBehaviorProfileService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Seed half-year simulated reimbursement, budget, and employee data.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--target-employees", type=int, default=100)
|
||||||
|
parser.add_argument("--start-date", type=date.fromisoformat, default=date(2026, 1, 1))
|
||||||
|
parser.add_argument("--months", type=int, default=6)
|
||||||
|
parser.add_argument("--seed", type=int, default=20260602)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Write data. Default is dry-run only.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--refresh-profiles",
|
||||||
|
action="store_true",
|
||||||
|
help="After --apply, refresh employee behavior profile snapshots for simulated employees.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--profile-limit", type=int, default=120)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
config = SimulationConfig(
|
||||||
|
target_employees=args.target_employees,
|
||||||
|
start_date=args.start_date,
|
||||||
|
months=args.months,
|
||||||
|
seed=args.seed,
|
||||||
|
)
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
seeder = HalfYearExpenseSimulationSeeder(db, config)
|
||||||
|
try:
|
||||||
|
summary = seeder.apply() if args.apply else seeder.preview()
|
||||||
|
profile_refresh = None
|
||||||
|
if args.apply and args.refresh_profiles:
|
||||||
|
profile_refresh = refresh_company_profiles(db, limit=args.profile_limit)
|
||||||
|
elif args.apply:
|
||||||
|
db.commit()
|
||||||
|
payload = summary.to_dict()
|
||||||
|
if profile_refresh is not None:
|
||||||
|
payload["profile_refresh"] = profile_refresh
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
if not args.apply:
|
||||||
|
print("dry-run only; pass --apply after confirmation to write simulated data.")
|
||||||
|
elif not args.refresh_profiles:
|
||||||
|
print("pass --refresh-profiles to generate employee behavior profile snapshots.")
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_company_profiles(db, *, limit: int) -> dict[str, object]:
|
||||||
|
capped_limit = max(1, min(int(limit or 120), 500))
|
||||||
|
employees = list(
|
||||||
|
db.scalars(select(Employee).order_by(Employee.employee_no.asc())).all()
|
||||||
|
)
|
||||||
|
employee_ids = [
|
||||||
|
employee.id
|
||||||
|
for employee in employees
|
||||||
|
if not is_admin_employee_like(employee)
|
||||||
|
][:capped_limit]
|
||||||
|
service = EmployeeBehaviorProfileService(db)
|
||||||
|
snapshot_count = 0
|
||||||
|
for employee_id in employee_ids:
|
||||||
|
snapshots = service.refresh_employee_profiles(
|
||||||
|
employee_id=employee_id,
|
||||||
|
window_days=(30, 90, 180),
|
||||||
|
expense_type_scope="overall",
|
||||||
|
source_task_type="half_year_expense_demo_seed",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
snapshot_count += len(snapshots)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"target_employee_count": len(employee_ids),
|
||||||
|
"snapshot_count": snapshot_count,
|
||||||
|
"window_days": [30, 90, 180],
|
||||||
|
"source_task_type": "half_year_expense_demo_seed",
|
||||||
|
"scope": "all_non_admin_employees",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -17,5 +17,8 @@ class FinanceDashboardRead(BaseModel):
|
|||||||
spend_by_category: list[dict[str, Any]] = Field(default_factory=list)
|
spend_by_category: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
exception_mix: list[dict[str, Any]] = Field(default_factory=list)
|
exception_mix: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
department_ranking: list[dict[str, Any]] = Field(default_factory=list)
|
department_ranking: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
employee_ranking: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
top_claims: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
bottlenecks: list[dict[str, Any]] = Field(default_factory=list)
|
bottlenecks: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
budget_summary: dict[str, Any] = Field(default_factory=dict)
|
budget_summary: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
budget_metrics: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -163,24 +163,13 @@ def build_application_system_estimate(
|
|||||||
lodging_display = format_application_money(lodging)
|
lodging_display = format_application_money(lodging)
|
||||||
allowance_display = format_application_money(allowance)
|
allowance_display = format_application_money(allowance)
|
||||||
total_display = format_application_money(total_amount)
|
total_display = format_application_money(total_amount)
|
||||||
band_label = {
|
|
||||||
"premium": "一线/高频城市",
|
|
||||||
"remote": "远途地区",
|
|
||||||
"coastal": "沿海城市",
|
|
||||||
"default": "普通城市",
|
|
||||||
}[location_band]
|
|
||||||
query_label = query_date or "出行日期待确认"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"amount": f"{total_display}元",
|
"amount": f"{total_display}元",
|
||||||
"lodging_daily_cap": f"{format_application_money(lodging_daily)}元/天",
|
"lodging_daily_cap": f"{format_application_money(lodging_daily)}元/天",
|
||||||
"subsidy_daily_cap": f"{format_application_money(allowance_daily)}元/天",
|
"subsidy_daily_cap": f"{format_application_money(allowance_daily)}元/天",
|
||||||
"transport_policy": (
|
"transport_policy": f"预估交通费用 {transport_display}元",
|
||||||
f"已查询 {query_label} {mode}参考票价,按{band_label}往返 {transport_display}元预估"
|
|
||||||
f"(查询耗时 {simulated_latency_ms}ms),报销阶段按真实票据复核"
|
|
||||||
),
|
|
||||||
"policy_estimate": (
|
"policy_estimate": (
|
||||||
f"交通 {transport_display}元(按 {query_label} 参考票价) + 住宿 {lodging_display}元"
|
f"交通 {transport_display}元 + 住宿 {lodging_display}元"
|
||||||
f" + 补贴 {allowance_display}元 = {total_display}元({days}天)"
|
f" + 补贴 {allowance_display}元 = {total_display}元({days}天)"
|
||||||
),
|
),
|
||||||
"matched_city": str(location or "").strip(),
|
"matched_city": str(location or "").strip(),
|
||||||
|
|||||||
274
server/src/app/services/demo_company_simulation_catalog.py
Normal file
274
server/src/app/services/demo_company_simulation_catalog.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SIM_EMPLOYEE_PREFIX = "SIM2026"
|
||||||
|
SIM_CLAIM_PREFIX = "SIM-EXP-2026"
|
||||||
|
SIM_BUDGET_PREFIX = "SIM-BUD-2026"
|
||||||
|
SIM_TRANSACTION_PREFIX = "SIM-BTX-2026"
|
||||||
|
SIM_RESERVATION_PREFIX = "SIM-BRS-2026"
|
||||||
|
SIM_RISK_PREFIX = "SIM-RISK-2026"
|
||||||
|
SIM_PROJECT_CODE = "SIM-DEMO"
|
||||||
|
DEFAULT_PASSWORD = "123456"
|
||||||
|
|
||||||
|
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
|
||||||
|
PENDING_STATUSES = {
|
||||||
|
"submitted",
|
||||||
|
"review",
|
||||||
|
"pending_review",
|
||||||
|
"manager_review",
|
||||||
|
"budget_review",
|
||||||
|
"finance_review",
|
||||||
|
"approving",
|
||||||
|
}
|
||||||
|
BUDGETED_STATUSES = SUCCESS_STATUSES | PENDING_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SimulationConfig:
|
||||||
|
target_employees: int = 100
|
||||||
|
start_date: date = date(2026, 1, 1)
|
||||||
|
months: int = 6
|
||||||
|
seed: int = 20260602
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SimulationSummary:
|
||||||
|
mode: str
|
||||||
|
current_employee_count: int
|
||||||
|
target_employee_count: int
|
||||||
|
selected_employee_count: int
|
||||||
|
employees_to_create: int
|
||||||
|
claims_to_create: int
|
||||||
|
claim_items_to_create: int
|
||||||
|
budget_allocations_to_create: int
|
||||||
|
budget_transactions_to_create: int
|
||||||
|
budget_reservations_to_create: int
|
||||||
|
risk_observations_to_create: int
|
||||||
|
period_start: str
|
||||||
|
period_end: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DepartmentRef:
|
||||||
|
id: str
|
||||||
|
unit_code: str
|
||||||
|
name: str
|
||||||
|
cost_center: str
|
||||||
|
location: str
|
||||||
|
manager_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EmployeeRef:
|
||||||
|
id: str
|
||||||
|
employee_no: str
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
grade: str
|
||||||
|
position: str
|
||||||
|
department: DepartmentRef
|
||||||
|
is_new: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ClaimItemPlan:
|
||||||
|
item_date: date
|
||||||
|
item_type: str
|
||||||
|
item_reason: str
|
||||||
|
item_location: str
|
||||||
|
item_amount: Decimal
|
||||||
|
invoice_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ClaimPlan:
|
||||||
|
id: str
|
||||||
|
claim_no: str
|
||||||
|
employee: EmployeeRef
|
||||||
|
expense_type: str
|
||||||
|
reason: str
|
||||||
|
location: str
|
||||||
|
amount: Decimal
|
||||||
|
invoice_count: int
|
||||||
|
occurred_at: datetime
|
||||||
|
submitted_at: datetime | None
|
||||||
|
status: str
|
||||||
|
approval_stage: str | None
|
||||||
|
risk_flags: list[dict[str, Any]]
|
||||||
|
hermes_risk_flag: bool
|
||||||
|
items: list[ClaimItemPlan]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def period_key(self) -> str:
|
||||||
|
quarter = ((self.occurred_at.month - 1) // 3) + 1
|
||||||
|
return f"{self.occurred_at.year}Q{quarter}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def budget_subject_code(self) -> str:
|
||||||
|
return "meal" if self.expense_type == "entertainment" else self.expense_type
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AllocationPlan:
|
||||||
|
key: tuple[int, str, str, str, str]
|
||||||
|
department: DepartmentRef
|
||||||
|
subject_code: str
|
||||||
|
subject_name: str
|
||||||
|
period_key: str
|
||||||
|
original_amount: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_DEPARTMENTS = (
|
||||||
|
DepartmentRef("sim-dept-tech", "TECH-DEPT", "技术部", "CC-6100", "北京", "吴磊"),
|
||||||
|
DepartmentRef("sim-dept-market", "MARKET-DEPT", "市场部", "CC-4100", "上海", "刘思雨"),
|
||||||
|
DepartmentRef("sim-dept-finance", "FINANCE-DEPT", "财务部", "CC-2100", "上海", "张晓晴"),
|
||||||
|
DepartmentRef("sim-dept-hr", "HR-DEPT", "人力资源部", "CC-3200", "杭州", "陈硕"),
|
||||||
|
DepartmentRef("sim-dept-prod", "PRODUCTION-DEPT", "生产部", "CC-7200", "南京", "梁雨辰"),
|
||||||
|
DepartmentRef("sim-dept-office", "PRESIDENT-OFFICE", "总裁办", "CC-1000", "上海", "李文静"),
|
||||||
|
)
|
||||||
|
|
||||||
|
SUBJECT_LABELS = {
|
||||||
|
"travel": "差旅",
|
||||||
|
"meal": "招待费",
|
||||||
|
"office": "办公用品",
|
||||||
|
"communication": "通信",
|
||||||
|
}
|
||||||
|
SUBJECT_BASE_AMOUNTS = {
|
||||||
|
"travel": Decimal("5600.00"),
|
||||||
|
"meal": Decimal("1800.00"),
|
||||||
|
"office": Decimal("820.00"),
|
||||||
|
"communication": Decimal("320.00"),
|
||||||
|
}
|
||||||
|
DEPARTMENT_CLAIM_WEIGHTS = {
|
||||||
|
"TECH-DEPT": {"travel": 4, "meal": 1, "office": 3, "communication": 2},
|
||||||
|
"MARKET-DEPT": {"travel": 5, "meal": 4, "office": 1, "communication": 1},
|
||||||
|
"FINANCE-DEPT": {"travel": 2, "meal": 1, "office": 3, "communication": 2},
|
||||||
|
"HR-DEPT": {"travel": 2, "meal": 2, "office": 3, "communication": 1},
|
||||||
|
"PRODUCTION-DEPT": {"travel": 3, "meal": 1, "office": 4, "communication": 1},
|
||||||
|
"PRESIDENT-OFFICE": {"travel": 4, "meal": 3, "office": 2, "communication": 1},
|
||||||
|
}
|
||||||
|
DEPARTMENT_EMPLOYEE_WEIGHTS = {
|
||||||
|
"TECH-DEPT": 30,
|
||||||
|
"MARKET-DEPT": 24,
|
||||||
|
"PRODUCTION-DEPT": 20,
|
||||||
|
"FINANCE-DEPT": 12,
|
||||||
|
"HR-DEPT": 9,
|
||||||
|
"PRESIDENT-OFFICE": 5,
|
||||||
|
}
|
||||||
|
GRADE_FACTORS = {
|
||||||
|
"P3": Decimal("0.82"),
|
||||||
|
"P4": Decimal("0.92"),
|
||||||
|
"P5": Decimal("1.00"),
|
||||||
|
"P6": Decimal("1.15"),
|
||||||
|
"P7": Decimal("1.32"),
|
||||||
|
"P8": Decimal("1.55"),
|
||||||
|
}
|
||||||
|
MONTH_FACTORS = {
|
||||||
|
1: Decimal("0.86"),
|
||||||
|
2: Decimal("0.72"),
|
||||||
|
3: Decimal("1.05"),
|
||||||
|
4: Decimal("1.12"),
|
||||||
|
5: Decimal("1.22"),
|
||||||
|
6: Decimal("1.34"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_employee_name(index: int) -> str:
|
||||||
|
surnames = ("林", "许", "周", "唐", "沈", "陆", "韩", "钱", "冯", "邹", "顾", "夏")
|
||||||
|
names = ("嘉宁", "思远", "雨桐", "景行", "明轩", "若琳", "子涵", "安琪", "奕辰", "诗涵")
|
||||||
|
return f"{surnames[index % len(surnames)]}{names[(index * 3) % len(names)]}"
|
||||||
|
|
||||||
|
|
||||||
|
def grade_for_index(index: int) -> str:
|
||||||
|
grades = ("P3", "P4", "P4", "P5", "P5", "P6", "P6", "P7", "P8")
|
||||||
|
return grades[index % len(grades)]
|
||||||
|
|
||||||
|
|
||||||
|
def position_for_grade(grade: str) -> str:
|
||||||
|
return {
|
||||||
|
"P3": "专员",
|
||||||
|
"P4": "高级专员",
|
||||||
|
"P5": "主管",
|
||||||
|
"P6": "经理",
|
||||||
|
"P7": "高级经理",
|
||||||
|
"P8": "部门负责人",
|
||||||
|
}.get(grade, "员工")
|
||||||
|
|
||||||
|
|
||||||
|
def claim_reason(expense_type: str, department_name: str, occurred_day: date) -> str:
|
||||||
|
labels = {
|
||||||
|
"travel": "客户拜访与项目交付差旅",
|
||||||
|
"meal": "客户沟通与商务招待",
|
||||||
|
"office": "团队办公用品采购",
|
||||||
|
"communication": "项目通信与移动办公",
|
||||||
|
}
|
||||||
|
return f"{department_name}{occurred_day.month}月{labels.get(expense_type, '业务费用')}"
|
||||||
|
|
||||||
|
|
||||||
|
def item_reason(expense_type: str) -> str:
|
||||||
|
return {
|
||||||
|
"meal": "商务招待餐费",
|
||||||
|
"office": "办公用品采购",
|
||||||
|
"communication": "通信服务费",
|
||||||
|
}.get(expense_type, "业务费用")
|
||||||
|
|
||||||
|
|
||||||
|
def claim_location(default_location: str, claim_index: int) -> str:
|
||||||
|
cities = ("上海", "北京", "深圳", "广州", "杭州", "南京", "成都", "武汉")
|
||||||
|
return cities[claim_index % len(cities)] or default_location
|
||||||
|
|
||||||
|
|
||||||
|
def risk_type(claim_index: int, expense_type: str) -> tuple[str, str]:
|
||||||
|
options = (
|
||||||
|
("amount_outlier", "金额异常"),
|
||||||
|
("budget_pressure", "预算压力偏高"),
|
||||||
|
("missing_material", "材料不完整"),
|
||||||
|
("duplicate_invoice", "疑似重复票据"),
|
||||||
|
("split_billing", "疑似拆分报销"),
|
||||||
|
)
|
||||||
|
if expense_type == "travel" and claim_index % 5 == 0:
|
||||||
|
return "location_mismatch", "地点不一致"
|
||||||
|
return options[claim_index % len(options)]
|
||||||
|
|
||||||
|
|
||||||
|
def target_budget_usage(period_key: str, subject_code: str, index: int) -> Decimal:
|
||||||
|
sequence = (
|
||||||
|
Decimal("0.62"),
|
||||||
|
Decimal("0.74"),
|
||||||
|
Decimal("0.83"),
|
||||||
|
Decimal("0.91"),
|
||||||
|
Decimal("1.06"),
|
||||||
|
)
|
||||||
|
usage = sequence[index % len(sequence)]
|
||||||
|
if period_key.endswith("Q2") and subject_code in {"travel", "meal"}:
|
||||||
|
usage += Decimal("0.07")
|
||||||
|
return min(usage, Decimal("1.12"))
|
||||||
|
|
||||||
|
|
||||||
|
def department_from_row(row: Any | None) -> DepartmentRef:
|
||||||
|
if row is None:
|
||||||
|
return DEFAULT_DEPARTMENTS[0]
|
||||||
|
return DepartmentRef(
|
||||||
|
id=row.id,
|
||||||
|
unit_code=row.unit_code,
|
||||||
|
name=row.name,
|
||||||
|
cost_center=row.cost_center or "",
|
||||||
|
location=row.location or "上海",
|
||||||
|
manager_name=row.manager_name or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def updated_at_for_claim_plan(plan: ClaimPlan) -> datetime:
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
base = plan.submitted_at or plan.occurred_at
|
||||||
|
if plan.status in SUCCESS_STATUSES | {"rejected", "returned"}:
|
||||||
|
return base + timedelta(hours=2 + int(plan.claim_no[-2:]) % 24)
|
||||||
|
return base + timedelta(hours=1)
|
||||||
79
server/src/app/services/demo_company_simulation_filters.py
Normal file
79
server/src/app/services/demo_company_simulation_filters.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ADMIN_KEYWORDS = {
|
||||||
|
"admin",
|
||||||
|
"administrator",
|
||||||
|
"root",
|
||||||
|
"system",
|
||||||
|
"sysadmin",
|
||||||
|
"superadmin",
|
||||||
|
}
|
||||||
|
ADMIN_CN_KEYWORDS = ("管理员", "系统")
|
||||||
|
APPLICATION_EXPENSE_TYPES = {
|
||||||
|
"application",
|
||||||
|
"expense_application",
|
||||||
|
"travel_application",
|
||||||
|
"trip_application",
|
||||||
|
"preapproval",
|
||||||
|
}
|
||||||
|
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
|
||||||
|
RECENT_VISIBLE_CLAIM_START = 501
|
||||||
|
RECENT_VISIBLE_CLAIM_END = 950
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_identity(*values: Any) -> bool:
|
||||||
|
for value in values:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
lowered = text.lower()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if lowered in ADMIN_KEYWORDS:
|
||||||
|
return True
|
||||||
|
if any(token in lowered for token in ADMIN_KEYWORDS):
|
||||||
|
return True
|
||||||
|
if any(token in text for token in ADMIN_CN_KEYWORDS):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_employee_like(employee: Any) -> bool:
|
||||||
|
return is_admin_identity(
|
||||||
|
getattr(employee, "employee_no", None),
|
||||||
|
getattr(employee, "name", None),
|
||||||
|
getattr(employee, "email", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_application_claim(claim: Any) -> bool:
|
||||||
|
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||||
|
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||||
|
if expense_type in APPLICATION_EXPENSE_TYPES:
|
||||||
|
return True
|
||||||
|
return claim_no.startswith(APPLICATION_CLAIM_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def is_finance_reimbursement_claim(claim: Any) -> bool:
|
||||||
|
if is_application_claim(claim):
|
||||||
|
return False
|
||||||
|
return not is_admin_identity(
|
||||||
|
getattr(claim, "employee_name", None),
|
||||||
|
getattr(claim, "employee_id", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recent_visible_claim_day(
|
||||||
|
months: list[date],
|
||||||
|
*,
|
||||||
|
employee_index: int,
|
||||||
|
claim_index: int,
|
||||||
|
) -> date | None:
|
||||||
|
if not months or not (RECENT_VISIBLE_CLAIM_START <= claim_index <= RECENT_VISIBLE_CLAIM_END):
|
||||||
|
return None
|
||||||
|
month = months[-1]
|
||||||
|
_, max_day = calendar.monthrange(month.year, month.month)
|
||||||
|
day = min(2, max_day)
|
||||||
|
return month.replace(day=1 + ((employee_index + claim_index) % day))
|
||||||
821
server/src/app/services/demo_company_simulation_seed.py
Normal file
821
server/src/app/services/demo_company_simulation_seed.py
Normal file
@@ -0,0 +1,821 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.models.organization import OrganizationUnit
|
||||||
|
from app.models.risk_observation import RiskObservation
|
||||||
|
from app.models.role import Role
|
||||||
|
from app.services.demo_company_simulation_catalog import (
|
||||||
|
BUDGETED_STATUSES,
|
||||||
|
DEFAULT_DEPARTMENTS,
|
||||||
|
DEFAULT_PASSWORD,
|
||||||
|
DEPARTMENT_CLAIM_WEIGHTS,
|
||||||
|
DEPARTMENT_EMPLOYEE_WEIGHTS,
|
||||||
|
GRADE_FACTORS,
|
||||||
|
MONTH_FACTORS,
|
||||||
|
PENDING_STATUSES,
|
||||||
|
SIM_BUDGET_PREFIX,
|
||||||
|
SIM_CLAIM_PREFIX,
|
||||||
|
SIM_EMPLOYEE_PREFIX,
|
||||||
|
SIM_PROJECT_CODE,
|
||||||
|
SIM_RESERVATION_PREFIX,
|
||||||
|
SIM_RISK_PREFIX,
|
||||||
|
SIM_TRANSACTION_PREFIX,
|
||||||
|
SUBJECT_BASE_AMOUNTS,
|
||||||
|
SUBJECT_LABELS,
|
||||||
|
SUCCESS_STATUSES,
|
||||||
|
AllocationPlan,
|
||||||
|
ClaimItemPlan,
|
||||||
|
ClaimPlan,
|
||||||
|
DepartmentRef,
|
||||||
|
EmployeeRef,
|
||||||
|
SimulationConfig,
|
||||||
|
SimulationSummary,
|
||||||
|
build_employee_name,
|
||||||
|
claim_location,
|
||||||
|
claim_reason,
|
||||||
|
department_from_row,
|
||||||
|
grade_for_index,
|
||||||
|
item_reason,
|
||||||
|
position_for_grade,
|
||||||
|
risk_type,
|
||||||
|
target_budget_usage,
|
||||||
|
updated_at_for_claim_plan,
|
||||||
|
)
|
||||||
|
from app.services.demo_company_simulation_filters import (
|
||||||
|
is_admin_employee_like,
|
||||||
|
recent_visible_claim_day,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HalfYearExpenseSimulationSeeder:
|
||||||
|
def __init__(self, db: Session, config: SimulationConfig | None = None) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.config = config or SimulationConfig()
|
||||||
|
self.rng = random.Random(self.config.seed)
|
||||||
|
|
||||||
|
def preview(self) -> SimulationSummary:
|
||||||
|
return self._run(apply=False)
|
||||||
|
|
||||||
|
def apply(self) -> SimulationSummary:
|
||||||
|
return self._run(apply=True)
|
||||||
|
|
||||||
|
def _run(self, *, apply: bool) -> SimulationSummary:
|
||||||
|
Base.metadata.create_all(bind=self.db.get_bind())
|
||||||
|
departments = self._department_refs(apply=apply)
|
||||||
|
current_employee_count = self._employee_count()
|
||||||
|
planned_employees = self._build_new_employee_refs(departments, current_employee_count)
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
self._ensure_user_role()
|
||||||
|
self._create_missing_employees(planned_employees)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
employees = self._employee_refs(departments)
|
||||||
|
if not apply:
|
||||||
|
employees = [*employees, *planned_employees]
|
||||||
|
|
||||||
|
selected_employees = self._select_company_employees(employees)
|
||||||
|
claim_plans = self._build_claim_plans(selected_employees)
|
||||||
|
allocation_plans = self._build_allocation_plans(claim_plans)
|
||||||
|
|
||||||
|
allocation_map, allocation_count = self._ensure_allocations(
|
||||||
|
allocation_plans,
|
||||||
|
apply=apply,
|
||||||
|
)
|
||||||
|
claim_count, item_count = self._ensure_claims(claim_plans, apply=apply)
|
||||||
|
transaction_count, reservation_count = self._ensure_budget_usage(
|
||||||
|
claim_plans,
|
||||||
|
allocation_map,
|
||||||
|
apply=apply,
|
||||||
|
)
|
||||||
|
risk_count = self._ensure_risk_observations(claim_plans, apply=apply)
|
||||||
|
|
||||||
|
return SimulationSummary(
|
||||||
|
mode="apply" if apply else "dry-run",
|
||||||
|
current_employee_count=current_employee_count,
|
||||||
|
target_employee_count=self.config.target_employees,
|
||||||
|
selected_employee_count=len(selected_employees),
|
||||||
|
employees_to_create=len(planned_employees),
|
||||||
|
claims_to_create=claim_count,
|
||||||
|
claim_items_to_create=item_count,
|
||||||
|
budget_allocations_to_create=allocation_count,
|
||||||
|
budget_transactions_to_create=transaction_count,
|
||||||
|
budget_reservations_to_create=reservation_count,
|
||||||
|
risk_observations_to_create=risk_count,
|
||||||
|
period_start=self.config.start_date.isoformat(),
|
||||||
|
period_end=self._period_end().isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _department_refs(self, *, apply: bool) -> list[DepartmentRef]:
|
||||||
|
rows = list(
|
||||||
|
self.db.scalars(
|
||||||
|
select(OrganizationUnit)
|
||||||
|
.where(OrganizationUnit.unit_type == "department")
|
||||||
|
.order_by(OrganizationUnit.unit_code.asc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
return [department_from_row(row) for row in rows]
|
||||||
|
if not apply:
|
||||||
|
return list(DEFAULT_DEPARTMENTS)
|
||||||
|
|
||||||
|
for item in DEFAULT_DEPARTMENTS:
|
||||||
|
self.db.add(
|
||||||
|
OrganizationUnit(
|
||||||
|
id=item.id,
|
||||||
|
unit_code=item.unit_code,
|
||||||
|
name=item.name,
|
||||||
|
unit_type="department",
|
||||||
|
cost_center=item.cost_center,
|
||||||
|
location=item.location,
|
||||||
|
manager_name=item.manager_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.db.flush()
|
||||||
|
return list(DEFAULT_DEPARTMENTS)
|
||||||
|
|
||||||
|
def _employee_count(self) -> int:
|
||||||
|
employees = list(self.db.scalars(select(Employee)).all())
|
||||||
|
return sum(1 for employee in employees if not is_admin_employee_like(employee))
|
||||||
|
|
||||||
|
def _build_new_employee_refs(
|
||||||
|
self,
|
||||||
|
departments: list[DepartmentRef],
|
||||||
|
current_employee_count: int,
|
||||||
|
) -> list[EmployeeRef]:
|
||||||
|
missing_count = max(self.config.target_employees - current_employee_count, 0)
|
||||||
|
if missing_count <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
existing_nos = set(self.db.scalars(select(Employee.employee_no)).all())
|
||||||
|
refs: list[EmployeeRef] = []
|
||||||
|
next_index = 1
|
||||||
|
while len(refs) < missing_count:
|
||||||
|
employee_no = f"{SIM_EMPLOYEE_PREFIX}{next_index:03d}"
|
||||||
|
next_index += 1
|
||||||
|
if employee_no in existing_nos:
|
||||||
|
continue
|
||||||
|
department = self._weighted_department(departments, len(refs))
|
||||||
|
grade = grade_for_index(len(refs))
|
||||||
|
refs.append(
|
||||||
|
EmployeeRef(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{employee_no}")),
|
||||||
|
employee_no=employee_no,
|
||||||
|
name=build_employee_name(len(refs)),
|
||||||
|
email=f"{employee_no.lower()}@xf.com",
|
||||||
|
grade=grade,
|
||||||
|
position=position_for_grade(grade),
|
||||||
|
department=department,
|
||||||
|
is_new=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return refs
|
||||||
|
|
||||||
|
def _ensure_user_role(self) -> Role:
|
||||||
|
role = self.db.scalar(select(Role).where(Role.role_code == "user"))
|
||||||
|
if role is not None:
|
||||||
|
return role
|
||||||
|
role = Role(
|
||||||
|
role_code="user",
|
||||||
|
name="使用者",
|
||||||
|
description="可以发起费用申请、报销和查看个人单据。",
|
||||||
|
)
|
||||||
|
self.db.add(role)
|
||||||
|
self.db.flush()
|
||||||
|
return role
|
||||||
|
|
||||||
|
def _create_missing_employees(self, refs: list[EmployeeRef]) -> None:
|
||||||
|
if not refs:
|
||||||
|
return
|
||||||
|
user_role = self._ensure_user_role()
|
||||||
|
existing_nos = set(self.db.scalars(select(Employee.employee_no)).all())
|
||||||
|
departments_by_id = {row.id: row for row in self.db.scalars(select(OrganizationUnit)).all()}
|
||||||
|
for ref in refs:
|
||||||
|
if ref.employee_no in existing_nos:
|
||||||
|
continue
|
||||||
|
employee = Employee(
|
||||||
|
id=ref.id,
|
||||||
|
employee_no=ref.employee_no,
|
||||||
|
name=ref.name,
|
||||||
|
email=ref.email,
|
||||||
|
gender="女" if int(ref.employee_no[-1]) % 2 == 0 else "男",
|
||||||
|
phone=f"139{int(ref.employee_no[-3:]):08d}",
|
||||||
|
join_date=date(2025, (int(ref.employee_no[-3:]) % 12) + 1, 10),
|
||||||
|
location=ref.department.location,
|
||||||
|
position=ref.position,
|
||||||
|
grade=ref.grade,
|
||||||
|
cost_center=ref.department.cost_center,
|
||||||
|
finance_owner_name=f"{ref.department.name}财务BP",
|
||||||
|
bank_name="招商银行",
|
||||||
|
bank_account_no=f"622588{int(ref.employee_no[-3:]):013d}",
|
||||||
|
bank_account_name=ref.name,
|
||||||
|
password_hash=hash_password(DEFAULT_PASSWORD),
|
||||||
|
employment_status="在职",
|
||||||
|
sync_state="已同步",
|
||||||
|
compliance_score=92 + int(ref.employee_no[-3:]) % 8,
|
||||||
|
organization_unit=departments_by_id.get(ref.department.id),
|
||||||
|
roles=[user_role],
|
||||||
|
last_sync_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
self.db.add(employee)
|
||||||
|
|
||||||
|
def _employee_refs(self, departments: list[DepartmentRef]) -> list[EmployeeRef]:
|
||||||
|
department_by_id = {item.id: item for item in departments}
|
||||||
|
fallback_departments = departments or list(DEFAULT_DEPARTMENTS)
|
||||||
|
rows = list(
|
||||||
|
self.db.scalars(
|
||||||
|
select(Employee)
|
||||||
|
.options(selectinload(Employee.organization_unit))
|
||||||
|
.order_by(Employee.employee_no.asc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
refs: list[EmployeeRef] = []
|
||||||
|
for index, employee in enumerate(rows):
|
||||||
|
department = (
|
||||||
|
department_by_id.get(str(employee.organization_unit_id or ""))
|
||||||
|
or department_from_row(employee.organization_unit)
|
||||||
|
if employee.organization_unit is not None
|
||||||
|
else fallback_departments[index % len(fallback_departments)]
|
||||||
|
)
|
||||||
|
refs.append(
|
||||||
|
EmployeeRef(
|
||||||
|
id=employee.id,
|
||||||
|
employee_no=employee.employee_no,
|
||||||
|
name=employee.name,
|
||||||
|
email=employee.email,
|
||||||
|
grade=employee.grade or "P4",
|
||||||
|
position=employee.position or "员工",
|
||||||
|
department=department,
|
||||||
|
is_new=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return refs
|
||||||
|
|
||||||
|
def _select_company_employees(self, employees: list[EmployeeRef]) -> list[EmployeeRef]:
|
||||||
|
sorted_employees = sorted(
|
||||||
|
(employee for employee in employees if not is_admin_employee_like(employee)),
|
||||||
|
key=lambda item: item.employee_no,
|
||||||
|
)
|
||||||
|
target = max(1, self.config.target_employees)
|
||||||
|
return sorted_employees[:target] if len(sorted_employees) > target else sorted_employees
|
||||||
|
|
||||||
|
def _build_claim_plans(self, employees: list[EmployeeRef]) -> list[ClaimPlan]:
|
||||||
|
plans: list[ClaimPlan] = []
|
||||||
|
months = self._month_starts()
|
||||||
|
claim_index = 1
|
||||||
|
for employee_index, employee in enumerate(employees):
|
||||||
|
count = self._claim_count_for_employee(employee, employee_index)
|
||||||
|
for local_index in range(count):
|
||||||
|
occurred_day = self._claim_day(
|
||||||
|
months,
|
||||||
|
employee_index,
|
||||||
|
local_index,
|
||||||
|
claim_index,
|
||||||
|
)
|
||||||
|
expense_type = self._expense_type_for_employee(employee)
|
||||||
|
amount = self._claim_amount(employee, expense_type, occurred_day)
|
||||||
|
status, stage = self._status_for_claim(employee_index, local_index)
|
||||||
|
risk_flags = self._risk_flags(employee, expense_type, amount, claim_index)
|
||||||
|
submitted_at = None
|
||||||
|
if status != "draft":
|
||||||
|
submitted_at = datetime.combine(occurred_day, datetime.min.time(), tzinfo=UTC)
|
||||||
|
submitted_at += timedelta(hours=9 + (claim_index % 7))
|
||||||
|
occurred_at = datetime.combine(occurred_day, datetime.min.time(), tzinfo=UTC)
|
||||||
|
occurred_at += timedelta(hours=8 + (claim_index % 9))
|
||||||
|
plans.append(
|
||||||
|
ClaimPlan(
|
||||||
|
id=str(
|
||||||
|
uuid.uuid5(
|
||||||
|
uuid.NAMESPACE_DNS,
|
||||||
|
f"x-financial:{SIM_CLAIM_PREFIX}:{claim_index}",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
claim_no=f"{SIM_CLAIM_PREFIX}-{claim_index:04d}",
|
||||||
|
employee=employee,
|
||||||
|
expense_type=expense_type,
|
||||||
|
reason=claim_reason(
|
||||||
|
expense_type,
|
||||||
|
employee.department.name,
|
||||||
|
occurred_day,
|
||||||
|
),
|
||||||
|
location=claim_location(employee.department.location, claim_index),
|
||||||
|
amount=amount,
|
||||||
|
invoice_count=1 + (claim_index % 3),
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
submitted_at=submitted_at,
|
||||||
|
status=status,
|
||||||
|
approval_stage=stage,
|
||||||
|
risk_flags=risk_flags,
|
||||||
|
hermes_risk_flag=bool(risk_flags and claim_index % 2 == 0),
|
||||||
|
items=self._claim_items(expense_type, amount, occurred_day, claim_index),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
claim_index += 1
|
||||||
|
return plans
|
||||||
|
|
||||||
|
def _build_allocation_plans(self, claim_plans: list[ClaimPlan]) -> list[AllocationPlan]:
|
||||||
|
bucket_amounts: dict[tuple[int, str, str, str, str], Decimal] = {}
|
||||||
|
bucket_departments: dict[tuple[int, str, str, str, str], DepartmentRef] = {}
|
||||||
|
for plan in claim_plans:
|
||||||
|
if plan.status not in BUDGETED_STATUSES:
|
||||||
|
continue
|
||||||
|
department = plan.employee.department
|
||||||
|
key = (
|
||||||
|
plan.occurred_at.year,
|
||||||
|
plan.period_key,
|
||||||
|
department.id,
|
||||||
|
department.cost_center,
|
||||||
|
plan.budget_subject_code,
|
||||||
|
)
|
||||||
|
bucket_amounts[key] = bucket_amounts.get(key, Decimal("0.00")) + plan.amount
|
||||||
|
bucket_departments[key] = department
|
||||||
|
|
||||||
|
plans: list[AllocationPlan] = []
|
||||||
|
for index, (key, used_amount) in enumerate(sorted(bucket_amounts.items())):
|
||||||
|
year, period_key, _department_id, _cost_center, subject_code = key
|
||||||
|
target_usage = target_budget_usage(period_key, subject_code, index)
|
||||||
|
original_amount = max(
|
||||||
|
(used_amount / target_usage).quantize(Decimal("0.01")),
|
||||||
|
Decimal("3000.00"),
|
||||||
|
)
|
||||||
|
plans.append(
|
||||||
|
AllocationPlan(
|
||||||
|
key=key,
|
||||||
|
department=bucket_departments[key],
|
||||||
|
subject_code=subject_code,
|
||||||
|
subject_name=SUBJECT_LABELS.get(subject_code, subject_code),
|
||||||
|
period_key=period_key,
|
||||||
|
original_amount=original_amount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return plans
|
||||||
|
|
||||||
|
def _ensure_allocations(
|
||||||
|
self,
|
||||||
|
plans: list[AllocationPlan],
|
||||||
|
*,
|
||||||
|
apply: bool,
|
||||||
|
) -> tuple[dict[tuple[int, str, str, str, str], str], int]:
|
||||||
|
allocation_map: dict[tuple[int, str, str, str, str], str] = {}
|
||||||
|
created_count = 0
|
||||||
|
for index, plan in enumerate(plans, start=1):
|
||||||
|
existing = self._find_sim_allocation(plan)
|
||||||
|
if existing is not None:
|
||||||
|
allocation_map[plan.key] = existing.id
|
||||||
|
continue
|
||||||
|
created_count += 1
|
||||||
|
allocation_id = str(
|
||||||
|
uuid.uuid5(
|
||||||
|
uuid.NAMESPACE_DNS,
|
||||||
|
f"x-financial:{SIM_BUDGET_PREFIX}:{plan.key}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
allocation_map[plan.key] = allocation_id
|
||||||
|
if not apply:
|
||||||
|
continue
|
||||||
|
self.db.add(
|
||||||
|
BudgetAllocation(
|
||||||
|
id=allocation_id,
|
||||||
|
budget_no=f"{SIM_BUDGET_PREFIX}-{index:04d}",
|
||||||
|
fiscal_year=plan.key[0],
|
||||||
|
period_type="quarter",
|
||||||
|
period_key=plan.period_key,
|
||||||
|
department_id=plan.department.id,
|
||||||
|
department_name=plan.department.name,
|
||||||
|
cost_center=plan.department.cost_center,
|
||||||
|
project_code=SIM_PROJECT_CODE,
|
||||||
|
subject_code=plan.subject_code,
|
||||||
|
subject_name=plan.subject_name,
|
||||||
|
original_amount=plan.original_amount,
|
||||||
|
adjusted_amount=Decimal("0.00"),
|
||||||
|
status="active",
|
||||||
|
warning_threshold=Decimal("80.00"),
|
||||||
|
control_action="warn",
|
||||||
|
description="半年报销模拟数据预算池",
|
||||||
|
created_by="simulation",
|
||||||
|
updated_by="simulation",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if apply:
|
||||||
|
self.db.flush()
|
||||||
|
return allocation_map, created_count
|
||||||
|
|
||||||
|
def _ensure_claims(self, plans: list[ClaimPlan], *, apply: bool) -> tuple[int, int]:
|
||||||
|
existing_claim_nos = set(
|
||||||
|
self.db.scalars(
|
||||||
|
select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
claim_count = 0
|
||||||
|
item_count = 0
|
||||||
|
for plan in plans:
|
||||||
|
if plan.claim_no in existing_claim_nos:
|
||||||
|
continue
|
||||||
|
claim_count += 1
|
||||||
|
item_count += len(plan.items)
|
||||||
|
if not apply:
|
||||||
|
continue
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id=plan.id,
|
||||||
|
claim_no=plan.claim_no,
|
||||||
|
employee_id=plan.employee.id,
|
||||||
|
employee_name=plan.employee.name,
|
||||||
|
department_id=plan.employee.department.id,
|
||||||
|
department_name=plan.employee.department.name,
|
||||||
|
project_code=SIM_PROJECT_CODE,
|
||||||
|
expense_type=plan.expense_type,
|
||||||
|
reason=plan.reason,
|
||||||
|
location=plan.location,
|
||||||
|
amount=plan.amount,
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=plan.invoice_count,
|
||||||
|
occurred_at=plan.occurred_at,
|
||||||
|
submitted_at=plan.submitted_at,
|
||||||
|
status=plan.status,
|
||||||
|
approval_stage=plan.approval_stage,
|
||||||
|
risk_flags_json=plan.risk_flags,
|
||||||
|
hermes_risk_flag=plan.hermes_risk_flag,
|
||||||
|
created_at=plan.occurred_at,
|
||||||
|
updated_at=updated_at_for_claim_plan(plan),
|
||||||
|
)
|
||||||
|
claim.items = [
|
||||||
|
ExpenseClaimItem(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{plan.claim_no}:{index}")),
|
||||||
|
item_date=item.item_date,
|
||||||
|
item_type=item.item_type,
|
||||||
|
item_reason=item.item_reason,
|
||||||
|
item_location=item.item_location,
|
||||||
|
item_amount=item.item_amount,
|
||||||
|
invoice_id=item.invoice_id,
|
||||||
|
)
|
||||||
|
for index, item in enumerate(plan.items, start=1)
|
||||||
|
]
|
||||||
|
self.db.add(claim)
|
||||||
|
if apply:
|
||||||
|
self.db.flush()
|
||||||
|
return claim_count, item_count
|
||||||
|
|
||||||
|
def _ensure_budget_usage(
|
||||||
|
self,
|
||||||
|
plans: list[ClaimPlan],
|
||||||
|
allocation_map: dict[tuple[int, str, str, str, str], str],
|
||||||
|
*,
|
||||||
|
apply: bool,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
existing_transactions = set(
|
||||||
|
self.db.scalars(
|
||||||
|
select(BudgetTransaction.transaction_no).where(
|
||||||
|
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
existing_reservations = set(
|
||||||
|
self.db.scalars(
|
||||||
|
select(BudgetReservation.reservation_no).where(
|
||||||
|
BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%")
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
transaction_count = 0
|
||||||
|
reservation_count = 0
|
||||||
|
for index, plan in enumerate(plans, start=1):
|
||||||
|
if plan.status not in BUDGETED_STATUSES:
|
||||||
|
continue
|
||||||
|
allocation_id = allocation_map.get(self._allocation_key(plan))
|
||||||
|
if not allocation_id:
|
||||||
|
continue
|
||||||
|
transaction_no = f"{SIM_TRANSACTION_PREFIX}-{index:04d}"
|
||||||
|
if transaction_no not in existing_transactions:
|
||||||
|
transaction_count += 1
|
||||||
|
if apply:
|
||||||
|
self.db.add(self._transaction_for_plan(plan, allocation_id, transaction_no))
|
||||||
|
if plan.status in PENDING_STATUSES:
|
||||||
|
reservation_no = f"{SIM_RESERVATION_PREFIX}-{index:04d}"
|
||||||
|
if reservation_no not in existing_reservations:
|
||||||
|
reservation_count += 1
|
||||||
|
if apply:
|
||||||
|
self.db.add(self._reservation_for_plan(plan, allocation_id, reservation_no))
|
||||||
|
if apply:
|
||||||
|
self.db.flush()
|
||||||
|
return transaction_count, reservation_count
|
||||||
|
|
||||||
|
def _ensure_risk_observations(self, plans: list[ClaimPlan], *, apply: bool) -> int:
|
||||||
|
existing_keys = set(
|
||||||
|
self.db.scalars(
|
||||||
|
select(RiskObservation.observation_key).where(
|
||||||
|
RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%")
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
count = 0
|
||||||
|
for index, plan in enumerate(plans, start=1):
|
||||||
|
if not plan.risk_flags:
|
||||||
|
continue
|
||||||
|
key = f"{SIM_RISK_PREFIX}-{index:04d}"
|
||||||
|
if key in existing_keys:
|
||||||
|
continue
|
||||||
|
count += 1
|
||||||
|
if not apply:
|
||||||
|
continue
|
||||||
|
first_flag = plan.risk_flags[0]
|
||||||
|
self.db.add(
|
||||||
|
RiskObservation(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{key}")),
|
||||||
|
observation_key=key,
|
||||||
|
subject_type="expense_claim",
|
||||||
|
subject_key=plan.claim_no,
|
||||||
|
subject_label=plan.claim_no,
|
||||||
|
claim_id=plan.id,
|
||||||
|
claim_no=plan.claim_no,
|
||||||
|
risk_type="simulation",
|
||||||
|
risk_signal=str(first_flag.get("event_type") or "amount_outlier"),
|
||||||
|
title=str(first_flag.get("label") or "模拟风险观察"),
|
||||||
|
description=str(first_flag.get("message") or ""),
|
||||||
|
risk_score=int(first_flag.get("risk_score") or 72),
|
||||||
|
risk_level=str(first_flag.get("severity") or "medium"),
|
||||||
|
confidence_score=0.78,
|
||||||
|
control_stage="reimbursement",
|
||||||
|
control_mode="manual_review",
|
||||||
|
automation_mode="simulation",
|
||||||
|
source="half_year_expense_simulation",
|
||||||
|
algorithm_version="simulation.v1",
|
||||||
|
status="pending_review",
|
||||||
|
evidence_json=[
|
||||||
|
{"label": "报销单号", "value": plan.claim_no},
|
||||||
|
{"label": "金额", "value": str(plan.amount)},
|
||||||
|
],
|
||||||
|
ontology_json={"scenario": "expense", "intent": "risk_check"},
|
||||||
|
created_at=plan.submitted_at or plan.occurred_at,
|
||||||
|
updated_at=updated_at_for_claim_plan(plan),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if apply:
|
||||||
|
self.db.flush()
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _find_sim_allocation(self, plan: AllocationPlan) -> BudgetAllocation | None:
|
||||||
|
year, period_key, department_id, cost_center, subject_code = plan.key
|
||||||
|
stmt = (
|
||||||
|
select(BudgetAllocation)
|
||||||
|
.where(BudgetAllocation.fiscal_year == year)
|
||||||
|
.where(BudgetAllocation.period_key == period_key)
|
||||||
|
.where(BudgetAllocation.subject_code == subject_code)
|
||||||
|
.where(BudgetAllocation.project_code == SIM_PROJECT_CODE)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
BudgetAllocation.department_id == department_id,
|
||||||
|
BudgetAllocation.cost_center == cost_center,
|
||||||
|
BudgetAllocation.department_name == plan.department.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return self.db.scalar(stmt)
|
||||||
|
|
||||||
|
def _transaction_for_plan(
|
||||||
|
self,
|
||||||
|
plan: ClaimPlan,
|
||||||
|
allocation_id: str,
|
||||||
|
transaction_no: str,
|
||||||
|
) -> BudgetTransaction:
|
||||||
|
transaction_type = "consume" if plan.status in SUCCESS_STATUSES else "reserve"
|
||||||
|
return BudgetTransaction(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{transaction_no}")),
|
||||||
|
transaction_no=transaction_no,
|
||||||
|
allocation_id=allocation_id,
|
||||||
|
source_type="claim",
|
||||||
|
source_id=plan.id,
|
||||||
|
source_no=plan.claim_no,
|
||||||
|
transaction_type=transaction_type,
|
||||||
|
amount=plan.amount,
|
||||||
|
before_available_amount=Decimal("0.00"),
|
||||||
|
after_available_amount=Decimal("0.00"),
|
||||||
|
operator="simulation",
|
||||||
|
reason=(
|
||||||
|
"半年报销模拟数据预算核销"
|
||||||
|
if transaction_type == "consume"
|
||||||
|
else "半年报销模拟数据预算预占"
|
||||||
|
),
|
||||||
|
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True},
|
||||||
|
created_at=plan.submitted_at or plan.occurred_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reservation_for_plan(
|
||||||
|
self,
|
||||||
|
plan: ClaimPlan,
|
||||||
|
allocation_id: str,
|
||||||
|
reservation_no: str,
|
||||||
|
) -> BudgetReservation:
|
||||||
|
return BudgetReservation(
|
||||||
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{reservation_no}")),
|
||||||
|
reservation_no=reservation_no,
|
||||||
|
allocation_id=allocation_id,
|
||||||
|
source_type="claim",
|
||||||
|
source_id=plan.id,
|
||||||
|
source_no=plan.claim_no,
|
||||||
|
source_status="active",
|
||||||
|
amount=plan.amount,
|
||||||
|
consumed_amount=Decimal("0.00"),
|
||||||
|
released_amount=Decimal("0.00"),
|
||||||
|
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True},
|
||||||
|
created_at=plan.submitted_at or plan.occurred_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _allocation_key(self, plan: ClaimPlan) -> tuple[int, str, str, str, str]:
|
||||||
|
department = plan.employee.department
|
||||||
|
return (
|
||||||
|
plan.occurred_at.year,
|
||||||
|
plan.period_key,
|
||||||
|
department.id,
|
||||||
|
department.cost_center,
|
||||||
|
plan.budget_subject_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _month_starts(self) -> list[date]:
|
||||||
|
current = self.config.start_date.replace(day=1)
|
||||||
|
months: list[date] = []
|
||||||
|
for _ in range(max(1, self.config.months)):
|
||||||
|
months.append(current)
|
||||||
|
year = current.year + (1 if current.month == 12 else 0)
|
||||||
|
month = 1 if current.month == 12 else current.month + 1
|
||||||
|
current = date(year, month, 1)
|
||||||
|
return months
|
||||||
|
|
||||||
|
def _period_end(self) -> date:
|
||||||
|
months = self._month_starts()
|
||||||
|
last_month = months[-1]
|
||||||
|
return last_month.replace(day=calendar.monthrange(last_month.year, last_month.month)[1])
|
||||||
|
|
||||||
|
def _claim_day(
|
||||||
|
self,
|
||||||
|
months: list[date],
|
||||||
|
employee_index: int,
|
||||||
|
local_index: int,
|
||||||
|
claim_index: int,
|
||||||
|
) -> date:
|
||||||
|
visible_day = recent_visible_claim_day(
|
||||||
|
months,
|
||||||
|
employee_index=employee_index,
|
||||||
|
claim_index=claim_index,
|
||||||
|
)
|
||||||
|
if visible_day is not None:
|
||||||
|
return visible_day
|
||||||
|
month = months[(employee_index + local_index * 2) % len(months)]
|
||||||
|
_, max_day = calendar.monthrange(month.year, month.month)
|
||||||
|
day = 1 + ((employee_index * 7 + local_index * 11 + self.rng.randint(0, 5)) % max_day)
|
||||||
|
return month.replace(day=day)
|
||||||
|
|
||||||
|
def _weighted_department(self, departments: list[DepartmentRef], index: int) -> DepartmentRef:
|
||||||
|
weighted: list[DepartmentRef] = []
|
||||||
|
by_code = {item.unit_code: item for item in departments}
|
||||||
|
for code, weight in DEPARTMENT_EMPLOYEE_WEIGHTS.items():
|
||||||
|
if code in by_code:
|
||||||
|
weighted.extend([by_code[code]] * weight)
|
||||||
|
weighted = weighted or departments or list(DEFAULT_DEPARTMENTS)
|
||||||
|
return weighted[index % len(weighted)]
|
||||||
|
|
||||||
|
def _expense_type_for_employee(self, employee: EmployeeRef) -> str:
|
||||||
|
weights = DEPARTMENT_CLAIM_WEIGHTS.get(
|
||||||
|
employee.department.unit_code,
|
||||||
|
{"travel": 3, "meal": 2, "office": 2, "communication": 1},
|
||||||
|
)
|
||||||
|
subjects = list(weights)
|
||||||
|
return self.rng.choices(subjects, weights=[weights[item] for item in subjects], k=1)[0]
|
||||||
|
|
||||||
|
def _claim_count_for_employee(self, employee: EmployeeRef, index: int) -> int:
|
||||||
|
base = 7 + (index % 5)
|
||||||
|
if employee.department.unit_code in {"MARKET-DEPT", "TECH-DEPT"}:
|
||||||
|
base += 3
|
||||||
|
elif employee.department.unit_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
|
||||||
|
base += 2
|
||||||
|
if employee.grade in {"P7", "P8"}:
|
||||||
|
base += 2
|
||||||
|
return max(6, min(base, 16))
|
||||||
|
|
||||||
|
def _claim_amount(
|
||||||
|
self,
|
||||||
|
employee: EmployeeRef,
|
||||||
|
expense_type: str,
|
||||||
|
occurred_day: date,
|
||||||
|
) -> Decimal:
|
||||||
|
subject = "meal" if expense_type == "entertainment" else expense_type
|
||||||
|
base = SUBJECT_BASE_AMOUNTS.get(subject, Decimal("1000.00"))
|
||||||
|
grade_factor = GRADE_FACTORS.get(employee.grade, Decimal("1.00"))
|
||||||
|
month_factor = MONTH_FACTORS.get(occurred_day.month, Decimal("1.00"))
|
||||||
|
department_factor = (
|
||||||
|
Decimal("1.18")
|
||||||
|
if employee.department.unit_code == "MARKET-DEPT"
|
||||||
|
else Decimal("1.00")
|
||||||
|
)
|
||||||
|
noise = Decimal(str(self.rng.uniform(0.72, 1.42))).quantize(Decimal("0.01"))
|
||||||
|
return (base * grade_factor * month_factor * department_factor * noise).quantize(
|
||||||
|
Decimal("0.01")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _status_for_claim(self, employee_index: int, local_index: int) -> tuple[str, str | None]:
|
||||||
|
selector = (employee_index * 11 + local_index * 17 + self.config.seed) % 100
|
||||||
|
if selector < 42:
|
||||||
|
return "paid", "已付款"
|
||||||
|
if selector < 62:
|
||||||
|
return "approved", "归档入账"
|
||||||
|
if selector < 75:
|
||||||
|
return "pending_payment", "待付款"
|
||||||
|
if selector < 84:
|
||||||
|
return "submitted", "财务审批"
|
||||||
|
if selector < 92:
|
||||||
|
return "submitted", "直属领导审批"
|
||||||
|
if selector < 96:
|
||||||
|
return "returned", "待补充"
|
||||||
|
if selector < 99:
|
||||||
|
return "rejected", "已驳回"
|
||||||
|
return "draft", "待提交"
|
||||||
|
|
||||||
|
def _risk_flags(
|
||||||
|
self,
|
||||||
|
employee: EmployeeRef,
|
||||||
|
expense_type: str,
|
||||||
|
amount: Decimal,
|
||||||
|
claim_index: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
base_probability = Decimal("0.10")
|
||||||
|
if amount >= SUBJECT_BASE_AMOUNTS.get(expense_type, Decimal("1000.00")) * Decimal("1.55"):
|
||||||
|
base_probability += Decimal("0.08")
|
||||||
|
if employee.department.unit_code in {"MARKET-DEPT", "PRESIDENT-OFFICE"}:
|
||||||
|
base_probability += Decimal("0.04")
|
||||||
|
if Decimal(str(self.rng.random())) > base_probability:
|
||||||
|
return []
|
||||||
|
event_type, label = risk_type(claim_index, expense_type)
|
||||||
|
severity = "high" if amount > Decimal("9000.00") or claim_index % 7 == 0 else "medium"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"source": "half_year_expense_simulation",
|
||||||
|
"event_type": event_type,
|
||||||
|
"severity": severity,
|
||||||
|
"label": label,
|
||||||
|
"message": (
|
||||||
|
f"{employee.name} 的"
|
||||||
|
f"{SUBJECT_LABELS.get(expense_type, expense_type)}样本触发{label}。"
|
||||||
|
),
|
||||||
|
"risk_score": 82 if severity == "high" else 68,
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def _claim_items(
|
||||||
|
self,
|
||||||
|
expense_type: str,
|
||||||
|
amount: Decimal,
|
||||||
|
occurred_day: date,
|
||||||
|
claim_index: int,
|
||||||
|
) -> list[ClaimItemPlan]:
|
||||||
|
if expense_type == "travel":
|
||||||
|
hotel = (amount * Decimal("0.48")).quantize(Decimal("0.01"))
|
||||||
|
transport = (amount * Decimal("0.37")).quantize(Decimal("0.01"))
|
||||||
|
allowance = amount - hotel - transport
|
||||||
|
return [
|
||||||
|
self._item("hotel", "项目出差住宿", hotel, occurred_day, claim_index, 1),
|
||||||
|
self._item("transport", "项目往返交通", transport, occurred_day, claim_index, 2),
|
||||||
|
self._item("travel_allowance", "差旅补贴", allowance, occurred_day, claim_index, 3),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
self._item(
|
||||||
|
expense_type,
|
||||||
|
item_reason(expense_type),
|
||||||
|
amount,
|
||||||
|
occurred_day,
|
||||||
|
claim_index,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _item(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
reason: str,
|
||||||
|
amount: Decimal,
|
||||||
|
item_date: date,
|
||||||
|
claim_index: int,
|
||||||
|
item_index: int,
|
||||||
|
) -> ClaimItemPlan:
|
||||||
|
return ClaimItemPlan(
|
||||||
|
item_date=item_date,
|
||||||
|
item_type=item_type,
|
||||||
|
item_reason=reason,
|
||||||
|
item_location=claim_location("上海", claim_index + item_index),
|
||||||
|
item_amount=amount.quantize(Decimal("0.01")),
|
||||||
|
invoice_id=f"SIM-INV-2026-{claim_index:04d}-{item_index}",
|
||||||
|
)
|
||||||
@@ -31,7 +31,12 @@ BUDGET_MONITOR_APPROVAL_GRADE = "P8"
|
|||||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||||
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
|
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
|
||||||
ARCHIVED_REIMBURSEMENT_STAGES = (ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE, "completed")
|
ARCHIVED_REIMBURSEMENT_STAGES = (
|
||||||
|
ARCHIVE_ACCOUNTING_STAGE,
|
||||||
|
PAYMENT_PAID_STAGE,
|
||||||
|
"payment",
|
||||||
|
"completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimAccessPolicy:
|
class ExpenseClaimAccessPolicy:
|
||||||
@@ -640,9 +645,23 @@ class ExpenseClaimAccessPolicy:
|
|||||||
include_approval_scope: bool = False,
|
include_approval_scope: bool = False,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
conditions = self.build_personal_claim_conditions(current_user)
|
conditions = self.build_personal_claim_conditions(current_user)
|
||||||
|
role_codes = self.normalize_role_codes(current_user)
|
||||||
|
|
||||||
|
if self.has_privileged_claim_access(current_user):
|
||||||
|
company_reimbursement_condition = and_(
|
||||||
|
func.lower(func.coalesce(ExpenseClaim.status, "")) != "draft",
|
||||||
|
func.lower(func.coalesce(ExpenseClaim.expense_type, "")) != "application",
|
||||||
|
~func.lower(func.coalesce(ExpenseClaim.expense_type, "")).like(
|
||||||
|
"%\\_application",
|
||||||
|
escape="\\",
|
||||||
|
),
|
||||||
|
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("AP-%"),
|
||||||
|
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("APP-%"),
|
||||||
|
~self.build_archived_claim_condition(),
|
||||||
|
)
|
||||||
|
conditions.append(company_reimbursement_condition)
|
||||||
|
|
||||||
if include_approval_scope:
|
if include_approval_scope:
|
||||||
role_codes = self.normalize_role_codes(current_user)
|
|
||||||
if current_user.is_admin or "executive" in role_codes:
|
if current_user.is_admin or "executive" in role_codes:
|
||||||
conditions.append(ExpenseClaim.status.in_(("submitted", PAYMENT_PENDING_STATUS, "returned")))
|
conditions.append(ExpenseClaim.status.in_(("submitted", PAYMENT_PENDING_STATUS, "returned")))
|
||||||
elif "finance" in role_codes:
|
elif "finance" in role_codes:
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ class ExpenseClaimApplicationHandoffMixin:
|
|||||||
"application_amount": application_amount,
|
"application_amount": application_amount,
|
||||||
"application_time": application_time,
|
"application_time": application_time,
|
||||||
"application_transport_mode": str(detail.get("transport_mode") or "").strip(),
|
"application_transport_mode": str(detail.get("transport_mode") or "").strip(),
|
||||||
|
"application_lodging_daily_cap": str(detail.get("lodging_daily_cap") or "").strip(),
|
||||||
|
"application_subsidy_daily_cap": str(detail.get("subsidy_daily_cap") or "").strip(),
|
||||||
|
"application_transport_policy": str(detail.get("transport_policy") or "").strip(),
|
||||||
|
"application_policy_estimate": str(detail.get("policy_estimate") or "").strip(),
|
||||||
|
"application_rule_name": str(detail.get("rule_name") or "").strip(),
|
||||||
|
"application_rule_version": str(detail.get("rule_version") or "").strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _create_reimbursement_draft_from_application(
|
def _create_reimbursement_draft_from_application(
|
||||||
|
|||||||
@@ -327,6 +327,10 @@ class ExpenseClaimDraftFlowMixin:
|
|||||||
)
|
)
|
||||||
self._sync_claim_from_items(claim)
|
self._sync_claim_from_items(claim)
|
||||||
elif skip_primary_item:
|
elif skip_primary_item:
|
||||||
|
self._clear_application_link_placeholder_items(claim, context_json=context_json)
|
||||||
|
if claim.items:
|
||||||
|
self._sync_claim_from_items(claim)
|
||||||
|
else:
|
||||||
self._sync_application_link_draft_without_items(claim)
|
self._sync_application_link_draft_without_items(claim)
|
||||||
else:
|
else:
|
||||||
self._upsert_primary_item(
|
self._upsert_primary_item(
|
||||||
@@ -394,6 +398,61 @@ class ExpenseClaimDraftFlowMixin:
|
|||||||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
||||||
|
|
||||||
|
def _clear_application_link_placeholder_items(
|
||||||
|
self,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
*,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
application_amounts = self._resolve_application_amount_candidates(context_json)
|
||||||
|
for item in list(claim.items or []):
|
||||||
|
if not self._is_application_link_placeholder_item(
|
||||||
|
item,
|
||||||
|
claim=claim,
|
||||||
|
context_json=context_json,
|
||||||
|
application_amounts=application_amounts,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
claim.items.remove(item)
|
||||||
|
self.db.delete(item)
|
||||||
|
|
||||||
|
def _is_application_link_placeholder_item(
|
||||||
|
self,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
*,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
application_amounts: set[Decimal],
|
||||||
|
) -> bool:
|
||||||
|
if str(item.invoice_id or "").strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
item_type = str(item.item_type or "").strip().lower()
|
||||||
|
if item_type in DOCUMENT_FACT_ITEM_TYPES:
|
||||||
|
return False
|
||||||
|
if item_type in SYSTEM_GENERATED_ITEM_TYPES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
claim_type = str(claim.expense_type or "").strip().lower()
|
||||||
|
if item_type and claim_type and item_type != claim_type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
amount = self._parse_context_money_amount(item.item_amount)
|
||||||
|
if application_amounts and amount is not None and amount > Decimal("0.00") and amount not in application_amounts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
reason = str(item.item_reason or "").strip()
|
||||||
|
if not reason or reason == "待补充":
|
||||||
|
return True
|
||||||
|
|
||||||
|
review_values = self._normalize_context_object(context_json.get("review_form_values"))
|
||||||
|
linked_reasons = {
|
||||||
|
str(review_values.get(key) or "").strip()
|
||||||
|
for key in ("application_reason", "reason", "business_reason")
|
||||||
|
}
|
||||||
|
linked_reasons.add(str(claim.reason or "").strip())
|
||||||
|
return reason in {value for value in linked_reasons if value}
|
||||||
|
|
||||||
def _should_skip_application_link_placeholder_item(
|
def _should_skip_application_link_placeholder_item(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -405,23 +464,10 @@ class ExpenseClaimDraftFlowMixin:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
if document_specs or attachment_count > 0:
|
if document_specs or attachment_count > 0:
|
||||||
return False
|
return False
|
||||||
if claim is not None and list(claim.items or []):
|
|
||||||
return False
|
|
||||||
if self._build_application_link_flag(context_json) is None:
|
if self._build_application_link_flag(context_json) is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
application_amounts = self._resolve_application_amount_candidates(context_json)
|
|
||||||
review_values = self._normalize_context_object(context_json.get("review_form_values"))
|
|
||||||
raw_amount = str(review_values.get("amount") or "").strip()
|
|
||||||
if raw_amount:
|
|
||||||
parsed_amount = self._parse_context_money_amount(raw_amount)
|
|
||||||
if parsed_amount is None:
|
|
||||||
return True
|
return True
|
||||||
return bool(application_amounts and parsed_amount in application_amounts)
|
|
||||||
|
|
||||||
if amount is None or amount <= Decimal("0.00"):
|
|
||||||
return True
|
|
||||||
return bool(application_amounts and amount in application_amounts)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
|
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
|
||||||
@@ -497,7 +543,26 @@ class ExpenseClaimDraftFlowMixin:
|
|||||||
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
|
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
|
||||||
application_reason = pick("application_reason", "applicationReason", "reason")
|
application_reason = pick("application_reason", "applicationReason", "reason")
|
||||||
application_location = pick("application_location", "applicationLocation", "location")
|
application_location = pick("application_location", "applicationLocation", "location")
|
||||||
application_date = pick("application_date", "applicationDate", "business_time", "time_range")
|
application_time = pick(
|
||||||
|
"application_business_time",
|
||||||
|
"applicationBusinessTime",
|
||||||
|
"application_time",
|
||||||
|
"applicationTime",
|
||||||
|
"business_time",
|
||||||
|
"businessTime",
|
||||||
|
"time_range",
|
||||||
|
"timeRange",
|
||||||
|
"time",
|
||||||
|
)
|
||||||
|
application_date = pick("application_date", "applicationDate")
|
||||||
|
application_days = pick("application_days", "applicationDays", "days")
|
||||||
|
application_transport_mode = pick("application_transport_mode", "applicationTransportMode", "transport_mode", "transportMode")
|
||||||
|
application_lodging_daily_cap = pick("application_lodging_daily_cap", "applicationLodgingDailyCap", "lodging_daily_cap", "lodgingDailyCap")
|
||||||
|
application_subsidy_daily_cap = pick("application_subsidy_daily_cap", "applicationSubsidyDailyCap", "subsidy_daily_cap", "subsidyDailyCap")
|
||||||
|
application_transport_policy = pick("application_transport_policy", "applicationTransportPolicy", "transport_policy", "transportPolicy")
|
||||||
|
application_policy_estimate = pick("application_policy_estimate", "applicationPolicyEstimate", "policy_estimate", "policyEstimate")
|
||||||
|
application_rule_name = pick("application_rule_name", "applicationRuleName", "rule_name", "ruleName")
|
||||||
|
application_rule_version = pick("application_rule_version", "applicationRuleVersion", "rule_version", "ruleVersion")
|
||||||
application_status = pick("application_status", "applicationStatus")
|
application_status = pick("application_status", "applicationStatus")
|
||||||
application_status_label = pick("application_status_label", "applicationStatusLabel")
|
application_status_label = pick("application_status_label", "applicationStatusLabel")
|
||||||
|
|
||||||
@@ -517,7 +582,17 @@ class ExpenseClaimDraftFlowMixin:
|
|||||||
"application_location": application_location,
|
"application_location": application_location,
|
||||||
"application_amount": application_amount,
|
"application_amount": application_amount,
|
||||||
"application_amount_label": application_amount_label,
|
"application_amount_label": application_amount_label,
|
||||||
"application_time": application_date,
|
"application_time": application_time or application_date,
|
||||||
|
"application_business_time": application_time,
|
||||||
|
"application_date": application_date,
|
||||||
|
"application_days": application_days,
|
||||||
|
"application_transport_mode": application_transport_mode,
|
||||||
|
"application_lodging_daily_cap": application_lodging_daily_cap,
|
||||||
|
"application_subsidy_daily_cap": application_subsidy_daily_cap,
|
||||||
|
"application_transport_policy": application_transport_policy,
|
||||||
|
"application_policy_estimate": application_policy_estimate,
|
||||||
|
"application_rule_name": application_rule_name,
|
||||||
|
"application_rule_version": application_rule_version,
|
||||||
},
|
},
|
||||||
"review_form_values": review_values,
|
"review_form_values": review_values,
|
||||||
"expense_scene_selection": scene_selection,
|
"expense_scene_selection": scene_selection,
|
||||||
|
|||||||
@@ -158,21 +158,139 @@ class ExpenseClaimItemSyncMixin:
|
|||||||
end_date = start_date
|
end_date = start_date
|
||||||
|
|
||||||
days = (end_date - start_date).days + 1
|
days = (end_date - start_date).days + 1
|
||||||
|
application_days = self._resolve_travel_allowance_days_from_application_link(claim)
|
||||||
explicit_days = max(
|
explicit_days = max(
|
||||||
(self._extract_travel_day_count(item.item_reason) for item in business_items),
|
(self._extract_travel_day_count(item.item_reason) for item in business_items),
|
||||||
default=0,
|
default=0,
|
||||||
)
|
)
|
||||||
|
unique_dates = {value for value in dated_items}
|
||||||
|
if application_days is not None and application_days[0] > days and len(unique_dates) <= 1:
|
||||||
|
return application_days
|
||||||
if explicit_days > 0:
|
if explicit_days > 0:
|
||||||
days = explicit_days
|
days = explicit_days
|
||||||
end_date = start_date + timedelta(days=days - 1)
|
end_date = start_date + timedelta(days=days - 1)
|
||||||
|
if application_days is not None and application_days[0] > days and len(unique_dates) <= 1:
|
||||||
|
return application_days
|
||||||
return max(1, days), start_date, end_date
|
return max(1, days), start_date, end_date
|
||||||
existing_days = self._extract_travel_allowance_days(existing_allowance)
|
existing_days = self._extract_travel_allowance_days(existing_allowance)
|
||||||
unique_dates = {value for value in dated_items}
|
|
||||||
if existing_days > days and len(unique_dates) <= 1:
|
if existing_days > days and len(unique_dates) <= 1:
|
||||||
days = existing_days
|
days = existing_days
|
||||||
end_date = start_date + timedelta(days=days - 1)
|
end_date = start_date + timedelta(days=days - 1)
|
||||||
return max(1, days), start_date, end_date
|
return max(1, days), start_date, end_date
|
||||||
|
|
||||||
|
def _resolve_travel_allowance_days_from_application_link(
|
||||||
|
self,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
) -> tuple[int, date, date] | None:
|
||||||
|
values = self._collect_application_link_values(claim)
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
time_text = str(
|
||||||
|
values.get("application_business_time")
|
||||||
|
or values.get("business_time")
|
||||||
|
or values.get("time_range")
|
||||||
|
or values.get("application_time")
|
||||||
|
or values.get("time")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
dates = self._extract_application_link_dates(time_text)
|
||||||
|
if len(dates) >= 2:
|
||||||
|
start_date, end_date = dates[0], dates[-1]
|
||||||
|
if end_date < start_date:
|
||||||
|
start_date, end_date = end_date, start_date
|
||||||
|
return max(1, (end_date - start_date).days + 1), start_date, end_date
|
||||||
|
|
||||||
|
days = self._extract_travel_day_count(
|
||||||
|
str(values.get("application_days") or values.get("days") or "").strip()
|
||||||
|
)
|
||||||
|
if days <= 0:
|
||||||
|
return None
|
||||||
|
start_date = dates[0] if dates else claim.occurred_at.date() if claim.occurred_at is not None else date.today()
|
||||||
|
end_date = start_date + timedelta(days=days - 1)
|
||||||
|
return days, start_date, end_date
|
||||||
|
|
||||||
|
def _collect_application_link_values(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
for flag in list(claim.risk_flags_json or []):
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
continue
|
||||||
|
if str(flag.get("source") or "").strip() not in {"application_link", "application_handoff"}:
|
||||||
|
continue
|
||||||
|
for source in (
|
||||||
|
flag.get("expense_scene_selection"),
|
||||||
|
flag.get("review_form_values"),
|
||||||
|
flag.get("application_detail"),
|
||||||
|
flag,
|
||||||
|
):
|
||||||
|
if isinstance(source, dict):
|
||||||
|
values.update(source)
|
||||||
|
linked_detail = self._resolve_linked_application_detail_values(values)
|
||||||
|
for key, value in linked_detail.items():
|
||||||
|
values.setdefault(key, value)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def _resolve_linked_application_detail_values(self, values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
application_claim = self._find_linked_application_claim(values)
|
||||||
|
if application_claim is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
detail: dict[str, Any] = {}
|
||||||
|
for flag in list(application_claim.risk_flags_json or []):
|
||||||
|
if not isinstance(flag, dict) or str(flag.get("source") or "").strip() != "application_detail":
|
||||||
|
continue
|
||||||
|
payload = flag.get("application_detail") or flag.get("applicationDetail") or {}
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
detail.update(payload)
|
||||||
|
if detail.get("time"):
|
||||||
|
detail.setdefault("application_time", detail.get("time"))
|
||||||
|
if detail.get("days"):
|
||||||
|
detail.setdefault("application_days", detail.get("days"))
|
||||||
|
if detail.get("transport_mode"):
|
||||||
|
detail.setdefault("application_transport_mode", detail.get("transport_mode"))
|
||||||
|
if detail.get("location"):
|
||||||
|
detail.setdefault("application_location", detail.get("location"))
|
||||||
|
if detail.get("reason"):
|
||||||
|
detail.setdefault("application_reason", detail.get("reason"))
|
||||||
|
if application_claim.occurred_at is not None:
|
||||||
|
detail.setdefault("application_time", application_claim.occurred_at.date().isoformat())
|
||||||
|
detail.setdefault("time", application_claim.occurred_at.date().isoformat())
|
||||||
|
detail.setdefault("application_reason", str(application_claim.reason or "").strip())
|
||||||
|
detail.setdefault("application_location", str(application_claim.location or "").strip())
|
||||||
|
return {str(key): value for key, value in detail.items() if str(value or "").strip()}
|
||||||
|
|
||||||
|
def _find_linked_application_claim(self, values: dict[str, Any]) -> ExpenseClaim | None:
|
||||||
|
application_claim_id = str(
|
||||||
|
values.get("application_claim_id")
|
||||||
|
or values.get("applicationClaimId")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if application_claim_id:
|
||||||
|
linked_claim = self.db.get(ExpenseClaim, application_claim_id)
|
||||||
|
if linked_claim is not None:
|
||||||
|
return linked_claim
|
||||||
|
|
||||||
|
application_claim_no = str(
|
||||||
|
values.get("application_claim_no")
|
||||||
|
or values.get("applicationClaimNo")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not application_claim_no:
|
||||||
|
return None
|
||||||
|
return self.db.scalar(
|
||||||
|
select(ExpenseClaim).where(ExpenseClaim.claim_no == application_claim_no)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_application_link_dates(value: str) -> list[date]:
|
||||||
|
dates: list[date] = []
|
||||||
|
for matched in re.findall(r"\d{4}-\d{2}-\d{2}", str(value or "")):
|
||||||
|
try:
|
||||||
|
dates.append(date.fromisoformat(matched))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return dates
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
|
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
|
||||||
if item is None:
|
if item is None:
|
||||||
|
|||||||
@@ -314,7 +314,13 @@ class ExpenseClaimOntologyResolverMixin:
|
|||||||
) -> datetime | None:
|
) -> datetime | None:
|
||||||
review_form_values = context_json.get("review_form_values")
|
review_form_values = context_json.get("review_form_values")
|
||||||
if isinstance(review_form_values, dict):
|
if isinstance(review_form_values, dict):
|
||||||
for key in ("occurred_date", "time_range", "business_time"):
|
for key in (
|
||||||
|
"occurred_date",
|
||||||
|
"time_range",
|
||||||
|
"business_time",
|
||||||
|
"application_business_time",
|
||||||
|
"application_time",
|
||||||
|
):
|
||||||
value = str(review_form_values.get(key) or "").strip()
|
value = str(review_form_values.get(key) or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
@@ -322,7 +328,9 @@ class ExpenseClaimOntologyResolverMixin:
|
|||||||
parsed = date.fromisoformat(value)
|
parsed = date.fromisoformat(value)
|
||||||
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
parsed = ExpenseClaimOntologyResolverMixin._resolve_first_date_from_text(value)
|
||||||
|
if parsed is not None:
|
||||||
|
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
||||||
|
|
||||||
start_date = ontology.time_range.start_date
|
start_date = ontology.time_range.start_date
|
||||||
if start_date:
|
if start_date:
|
||||||
@@ -333,6 +341,21 @@ class ExpenseClaimOntologyResolverMixin:
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_first_date_from_text(value: str) -> date | None:
|
||||||
|
match = re.search(r"20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}", str(value or ""))
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
normalized = match.group(0).replace("/", "-").replace(".", "-")
|
||||||
|
parts = [part for part in normalized.split("-") if part]
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
year, month, day = (int(part) for part in parts)
|
||||||
|
return date(year, month, day)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_amount(
|
def _resolve_amount(
|
||||||
entities: list[OntologyEntity],
|
entities: list[OntologyEntity],
|
||||||
|
|||||||
224
server/src/app/services/expense_claim_status_registry.py
Normal file
224
server/src/app/services/expense_claim_status_registry.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.expense_claim_workflow_constants import (
|
||||||
|
APPROVAL_DONE_STAGE,
|
||||||
|
ARCHIVE_ACCOUNTING_STAGE,
|
||||||
|
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||||
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
FINANCE_APPROVAL_STAGE,
|
||||||
|
PAYMENT_PAID_STAGE,
|
||||||
|
PAYMENT_PENDING_STAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExpenseClaimStatusSpec:
|
||||||
|
code: int
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
terminal: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExpenseClaimState:
|
||||||
|
status: str
|
||||||
|
approval_stage: str
|
||||||
|
status_code: int | None
|
||||||
|
status_label: str
|
||||||
|
changed: bool
|
||||||
|
|
||||||
|
|
||||||
|
CLAIM_STATUS_REGISTRY: dict[str, ExpenseClaimStatusSpec] = {
|
||||||
|
"draft": ExpenseClaimStatusSpec(10, "draft", "草稿"),
|
||||||
|
"submitted": ExpenseClaimStatusSpec(20, "submitted", "审批中"),
|
||||||
|
"approved": ExpenseClaimStatusSpec(30, "approved", "已通过"),
|
||||||
|
"pending_payment": ExpenseClaimStatusSpec(40, "pending_payment", "待付款"),
|
||||||
|
"paid": ExpenseClaimStatusSpec(50, "paid", "已付款", terminal=True),
|
||||||
|
"returned": ExpenseClaimStatusSpec(60, "returned", "待补充"),
|
||||||
|
"rejected": ExpenseClaimStatusSpec(70, "rejected", "已驳回", terminal=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
CLAIM_STATUS_ALIASES = {
|
||||||
|
"review": "submitted",
|
||||||
|
"pending_review": "submitted",
|
||||||
|
"approving": "submitted",
|
||||||
|
"manager_review": "submitted",
|
||||||
|
"budget_review": "submitted",
|
||||||
|
"finance_review": "submitted",
|
||||||
|
"completed": "approved",
|
||||||
|
"complete": "approved",
|
||||||
|
"payment": "pending_payment",
|
||||||
|
"supplement": "returned",
|
||||||
|
"草稿": "draft",
|
||||||
|
"待提交": "draft",
|
||||||
|
"已提交": "submitted",
|
||||||
|
"审批中": "submitted",
|
||||||
|
"审核中": "submitted",
|
||||||
|
"审批完成": "approved",
|
||||||
|
"已通过": "approved",
|
||||||
|
"归档入账": "approved",
|
||||||
|
"待付款": "pending_payment",
|
||||||
|
"已付款": "paid",
|
||||||
|
"待补充": "returned",
|
||||||
|
"已驳回": "rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
CANONICAL_APPROVAL_STAGES = {
|
||||||
|
"",
|
||||||
|
"待提交",
|
||||||
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||||
|
FINANCE_APPROVAL_STAGE,
|
||||||
|
APPROVAL_DONE_STAGE,
|
||||||
|
ARCHIVE_ACCOUNTING_STAGE,
|
||||||
|
PAYMENT_PENDING_STAGE,
|
||||||
|
PAYMENT_PAID_STAGE,
|
||||||
|
"待补充",
|
||||||
|
"已驳回",
|
||||||
|
}
|
||||||
|
|
||||||
|
STAGE_ALIASES = {
|
||||||
|
"draft": "待提交",
|
||||||
|
"review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"pending_review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"approving": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"manager_review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"budget_review": BUDGET_MANAGER_APPROVAL_STAGE,
|
||||||
|
"finance_review": FINANCE_APPROVAL_STAGE,
|
||||||
|
"pending_payment": PAYMENT_PENDING_STAGE,
|
||||||
|
"supplement": "待补充",
|
||||||
|
"rejected": "已驳回",
|
||||||
|
"草稿": "待提交",
|
||||||
|
"审核中": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_DEFAULT_STAGE = {
|
||||||
|
"draft": "待提交",
|
||||||
|
"submitted": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"pending_payment": PAYMENT_PENDING_STAGE,
|
||||||
|
"paid": PAYMENT_PAID_STAGE,
|
||||||
|
"returned": "待补充",
|
||||||
|
"rejected": "已驳回",
|
||||||
|
}
|
||||||
|
|
||||||
|
LEGACY_REVIEW_STATUS_STAGE = {
|
||||||
|
"review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"pending_review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"approving": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"manager_review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
"budget_review": BUDGET_MANAGER_APPROVAL_STAGE,
|
||||||
|
"finance_review": FINANCE_APPROVAL_STAGE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_claim_status(value: Any) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
lowered = raw.lower()
|
||||||
|
if lowered in CLAIM_STATUS_REGISTRY:
|
||||||
|
return lowered
|
||||||
|
return CLAIM_STATUS_ALIASES.get(lowered) or CLAIM_STATUS_ALIASES.get(raw) or raw
|
||||||
|
|
||||||
|
|
||||||
|
def claim_status_code(value: Any) -> int | None:
|
||||||
|
status = normalize_claim_status(value)
|
||||||
|
spec = CLAIM_STATUS_REGISTRY.get(status)
|
||||||
|
return spec.code if spec is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def claim_status_label(value: Any) -> str:
|
||||||
|
status = normalize_claim_status(value)
|
||||||
|
spec = CLAIM_STATUS_REGISTRY.get(status)
|
||||||
|
return spec.label if spec is not None else str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_claim_status(value: Any) -> bool:
|
||||||
|
return normalize_claim_status(value) in CLAIM_STATUS_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_approval_stage(value: Any) -> bool:
|
||||||
|
stage = str(value or "").strip()
|
||||||
|
normalized_stage = _normalize_stage_alias(stage)
|
||||||
|
return stage in CANONICAL_APPROVAL_STAGES or normalized_stage in CANONICAL_APPROVAL_STAGES
|
||||||
|
|
||||||
|
|
||||||
|
def is_application_claim_reference(
|
||||||
|
*,
|
||||||
|
claim_no: str | None = None,
|
||||||
|
expense_type: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
normalized_no = str(claim_no or "").strip().upper()
|
||||||
|
normalized_type = str(expense_type or "").strip().lower()
|
||||||
|
return (
|
||||||
|
normalized_no.startswith(("AP-", "APP-"))
|
||||||
|
or normalized_type == "application"
|
||||||
|
or normalized_type.endswith("_application")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_expense_claim_state(
|
||||||
|
status: Any,
|
||||||
|
approval_stage: Any,
|
||||||
|
*,
|
||||||
|
claim_no: str | None = None,
|
||||||
|
expense_type: str | None = None,
|
||||||
|
is_application_claim: bool | None = None,
|
||||||
|
) -> ExpenseClaimState:
|
||||||
|
original_status = str(status or "").strip()
|
||||||
|
original_stage = str(approval_stage or "").strip()
|
||||||
|
normalized_status = normalize_claim_status(original_status)
|
||||||
|
normalized_stage = _normalize_stage_alias(original_stage)
|
||||||
|
application = (
|
||||||
|
is_application_claim
|
||||||
|
if is_application_claim is not None
|
||||||
|
else is_application_claim_reference(claim_no=claim_no, expense_type=expense_type)
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy_status = original_status.lower()
|
||||||
|
if legacy_status in LEGACY_REVIEW_STATUS_STAGE:
|
||||||
|
normalized_stage = LEGACY_REVIEW_STATUS_STAGE[legacy_status]
|
||||||
|
elif normalized_status == "approved":
|
||||||
|
normalized_stage = _approved_stage(original_stage, application)
|
||||||
|
elif normalized_status == "pending_payment":
|
||||||
|
normalized_stage = PAYMENT_PENDING_STAGE
|
||||||
|
elif normalized_status == "paid":
|
||||||
|
normalized_stage = PAYMENT_PAID_STAGE
|
||||||
|
elif normalized_status in STATUS_DEFAULT_STAGE and not normalized_stage:
|
||||||
|
normalized_stage = STATUS_DEFAULT_STAGE[normalized_status]
|
||||||
|
|
||||||
|
if normalized_status == "submitted" and normalized_stage in {"payment", "completed"}:
|
||||||
|
normalized_stage = DIRECT_MANAGER_APPROVAL_STAGE
|
||||||
|
|
||||||
|
spec = CLAIM_STATUS_REGISTRY.get(normalized_status)
|
||||||
|
return ExpenseClaimState(
|
||||||
|
status=normalized_status,
|
||||||
|
approval_stage=normalized_stage,
|
||||||
|
status_code=spec.code if spec is not None else None,
|
||||||
|
status_label=spec.label if spec is not None else normalized_status,
|
||||||
|
changed=normalized_status != original_status or normalized_stage != original_stage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_stage_alias(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
lowered = value.lower()
|
||||||
|
return STAGE_ALIASES.get(lowered) or STAGE_ALIASES.get(value) or value
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_stage(raw_stage: str, is_application_claim: bool) -> str:
|
||||||
|
stage = _normalize_stage_alias(raw_stage)
|
||||||
|
lowered = str(raw_stage or "").strip().lower()
|
||||||
|
if is_application_claim:
|
||||||
|
if not stage or lowered == "completed":
|
||||||
|
return APPROVAL_DONE_STAGE
|
||||||
|
return stage
|
||||||
|
if stage in {ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE}:
|
||||||
|
return stage
|
||||||
|
if lowered in {"completed", "complete", ""} or stage == APPROVAL_DONE_STAGE:
|
||||||
|
return ARCHIVE_ACCOUNTING_STAGE
|
||||||
|
return stage
|
||||||
@@ -12,9 +12,9 @@ from sqlalchemy.orm import Session
|
|||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.budget import BudgetAllocation
|
from app.models.budget import BudgetAllocation
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.models.risk_observation import RiskObservation
|
|
||||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||||
from app.services.budget_support import BudgetSupportMixin
|
from app.services.budget_support import BudgetSupportMixin
|
||||||
|
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
|
||||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||||
|
|
||||||
SLA_TARGET_HOURS = Decimal("8.0")
|
SLA_TARGET_HOURS = Decimal("8.0")
|
||||||
@@ -30,6 +30,17 @@ PENDING_STATUSES = {
|
|||||||
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
|
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
|
||||||
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
|
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
|
||||||
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
|
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
|
||||||
|
EXPENSE_TYPE_ALIASES = {
|
||||||
|
"travel_application": "travel",
|
||||||
|
"business_travel": "travel",
|
||||||
|
"trip": "travel",
|
||||||
|
"traffic": "travel",
|
||||||
|
"transportation": "travel",
|
||||||
|
"hotel": "travel",
|
||||||
|
"accommodation": "travel",
|
||||||
|
"business_meal": "meal",
|
||||||
|
"communication_fee": "communication",
|
||||||
|
}
|
||||||
CHART_COLORS = [
|
CHART_COLORS = [
|
||||||
"var(--theme-primary)",
|
"var(--theme-primary)",
|
||||||
"var(--chart-blue)",
|
"var(--chart-blue)",
|
||||||
@@ -55,6 +66,17 @@ RISK_SIGNAL_LABELS = {
|
|||||||
"location_mismatch": "地点不一致",
|
"location_mismatch": "地点不一致",
|
||||||
"amount_outlier": "金额异常",
|
"amount_outlier": "金额异常",
|
||||||
"preapproval_absent": "缺少事前申请",
|
"preapproval_absent": "缺少事前申请",
|
||||||
|
"missing_material": "材料不完整",
|
||||||
|
"budget_pressure": "预算压力偏高",
|
||||||
|
"budget_overrun": "预算超支",
|
||||||
|
"budget_warning": "预算预警",
|
||||||
|
"over_budget": "预算超支",
|
||||||
|
"invoice_abnormal": "发票异常",
|
||||||
|
"invoice_missing": "缺少发票",
|
||||||
|
"missing_invoice": "缺少发票",
|
||||||
|
"policy_violation": "政策不符",
|
||||||
|
"abnormal_frequency": "频次异常",
|
||||||
|
"manual_review": "人工复核",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -83,31 +105,34 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
|
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
|
||||||
department_start, department_end = self._resolve_department_scope(department_range, now)
|
department_start, department_end = self._resolve_department_scope(department_range, now)
|
||||||
|
|
||||||
claims = self._fetch_claims()
|
claims = [
|
||||||
observations = self._fetch_risk_observations()
|
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
||||||
|
]
|
||||||
scope_claims = self._claims_between(claims, start, end)
|
scope_claims = self._claims_between(claims, start, end)
|
||||||
previous_claims = self._claims_between(claims, previous_start, start)
|
previous_claims = self._claims_between(claims, previous_start, start)
|
||||||
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
||||||
department_claims = self._claims_between(claims, department_start, department_end)
|
department_claims = self._claims_between(claims, department_start, department_end)
|
||||||
scope_observations = self._observations_between(observations, start, end)
|
|
||||||
|
|
||||||
totals = self._totals(scope_claims, scope_observations, now)
|
totals = self._totals(scope_claims)
|
||||||
previous_totals = self._totals(previous_claims, [], now)
|
previous_totals = self._totals(previous_claims)
|
||||||
|
|
||||||
return FinanceDashboardRead(
|
return FinanceDashboardRead(
|
||||||
range_key=resolved_key,
|
range_key=resolved_key,
|
||||||
start_date=start.date().isoformat(),
|
start_date=start.date().isoformat(),
|
||||||
end_date=(end - timedelta(days=1)).date().isoformat(),
|
end_date=(end - timedelta(days=1)).date().isoformat(),
|
||||||
generated_at=now.isoformat(),
|
generated_at=now.isoformat(),
|
||||||
has_real_data=bool(claims or observations or self._fetch_budget_allocations(now.year)),
|
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
|
||||||
totals=totals,
|
totals=totals,
|
||||||
metric_meta=self._metric_meta(totals, previous_totals),
|
metric_meta=self._metric_meta(totals, previous_totals),
|
||||||
trend=self._trend(trend_labels, trend_claims, now),
|
trend=self._trend(trend_labels, trend_claims, now),
|
||||||
spend_by_category=self._spend_by_category(scope_claims),
|
spend_by_category=self._spend_by_category(scope_claims),
|
||||||
exception_mix=self._exception_mix(scope_claims, scope_observations),
|
exception_mix=self._payment_status_mix(scope_claims),
|
||||||
department_ranking=self._department_ranking(department_claims),
|
department_ranking=self._department_ranking(department_claims),
|
||||||
bottlenecks=self._bottlenecks(scope_claims, now),
|
employee_ranking=self._employee_ranking(department_claims),
|
||||||
|
top_claims=self._top_claims(department_claims),
|
||||||
|
bottlenecks=self._bottlenecks(scope_claims),
|
||||||
budget_summary=self._budget_summary(now.year),
|
budget_summary=self._budget_summary(now.year),
|
||||||
|
budget_metrics=self._budget_metrics(now.year),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_storage_ready(self) -> None:
|
def _ensure_storage_ready(self) -> None:
|
||||||
@@ -117,10 +142,6 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
def _fetch_risk_observations(self) -> list[RiskObservation]:
|
|
||||||
stmt = select(RiskObservation).order_by(RiskObservation.created_at.asc())
|
|
||||||
return list(self.db.scalars(stmt).all())
|
|
||||||
|
|
||||||
def _fetch_budget_allocations(self, fiscal_year: int) -> list[BudgetAllocation]:
|
def _fetch_budget_allocations(self, fiscal_year: int) -> list[BudgetAllocation]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(BudgetAllocation)
|
select(BudgetAllocation)
|
||||||
@@ -192,50 +213,49 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
) -> list[ExpenseClaim]:
|
) -> list[ExpenseClaim]:
|
||||||
return [claim for claim in claims if start <= self._claim_time(claim) < end]
|
return [claim for claim in claims if start <= self._claim_time(claim) < end]
|
||||||
|
|
||||||
def _observations_between(
|
|
||||||
self,
|
|
||||||
observations: list[RiskObservation],
|
|
||||||
start: datetime,
|
|
||||||
end: datetime,
|
|
||||||
) -> list[RiskObservation]:
|
|
||||||
return [item for item in observations if start <= self._as_utc(item.created_at) < end]
|
|
||||||
|
|
||||||
def _totals(
|
def _totals(
|
||||||
self,
|
self,
|
||||||
claims: list[ExpenseClaim],
|
claims: list[ExpenseClaim],
|
||||||
observations: list[RiskObservation],
|
|
||||||
now: datetime,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
active_claims = [claim for claim in claims if self._status(claim) not in {"draft", "deleted"}]
|
active_claims = [
|
||||||
pending_claims = [claim for claim in active_claims if self._status(claim) in PENDING_STATUSES]
|
claim for claim in claims if self._status(claim) not in {"draft", "deleted"}
|
||||||
success_claims = [claim for claim in active_claims if self._status(claim) in SUCCESS_STATUSES]
|
]
|
||||||
risk_claim_keys = {self._claim_key(claim) for claim in active_claims if self._has_claim_risk(claim)}
|
spend_claims = [
|
||||||
observation_keys = {
|
claim for claim in active_claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||||
str(item.claim_no or item.subject_key or item.id).strip()
|
]
|
||||||
for item in observations
|
pending_payment_claims = [
|
||||||
if str(item.status or "").strip().lower() != "false_positive"
|
claim for claim in spend_claims if self._status(claim) == "pending_payment"
|
||||||
}
|
]
|
||||||
sla_hours = [self._claim_sla_hours(claim, now) for claim in active_claims if claim.submitted_at]
|
paid_claims = [claim for claim in spend_claims if self._status(claim) == "paid"]
|
||||||
sla_met = sum(1 for hours in sla_hours if hours <= SLA_TARGET_HOURS)
|
total_amount = sum((self._claim_amount(claim) for claim in spend_claims), Decimal("0.00"))
|
||||||
clean_success = sum(1 for claim in success_claims if not self._has_claim_risk(claim))
|
pending_payment_amount = sum(
|
||||||
|
(self._claim_amount(claim) for claim in pending_payment_claims),
|
||||||
|
Decimal("0.00"),
|
||||||
|
)
|
||||||
|
budget_summary = self._budget_summary(datetime.now(UTC).year)
|
||||||
|
avg_amount = (
|
||||||
|
total_amount / Decimal(str(len(spend_claims)))
|
||||||
|
if spend_claims
|
||||||
|
else Decimal("0.00")
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"pendingCount": len(pending_claims),
|
"reimbursementAmount": self._decimal_number(total_amount),
|
||||||
"pendingAmount": self._decimal_number(sum((self._claim_amount(claim) for claim in pending_claims), Decimal("0.00"))),
|
"reimbursementCount": len(spend_claims),
|
||||||
"avgSla": self._decimal_number(self._average(sla_hours)),
|
"pendingPaymentAmount": self._decimal_number(pending_payment_amount),
|
||||||
"autoPassRate": self._percent(clean_success, len(active_claims)),
|
"avgClaimAmount": self._decimal_number(avg_amount),
|
||||||
"riskCount": len({key for key in risk_claim_keys | observation_keys if key}),
|
"budgetUsageRate": float(budget_summary.get("ratio") or 0),
|
||||||
"slaRate": self._percent(sla_met, len(sla_hours)),
|
"paymentClearanceRate": self._percent(len(paid_claims), len(spend_claims)),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|
def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|
||||||
unit_by_key = {
|
unit_by_key = {
|
||||||
"pendingCount": "单",
|
"reimbursementAmount": "元",
|
||||||
"pendingAmount": "元",
|
"reimbursementCount": "单",
|
||||||
"avgSla": "h",
|
"pendingPaymentAmount": "元",
|
||||||
"autoPassRate": "%",
|
"avgClaimAmount": "元",
|
||||||
"riskCount": "单",
|
"budgetUsageRate": "%",
|
||||||
"slaRate": "%",
|
"paymentClearanceRate": "%",
|
||||||
}
|
}
|
||||||
meta: dict[str, Any] = {}
|
meta: dict[str, Any] = {}
|
||||||
for key, current_value in current.items():
|
for key, current_value in current.items():
|
||||||
@@ -257,28 +277,34 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
claims: list[ExpenseClaim],
|
claims: list[ExpenseClaim],
|
||||||
now: datetime,
|
now: datetime,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
applications = [0 for _ in labels]
|
claim_count = [0 for _ in labels]
|
||||||
approved = [0 for _ in labels]
|
claim_amount = [Decimal("0.00") for _ in labels]
|
||||||
|
success_count = [0 for _ in labels]
|
||||||
hours: list[list[Decimal]] = [[] for _ in labels]
|
hours: list[list[Decimal]] = [[] for _ in labels]
|
||||||
index = {label: idx for idx, label in enumerate(labels)}
|
index = {label: idx for idx, label in enumerate(labels)}
|
||||||
|
|
||||||
for claim in claims:
|
for claim in claims:
|
||||||
if self._status(claim) == "draft":
|
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||||
continue
|
continue
|
||||||
label = self._date_label(self._claim_time(claim).date())
|
label = self._date_label(self._claim_time(claim).date())
|
||||||
if label not in index:
|
if label not in index:
|
||||||
continue
|
continue
|
||||||
bucket = index[label]
|
bucket = index[label]
|
||||||
applications[bucket] += 1
|
claim_count[bucket] += 1
|
||||||
|
claim_amount[bucket] += self._claim_amount(claim)
|
||||||
if self._status(claim) in SUCCESS_STATUSES:
|
if self._status(claim) in SUCCESS_STATUSES:
|
||||||
approved[bucket] += 1
|
success_count[bucket] += 1
|
||||||
if claim.submitted_at:
|
if claim.submitted_at:
|
||||||
hours[bucket].append(self._claim_sla_hours(claim, now))
|
hours[bucket].append(self._claim_sla_hours(claim, now))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
"applications": applications,
|
"claimCount": claim_count,
|
||||||
"approved": approved,
|
"claimAmount": [self._decimal_number(value) for value in claim_amount],
|
||||||
|
"successCount": success_count,
|
||||||
|
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。
|
||||||
|
"applications": claim_count,
|
||||||
|
"approved": success_count,
|
||||||
"avgHours": [self._decimal_number(self._average(row)) for row in hours],
|
"avgHours": [self._decimal_number(self._average(row)) for row in hours],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,79 +313,178 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
for claim in claims:
|
for claim in claims:
|
||||||
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||||
continue
|
continue
|
||||||
label = EXPENSE_TYPE_LABELS.get(str(claim.expense_type or "").strip(), claim.expense_type)
|
buckets[self._expense_type_label(claim.expense_type)] += self._claim_amount(claim)
|
||||||
buckets[str(label or "其他费用")] += self._claim_amount(claim)
|
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
{"name": name, "value": self._decimal_number(value), "color": CHART_COLORS[index % len(CHART_COLORS)]}
|
{
|
||||||
for index, (name, value) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
|
"name": name,
|
||||||
|
"value": self._decimal_number(value),
|
||||||
|
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||||
|
}
|
||||||
|
for index, (name, value) in enumerate(
|
||||||
|
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
return rows or EMPTY_DONUT
|
return rows or EMPTY_DONUT
|
||||||
|
|
||||||
def _exception_mix(
|
def _payment_status_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||||
self,
|
|
||||||
claims: list[ExpenseClaim],
|
|
||||||
observations: list[RiskObservation],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
buckets: dict[str, int] = defaultdict(int)
|
buckets: dict[str, int] = defaultdict(int)
|
||||||
|
|
||||||
for observation in observations:
|
|
||||||
key = str(observation.risk_signal or observation.risk_type or "").strip()
|
|
||||||
buckets[RISK_SIGNAL_LABELS.get(key, key.replace("_", " ") or "风险观察")] += 1
|
|
||||||
|
|
||||||
if not buckets:
|
|
||||||
for claim in claims:
|
for claim in claims:
|
||||||
if self._status(claim) in {"draft", "deleted"}:
|
status = self._status(claim)
|
||||||
|
if status in {"draft", "deleted"}:
|
||||||
continue
|
continue
|
||||||
for label in self._claim_risk_labels(claim):
|
buckets[self._finance_status_label(status)] += 1
|
||||||
buckets[label] += 1
|
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
{"name": name, "value": count, "color": CHART_COLORS[index % len(CHART_COLORS)]}
|
{"name": name, "value": count, "color": CHART_COLORS[index % len(CHART_COLORS)]}
|
||||||
for index, (name, count) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
|
for index, (name, count) in enumerate(
|
||||||
|
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
return rows or EMPTY_DONUT
|
return rows or EMPTY_DONUT
|
||||||
|
|
||||||
def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
counts: dict[str, int] = defaultdict(int)
|
||||||
|
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
for claim in claims:
|
for claim in claims:
|
||||||
if self._status(claim) not in PENDING_STATUSES:
|
status = self._status(claim)
|
||||||
|
if status in EXCLUDED_SPEND_STATUSES:
|
||||||
continue
|
continue
|
||||||
buckets[str(claim.department_name or "未归属部门")] += self._claim_amount(claim)
|
department_name = str(claim.department_name or "").strip()
|
||||||
|
if self._is_missing_finance_dimension(department_name):
|
||||||
|
continue
|
||||||
|
amount = self._claim_amount(claim)
|
||||||
|
buckets[department_name] += amount
|
||||||
|
counts[department_name] += 1
|
||||||
|
if status in PENDING_STATUSES:
|
||||||
|
pending_amounts[department_name] += amount
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"amount": self._decimal_number(amount),
|
"amount": self._decimal_number(amount),
|
||||||
"value": self._decimal_number(amount),
|
"value": self._decimal_number(amount),
|
||||||
|
"count": counts[name],
|
||||||
|
"pendingAmount": self._decimal_number(pending_amounts[name]),
|
||||||
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||||
}
|
}
|
||||||
for index, (name, amount) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:5])
|
for index, (name, amount) in enumerate(
|
||||||
|
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def _bottlenecks(self, claims: list[ExpenseClaim], now: datetime) -> list[dict[str, Any]]:
|
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||||
buckets: dict[str, list[Decimal]] = defaultdict(list)
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
counts: dict[str, int] = defaultdict(int)
|
||||||
|
departments: dict[str, str] = {}
|
||||||
for claim in claims:
|
for claim in claims:
|
||||||
if self._status(claim) not in PENDING_STATUSES:
|
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||||
continue
|
continue
|
||||||
stage = self._stage_label(claim)
|
employee_name = str(claim.employee_name or "").strip()
|
||||||
buckets[stage].append(self._claim_sla_hours(claim, now))
|
if self._is_missing_finance_dimension(employee_name):
|
||||||
|
continue
|
||||||
|
amount = self._claim_amount(claim)
|
||||||
|
buckets[employee_name] += amount
|
||||||
|
counts[employee_name] += 1
|
||||||
|
departments.setdefault(employee_name, str(claim.department_name or "").strip())
|
||||||
|
|
||||||
rows: list[dict[str, Any]] = []
|
return [
|
||||||
for index, (stage, values) in enumerate(sorted(buckets.items(), key=lambda item: self._average(item[1]), reverse=True)[:3]):
|
|
||||||
avg_hours = self._average(values)
|
|
||||||
rows.append(
|
|
||||||
{
|
{
|
||||||
"name": stage,
|
"name": name,
|
||||||
"role": "审批节点",
|
"department": departments.get(name, ""),
|
||||||
"duration": f"{self._decimal_number(avg_hours):.1f} h",
|
"amount": self._decimal_number(amount),
|
||||||
"status": self._duration_status(avg_hours),
|
"value": self._decimal_number(amount),
|
||||||
"tone": self._duration_tone(avg_hours),
|
"count": counts[name],
|
||||||
"avatar": stage[:1] or str(index + 1),
|
"avgAmount": self._decimal_number(amount / Decimal(str(counts[name]))),
|
||||||
|
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||||
}
|
}
|
||||||
|
for index, (name, amount) in enumerate(
|
||||||
|
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||||
)
|
)
|
||||||
return rows
|
]
|
||||||
|
|
||||||
|
def _top_claims(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||||
|
spend_claims = [
|
||||||
|
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"claimNo": claim.claim_no,
|
||||||
|
"employeeName": claim.employee_name,
|
||||||
|
"departmentName": self._display_finance_dimension(
|
||||||
|
claim.department_name,
|
||||||
|
fallback="未归属部门",
|
||||||
|
),
|
||||||
|
"expenseTypeLabel": self._expense_type_label(claim.expense_type),
|
||||||
|
"amount": self._decimal_number(self._claim_amount(claim)),
|
||||||
|
"amountLabel": self._currency(self._claim_amount(claim)),
|
||||||
|
"statusLabel": self._finance_status_label(self._status(claim)),
|
||||||
|
}
|
||||||
|
for claim in sorted(spend_claims, key=self._claim_amount, reverse=True)[:6]
|
||||||
|
]
|
||||||
|
|
||||||
|
def _bottlenecks(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||||
|
active_claims = [
|
||||||
|
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||||
|
]
|
||||||
|
pending_payment_claims = [
|
||||||
|
claim for claim in active_claims if self._status(claim) == "pending_payment"
|
||||||
|
]
|
||||||
|
paid_claims = [claim for claim in active_claims if self._status(claim) == "paid"]
|
||||||
|
submitted_claims = [
|
||||||
|
claim for claim in active_claims if self._status(claim) in PENDING_STATUSES
|
||||||
|
]
|
||||||
|
budget_rows = self._budget_focus_rows()
|
||||||
|
|
||||||
|
pending_payment_amount = sum(
|
||||||
|
(self._claim_amount(claim) for claim in pending_payment_claims),
|
||||||
|
Decimal("0.00"),
|
||||||
|
)
|
||||||
|
high_claim = max(
|
||||||
|
(self._claim_amount(claim) for claim in active_claims),
|
||||||
|
default=Decimal("0.00"),
|
||||||
|
)
|
||||||
|
payment_clearance = self._percent(len(paid_claims), len(active_claims))
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
*budget_rows,
|
||||||
|
self._focus_item(
|
||||||
|
name="待付款",
|
||||||
|
role="资金计划",
|
||||||
|
duration=self._currency(pending_payment_amount),
|
||||||
|
status=f"{len(pending_payment_claims)} 单",
|
||||||
|
tone="warning" if pending_payment_claims else "success",
|
||||||
|
avatar="付",
|
||||||
|
),
|
||||||
|
self._focus_item(
|
||||||
|
name="高额单据",
|
||||||
|
role="费用集中度",
|
||||||
|
duration=self._currency(high_claim),
|
||||||
|
status="本期最高",
|
||||||
|
tone="warning" if high_claim >= Decimal("10000") else "success",
|
||||||
|
avatar="高",
|
||||||
|
),
|
||||||
|
self._focus_item(
|
||||||
|
name="待入账",
|
||||||
|
role="月结准备",
|
||||||
|
duration=f"{len(submitted_claims)} 单",
|
||||||
|
status="待流转" if submitted_claims else "已清理",
|
||||||
|
tone="warning" if submitted_claims else "success",
|
||||||
|
avatar="账",
|
||||||
|
),
|
||||||
|
self._focus_item(
|
||||||
|
name="付款完成率",
|
||||||
|
role="付款执行",
|
||||||
|
duration=f"{payment_clearance:.1f}%",
|
||||||
|
status=f"{len(paid_claims)} 单已付",
|
||||||
|
tone="success" if payment_clearance >= 80 else "warning",
|
||||||
|
avatar="率",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
priority = {"danger": 0, "warning": 1, "success": 2}
|
||||||
|
return sorted(rows, key=lambda item: priority.get(str(item.get("tone")), 3))[:6]
|
||||||
|
|
||||||
def _budget_summary(self, fiscal_year: int) -> dict[str, Any]:
|
def _budget_summary(self, fiscal_year: int) -> dict[str, Any]:
|
||||||
allocations = self._fetch_budget_allocations(fiscal_year)
|
allocations = self._fetch_budget_allocations(fiscal_year)
|
||||||
@@ -384,6 +509,149 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
"left": self._currency(available),
|
"left": self._currency(available),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _budget_metrics(self, fiscal_year: int) -> list[dict[str, Any]]:
|
||||||
|
allocations = self._fetch_budget_allocations(fiscal_year)
|
||||||
|
total = Decimal("0.00")
|
||||||
|
consumed = Decimal("0.00")
|
||||||
|
reserved = Decimal("0.00")
|
||||||
|
available = Decimal("0.00")
|
||||||
|
over_count = 0
|
||||||
|
warning_count = 0
|
||||||
|
|
||||||
|
for allocation in allocations:
|
||||||
|
balance = self.get_balance(allocation)
|
||||||
|
total += balance.total_amount
|
||||||
|
consumed += balance.consumed_amount
|
||||||
|
reserved += balance.reserved_amount
|
||||||
|
available += balance.available_amount
|
||||||
|
if balance.available_amount < Decimal("0.00"):
|
||||||
|
over_count += 1
|
||||||
|
continue
|
||||||
|
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
|
||||||
|
warning_count += 1
|
||||||
|
|
||||||
|
used = consumed + reserved
|
||||||
|
usage_rate = Decimal("0.00")
|
||||||
|
if total > Decimal("0.00"):
|
||||||
|
usage_rate = (used / total) * Decimal("100")
|
||||||
|
|
||||||
|
return [
|
||||||
|
self._budget_metric(
|
||||||
|
label="预算池数量",
|
||||||
|
value=f"{len(allocations)} 个",
|
||||||
|
detail="年度有效预算池",
|
||||||
|
tone="neutral",
|
||||||
|
icon="mdi mdi-database-outline",
|
||||||
|
),
|
||||||
|
self._budget_metric(
|
||||||
|
label="总预算",
|
||||||
|
value=self._currency(total),
|
||||||
|
detail="原始预算 + 调整",
|
||||||
|
tone="neutral",
|
||||||
|
icon="mdi mdi-cash-register",
|
||||||
|
),
|
||||||
|
self._budget_metric(
|
||||||
|
label="已用预算",
|
||||||
|
value=self._currency(used),
|
||||||
|
detail=f"使用率 {self._decimal_number(usage_rate):.1f}%",
|
||||||
|
tone="warning" if usage_rate >= Decimal("80") else "success",
|
||||||
|
icon="mdi mdi-chart-arc",
|
||||||
|
),
|
||||||
|
self._budget_metric(
|
||||||
|
label="预占预算",
|
||||||
|
value=self._currency(reserved),
|
||||||
|
detail="待流转单据占用",
|
||||||
|
tone="warning" if reserved > Decimal("0.00") else "success",
|
||||||
|
icon="mdi mdi-lock-outline",
|
||||||
|
),
|
||||||
|
self._budget_metric(
|
||||||
|
label="可用预算",
|
||||||
|
value=self._currency(available),
|
||||||
|
detail="可继续使用额度",
|
||||||
|
tone="danger" if available < Decimal("0.00") else "success",
|
||||||
|
icon="mdi mdi-wallet-outline",
|
||||||
|
),
|
||||||
|
self._budget_metric(
|
||||||
|
label="预警预算池",
|
||||||
|
value=f"{warning_count} 个",
|
||||||
|
detail=f"超支 {over_count} 个",
|
||||||
|
tone="danger" if over_count else "warning" if warning_count else "success",
|
||||||
|
icon="mdi mdi-alert-outline",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _budget_metric(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
value: str,
|
||||||
|
detail: str,
|
||||||
|
tone: str,
|
||||||
|
icon: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"label": label,
|
||||||
|
"value": value,
|
||||||
|
"detail": detail,
|
||||||
|
"tone": tone,
|
||||||
|
"icon": icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _budget_focus_rows(self) -> list[dict[str, Any]]:
|
||||||
|
allocations = self._fetch_budget_allocations(datetime.now(UTC).year)
|
||||||
|
over_count = 0
|
||||||
|
warning_count = 0
|
||||||
|
over_amount = Decimal("0.00")
|
||||||
|
warning_used = Decimal("0.00")
|
||||||
|
|
||||||
|
for allocation in allocations:
|
||||||
|
balance = self.get_balance(allocation)
|
||||||
|
if balance.available_amount < Decimal("0.00"):
|
||||||
|
over_count += 1
|
||||||
|
over_amount += abs(balance.available_amount)
|
||||||
|
continue
|
||||||
|
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
|
||||||
|
warning_count += 1
|
||||||
|
warning_used += balance.reserved_amount + balance.consumed_amount
|
||||||
|
|
||||||
|
return [
|
||||||
|
self._focus_item(
|
||||||
|
name="预算超支",
|
||||||
|
role="预算控制",
|
||||||
|
duration=f"{over_count} 个池",
|
||||||
|
status=self._currency(over_amount),
|
||||||
|
tone="danger" if over_count else "success",
|
||||||
|
avatar="超",
|
||||||
|
),
|
||||||
|
self._focus_item(
|
||||||
|
name="预算预警",
|
||||||
|
role="预算控制",
|
||||||
|
duration=f"{warning_count} 个池",
|
||||||
|
status=self._currency(warning_used),
|
||||||
|
tone="warning" if warning_count else "success",
|
||||||
|
avatar="预",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _focus_item(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
role: str,
|
||||||
|
duration: str,
|
||||||
|
status: str,
|
||||||
|
tone: str,
|
||||||
|
avatar: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"role": role,
|
||||||
|
"duration": duration,
|
||||||
|
"status": status,
|
||||||
|
"tone": tone,
|
||||||
|
"avatar": avatar,
|
||||||
|
}
|
||||||
|
|
||||||
def _claim_time(self, claim: ExpenseClaim) -> datetime:
|
def _claim_time(self, claim: ExpenseClaim) -> datetime:
|
||||||
return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at)
|
return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at)
|
||||||
|
|
||||||
@@ -410,10 +678,14 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
labels.append("风险扫描命中")
|
labels.append("风险扫描命中")
|
||||||
for flag in self._risk_flags(claim):
|
for flag in self._risk_flags(claim):
|
||||||
if isinstance(flag, dict):
|
if isinstance(flag, dict):
|
||||||
label = str(flag.get("label") or flag.get("message") or flag.get("type") or "").strip()
|
label = str(flag.get("label") or flag.get("message") or "").strip()
|
||||||
|
if not label:
|
||||||
|
label = self._risk_signal_label(
|
||||||
|
flag.get("type") or flag.get("risk_signal") or ""
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
label = str(flag or "").strip()
|
label = self._risk_signal_label(flag)
|
||||||
labels.append(label or "规则异常")
|
labels.append(self._display_risk_label(label))
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
def _risk_flags(self, claim: ExpenseClaim) -> list[Any]:
|
def _risk_flags(self, claim: ExpenseClaim) -> list[Any]:
|
||||||
@@ -424,6 +696,70 @@ class FinanceDashboardService(BudgetSupportMixin):
|
|||||||
stage = str(claim.approval_stage or self._status(claim) or "").strip().lower()
|
stage = str(claim.approval_stage or self._status(claim) or "").strip().lower()
|
||||||
return STAGE_LABELS.get(stage, stage.replace("_", " ").strip() or "待审批")
|
return STAGE_LABELS.get(stage, stage.replace("_", " ").strip() or "待审批")
|
||||||
|
|
||||||
|
def _finance_status_label(self, status: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"submitted": "审批中",
|
||||||
|
"review": "审批中",
|
||||||
|
"pending_review": "审批中",
|
||||||
|
"manager_review": "审批中",
|
||||||
|
"budget_review": "审批中",
|
||||||
|
"finance_review": "审批中",
|
||||||
|
"approving": "审批中",
|
||||||
|
"approved": "已入账",
|
||||||
|
"pending_payment": "待付款",
|
||||||
|
"paid": "已付款",
|
||||||
|
"returned": "待补充",
|
||||||
|
"rejected": "已驳回",
|
||||||
|
}
|
||||||
|
return labels.get(str(status or "").strip().lower(), "其他")
|
||||||
|
|
||||||
|
def _expense_type_label(self, value: str | None) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
normalized = raw.lower().replace(" ", "_").replace("-", "_")
|
||||||
|
normalized = EXPENSE_TYPE_ALIASES.get(normalized, normalized)
|
||||||
|
if normalized.endswith("_application"):
|
||||||
|
normalized = normalized.removesuffix("_application")
|
||||||
|
return EXPENSE_TYPE_LABELS.get(normalized, "其他费用")
|
||||||
|
|
||||||
|
def _is_missing_finance_dimension(self, value: str | None) -> bool:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
return not normalized or normalized in {
|
||||||
|
"待补充",
|
||||||
|
"待确认",
|
||||||
|
"未归属部门",
|
||||||
|
"未归属",
|
||||||
|
"N/A",
|
||||||
|
"n/a",
|
||||||
|
"-",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _display_finance_dimension(self, value: str | None, *, fallback: str) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return fallback if self._is_missing_finance_dimension(text) else text
|
||||||
|
|
||||||
|
def _risk_signal_label(self, value: Any) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return "风险观察"
|
||||||
|
key = normalized.lower().replace(" ", "_").replace("-", "_")
|
||||||
|
if key in RISK_SIGNAL_LABELS:
|
||||||
|
return RISK_SIGNAL_LABELS[key]
|
||||||
|
return self._display_risk_label(normalized)
|
||||||
|
|
||||||
|
def _display_risk_label(self, value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return "风险观察"
|
||||||
|
key = text.lower().replace(" ", "_").replace("-", "_")
|
||||||
|
if key in RISK_SIGNAL_LABELS:
|
||||||
|
return RISK_SIGNAL_LABELS[key]
|
||||||
|
if self._contains_cjk(text):
|
||||||
|
return text
|
||||||
|
return "风险观察"
|
||||||
|
|
||||||
|
def _contains_cjk(self, value: str) -> bool:
|
||||||
|
return any("\u4e00" <= char <= "\u9fff" for char in value)
|
||||||
|
|
||||||
def _status(self, claim: ExpenseClaim) -> str:
|
def _status(self, claim: ExpenseClaim) -> str:
|
||||||
return str(claim.status or "").strip().lower()
|
return str(claim.status or "").strip().lower()
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ from app.schemas.user_agent import (
|
|||||||
UserAgentResponse,
|
UserAgentResponse,
|
||||||
UserAgentSuggestedAction,
|
UserAgentSuggestedAction,
|
||||||
)
|
)
|
||||||
|
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||||
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
from app.services.document_numbering import (
|
from app.services.document_numbering import (
|
||||||
build_document_number,
|
build_document_number,
|
||||||
generate_unique_expense_claim_no,
|
generate_unique_expense_claim_no,
|
||||||
@@ -25,6 +27,11 @@ from app.services.user_agent_application_dates import (
|
|||||||
resolve_application_days_from_time_range,
|
resolve_application_days_from_time_range,
|
||||||
)
|
)
|
||||||
from app.services.user_agent_application_locations import normalize_application_location
|
from app.services.user_agent_application_locations import normalize_application_location
|
||||||
|
from app.services.user_agent_application_summary import (
|
||||||
|
build_application_summary,
|
||||||
|
build_application_summary_table,
|
||||||
|
resolve_application_time_label,
|
||||||
|
)
|
||||||
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
|
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
|
||||||
|
|
||||||
APPLICATION_CONTEXT_VALUES = {
|
APPLICATION_CONTEXT_VALUES = {
|
||||||
@@ -35,7 +42,7 @@ APPLICATION_CONTEXT_VALUES = {
|
|||||||
"preapproval",
|
"preapproval",
|
||||||
}
|
}
|
||||||
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
|
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
|
||||||
APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
|
APPLICATION_TIME_LABELS = ("行程时间", "出发时间", "返回时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
|
||||||
APPLICATION_FIELD_LABELS = (
|
APPLICATION_FIELD_LABELS = (
|
||||||
"申请类型",
|
"申请类型",
|
||||||
"费用类型",
|
"费用类型",
|
||||||
@@ -202,7 +209,7 @@ class UserAgentApplicationMixin:
|
|||||||
facts: dict[str, str],
|
facts: dict[str, str],
|
||||||
step: str,
|
step: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
recognized_table = self._build_application_summary_table(facts, include_empty=False)
|
recognized_table = build_application_summary_table(facts, include_empty=False)
|
||||||
|
|
||||||
if step == "ask_missing":
|
if step == "ask_missing":
|
||||||
missing_fields = self._resolve_application_missing_fields(facts)
|
missing_fields = self._resolve_application_missing_fields(facts)
|
||||||
@@ -234,7 +241,7 @@ class UserAgentApplicationMixin:
|
|||||||
if step == "duplicate":
|
if step == "duplicate":
|
||||||
application_no = str(facts.get("application_no") or "").strip()
|
application_no = str(facts.get("application_no") or "").strip()
|
||||||
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
|
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
|
||||||
time_label = self._resolve_application_time_label(facts)
|
time_label = resolve_application_time_label(facts)
|
||||||
return "\n\n".join(
|
return "\n\n".join(
|
||||||
[
|
[
|
||||||
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
|
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
|
||||||
@@ -247,7 +254,7 @@ class UserAgentApplicationMixin:
|
|||||||
return "\n\n".join(
|
return "\n\n".join(
|
||||||
[
|
[
|
||||||
"这是费用申请核对结果,请核对:",
|
"这是费用申请核对结果,请核对:",
|
||||||
self._build_application_summary_table(facts),
|
build_application_summary_table(facts),
|
||||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -375,9 +382,71 @@ class UserAgentApplicationMixin:
|
|||||||
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
|
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
|
||||||
if range_days:
|
if range_days:
|
||||||
facts["days"] = f"{range_days}天"
|
facts["days"] = f"{range_days}天"
|
||||||
|
self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
|
||||||
apply_application_system_estimate_to_facts(facts)
|
apply_application_system_estimate_to_facts(facts)
|
||||||
return facts
|
return facts
|
||||||
|
|
||||||
|
def _apply_rule_center_travel_policy_to_application_facts(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
facts: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
|
||||||
|
return
|
||||||
|
|
||||||
|
location = str(facts.get("location") or "").strip()
|
||||||
|
grade = str(facts.get("grade") or "").strip()
|
||||||
|
if not location or not grade:
|
||||||
|
return
|
||||||
|
|
||||||
|
days = self._parse_application_days_count(facts.get("days", "")) or 1
|
||||||
|
try:
|
||||||
|
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||||||
|
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
|
||||||
|
self._build_application_current_user(payload),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
hotel_rate = self._format_application_policy_money(result.hotel_rate)
|
||||||
|
hotel_amount = self._format_application_policy_money(result.hotel_amount)
|
||||||
|
allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
|
||||||
|
allowance_amount = self._format_application_policy_money(result.allowance_amount)
|
||||||
|
if hotel_rate:
|
||||||
|
facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
|
||||||
|
if hotel_amount:
|
||||||
|
facts["hotel_amount"] = f"{hotel_amount}元"
|
||||||
|
if allowance_rate:
|
||||||
|
facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
|
||||||
|
if allowance_amount:
|
||||||
|
facts["allowance_amount"] = f"{allowance_amount}元"
|
||||||
|
if str(result.matched_city or "").strip():
|
||||||
|
facts["matched_city"] = str(result.matched_city).strip()
|
||||||
|
if str(result.rule_name or "").strip():
|
||||||
|
facts["rule_name"] = str(result.rule_name).strip()
|
||||||
|
if str(result.rule_version or "").strip():
|
||||||
|
facts["rule_version"] = str(result.rule_version).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_application_policy_money(value: object) -> str:
|
||||||
|
try:
|
||||||
|
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return ""
|
||||||
|
if amount == amount.to_integral():
|
||||||
|
return f"{int(amount):,}"
|
||||||
|
return f"{amount:,.2f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_application_days_count(value: object) -> int:
|
||||||
|
match = re.search(r"\d+", str(value or ""))
|
||||||
|
if not match:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return max(0, int(match.group(0)))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
|
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
|
||||||
preview = context_json.get("application_preview")
|
preview = context_json.get("application_preview")
|
||||||
@@ -496,6 +565,17 @@ class UserAgentApplicationMixin:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_application_time_from_text(message: str) -> str:
|
def _resolve_application_time_from_text(message: str) -> str:
|
||||||
|
departure_time = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
|
message,
|
||||||
|
("出发时间", "出发日期"),
|
||||||
|
)
|
||||||
|
return_time = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
|
message,
|
||||||
|
("返回时间", "返回日期"),
|
||||||
|
)
|
||||||
|
if departure_time and return_time:
|
||||||
|
return departure_time if departure_time == return_time else f"{departure_time} 至 {return_time}"
|
||||||
|
|
||||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||||
message,
|
message,
|
||||||
APPLICATION_TIME_LABELS,
|
APPLICATION_TIME_LABELS,
|
||||||
@@ -543,6 +623,13 @@ class UserAgentApplicationMixin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
|
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
|
||||||
label_pattern = "|".join(re.escape(label) for label in labels)
|
label_pattern = "|".join(re.escape(label) for label in labels)
|
||||||
|
table_match = re.search(
|
||||||
|
rf"\|\s*(?:{label_pattern})\s*\|\s*(?P<value>[^|\n]+?)\s*\|",
|
||||||
|
str(message or ""),
|
||||||
|
)
|
||||||
|
if table_match:
|
||||||
|
return table_match.group("value").strip()
|
||||||
|
|
||||||
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
|
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
|
||||||
match = re.search(
|
match = re.search(
|
||||||
rf"(?:{label_pattern})[::]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[::]|[\n,。;;]|$)",
|
rf"(?:{label_pattern})[::]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[::]|[\n,。;;]|$)",
|
||||||
@@ -644,7 +731,7 @@ class UserAgentApplicationMixin:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[::]\s*",
|
r"^(?:行程时间|出发时间|返回时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[::]\s*",
|
||||||
"",
|
"",
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
@@ -843,73 +930,6 @@ class UserAgentApplicationMixin:
|
|||||||
return "会务费用申请"
|
return "会务费用申请"
|
||||||
return "差旅费用申请"
|
return "差旅费用申请"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resolve_application_time_label(facts: dict[str, str]) -> str:
|
|
||||||
application_type = str(facts.get("application_type") or "").strip()
|
|
||||||
if "差旅" in application_type or "出差" in application_type:
|
|
||||||
return "行程时间"
|
|
||||||
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
|
|
||||||
return "招待时间"
|
|
||||||
return "申请时间"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_application_summary(cls, facts: dict[str, str]) -> str:
|
|
||||||
time_label = cls._resolve_application_time_label(facts)
|
|
||||||
return "\n".join(
|
|
||||||
f"{label}:{value or '待补充'}"
|
|
||||||
for label, value in (
|
|
||||||
("申请类型", facts.get("application_type", "")),
|
|
||||||
("姓名", facts.get("applicant", "")),
|
|
||||||
("部门", facts.get("department", "")),
|
|
||||||
("岗位", facts.get("position", "")),
|
|
||||||
("职级", facts.get("grade", "")),
|
|
||||||
("直属领导", facts.get("manager_name", "")),
|
|
||||||
(time_label, facts.get("time", "")),
|
|
||||||
("地点", facts.get("location", "")),
|
|
||||||
("事由", facts.get("reason", "")),
|
|
||||||
("天数", facts.get("days", "")),
|
|
||||||
("出行方式", facts.get("transport_mode", "")),
|
|
||||||
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
|
||||||
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
|
||||||
("交通费用口径", facts.get("transport_policy", "")),
|
|
||||||
("规则测算参考", facts.get("policy_estimate", "")),
|
|
||||||
("系统预估费用", facts.get("amount", "")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_application_summary_table(
|
|
||||||
cls,
|
|
||||||
facts: dict[str, str],
|
|
||||||
*,
|
|
||||||
include_empty: bool = True,
|
|
||||||
) -> str:
|
|
||||||
time_label = cls._resolve_application_time_label(facts)
|
|
||||||
rows = [
|
|
||||||
("申请类型", facts.get("application_type", "")),
|
|
||||||
("姓名", facts.get("applicant", "")),
|
|
||||||
("部门", facts.get("department", "")),
|
|
||||||
("岗位", facts.get("position", "")),
|
|
||||||
("职级", facts.get("grade", "")),
|
|
||||||
("直属领导", facts.get("manager_name", "")),
|
|
||||||
(time_label, facts.get("time", "")),
|
|
||||||
("地点", facts.get("location", "")),
|
|
||||||
("事由", facts.get("reason", "")),
|
|
||||||
("天数", facts.get("days", "")),
|
|
||||||
("出行方式", facts.get("transport_mode", "")),
|
|
||||||
("住宿上限/天", 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:
|
|
||||||
visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")]
|
|
||||||
lines = ["| 字段 | 内容 |", "| --- | --- |"]
|
|
||||||
lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def _create_expense_application_record(
|
def _create_expense_application_record(
|
||||||
self,
|
self,
|
||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
@@ -1204,7 +1224,7 @@ class UserAgentApplicationMixin:
|
|||||||
return UserAgentDraftPayload(
|
return UserAgentDraftPayload(
|
||||||
draft_type="expense_application",
|
draft_type="expense_application",
|
||||||
title=str(facts.get("application_type") or "费用申请").strip() or "费用申请",
|
title=str(facts.get("application_type") or "费用申请").strip() or "费用申请",
|
||||||
body=self._build_application_summary(facts),
|
body=build_application_summary(facts),
|
||||||
confirmation_required=False,
|
confirmation_required=False,
|
||||||
claim_id=claim.id,
|
claim_id=claim.id,
|
||||||
claim_no=claim.claim_no,
|
claim_no=claim.claim_no,
|
||||||
|
|||||||
100
server/src/app/services/user_agent_application_summary.py
Normal file
100
server/src/app/services/user_agent_application_summary.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_application_time_label(facts: dict[str, str]) -> str:
|
||||||
|
application_type = str(facts.get("application_type") or "").strip()
|
||||||
|
if "差旅" in application_type or "出差" in application_type:
|
||||||
|
return "出发时间"
|
||||||
|
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
|
||||||
|
return "招待时间"
|
||||||
|
return "申请时间"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_travel_application(facts: dict[str, str]) -> bool:
|
||||||
|
application_type = str(facts.get("application_type") or "").strip()
|
||||||
|
return "差旅" in application_type or "出差" in application_type
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_application_day_count(value: str) -> int:
|
||||||
|
match = re.search(r"(\d{1,2})\s*天", str(value or ""))
|
||||||
|
if not match:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return max(0, int(match.group(1)))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _add_application_days(start_date: str, days: int) -> str:
|
||||||
|
if not start_date or days <= 1:
|
||||||
|
return start_date
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(start_date)
|
||||||
|
except ValueError:
|
||||||
|
return start_date
|
||||||
|
return (value + timedelta(days=days - 1)).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_application_trip_dates(facts: dict[str, str]) -> tuple[str, str]:
|
||||||
|
time_text = str(facts.get("time") or "").strip()
|
||||||
|
matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text)
|
||||||
|
start_date = matched_dates[0] if matched_dates else time_text
|
||||||
|
end_date = matched_dates[-1] if len(matched_dates) >= 2 else ""
|
||||||
|
if not end_date or end_date == start_date:
|
||||||
|
end_date = _add_application_days(start_date, _extract_application_day_count(facts.get("days", "")))
|
||||||
|
return start_date, end_date or start_date
|
||||||
|
|
||||||
|
|
||||||
|
def build_application_time_rows(facts: dict[str, str]) -> list[tuple[str, str]]:
|
||||||
|
if _is_travel_application(facts):
|
||||||
|
start_date, end_date = _resolve_application_trip_dates(facts)
|
||||||
|
return [
|
||||||
|
("出发时间", start_date),
|
||||||
|
("返回时间", end_date),
|
||||||
|
]
|
||||||
|
return [(resolve_application_time_label(facts), facts.get("time", ""))]
|
||||||
|
|
||||||
|
|
||||||
|
def build_application_summary_rows(facts: dict[str, str]) -> list[tuple[str, str]]:
|
||||||
|
return [
|
||||||
|
("申请类型", facts.get("application_type", "")),
|
||||||
|
("姓名", facts.get("applicant", "")),
|
||||||
|
("部门", facts.get("department", "")),
|
||||||
|
("岗位", facts.get("position", "")),
|
||||||
|
("职级", facts.get("grade", "")),
|
||||||
|
("直属领导", facts.get("manager_name", "")),
|
||||||
|
*build_application_time_rows(facts),
|
||||||
|
("地点", facts.get("location", "")),
|
||||||
|
("事由", facts.get("reason", "")),
|
||||||
|
("天数", facts.get("days", "")),
|
||||||
|
("出行方式", facts.get("transport_mode", "")),
|
||||||
|
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
||||||
|
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
||||||
|
("交通费用口径", facts.get("transport_policy", "")),
|
||||||
|
("规则测算参考", facts.get("policy_estimate", "")),
|
||||||
|
("系统预估费用", facts.get("amount", "")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_application_summary(facts: dict[str, str]) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
f"{label}:{value or '待补充'}"
|
||||||
|
for label, value in build_application_summary_rows(facts)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_application_summary_table(
|
||||||
|
facts: dict[str, str],
|
||||||
|
*,
|
||||||
|
include_empty: bool = True,
|
||||||
|
) -> str:
|
||||||
|
rows = build_application_summary_rows(facts)
|
||||||
|
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
|
||||||
|
if not visible_rows:
|
||||||
|
visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")]
|
||||||
|
lines = ["| 字段 | 内容 |", "| --- | --- |"]
|
||||||
|
lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows)
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -190,6 +190,11 @@ class UserAgentReviewSlotMixin:
|
|||||||
if not cleaned_key:
|
if not cleaned_key:
|
||||||
continue
|
continue
|
||||||
normalized[cleaned_key] = str(value or "").strip()
|
normalized[cleaned_key] = str(value or "").strip()
|
||||||
|
if not normalized.get("transport_mode"):
|
||||||
|
for alias in ("transportMode", "application_transport_mode", "applicationTransportMode"):
|
||||||
|
if normalized.get(alias):
|
||||||
|
normalized["transport_mode"] = normalized[alias]
|
||||||
|
break
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
188
server/tests/test_demo_company_simulation_seed.py
Normal file
188
server/tests/test_demo_company_simulation_seed.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, func, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.models.organization import OrganizationUnit
|
||||||
|
from app.models.risk_observation import RiskObservation
|
||||||
|
from app.services.budget import BudgetService
|
||||||
|
from app.services.demo_company_simulation_seed import (
|
||||||
|
SIM_CLAIM_PREFIX,
|
||||||
|
SIM_EMPLOYEE_PREFIX,
|
||||||
|
HalfYearExpenseSimulationSeeder,
|
||||||
|
SimulationConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 seed_company(db: Session) -> None:
|
||||||
|
tech = OrganizationUnit(
|
||||||
|
id="dept-tech",
|
||||||
|
unit_code="TECH-DEPT",
|
||||||
|
name="技术部",
|
||||||
|
unit_type="department",
|
||||||
|
cost_center="CC-6100",
|
||||||
|
location="北京",
|
||||||
|
)
|
||||||
|
market = OrganizationUnit(
|
||||||
|
id="dept-market",
|
||||||
|
unit_code="MARKET-DEPT",
|
||||||
|
name="市场部",
|
||||||
|
unit_type="department",
|
||||||
|
cost_center="CC-4100",
|
||||||
|
location="上海",
|
||||||
|
)
|
||||||
|
db.add_all([tech, market])
|
||||||
|
for index in range(3):
|
||||||
|
db.add(
|
||||||
|
Employee(
|
||||||
|
id=f"emp-existing-{index}",
|
||||||
|
employee_no=f"E-EXISTING-{index}",
|
||||||
|
name=f"现有员工{index}",
|
||||||
|
email=f"existing-{index}@xf.com",
|
||||||
|
grade="P5",
|
||||||
|
position="主管",
|
||||||
|
organization_unit=tech if index % 2 == 0 else market,
|
||||||
|
cost_center="CC-6100" if index % 2 == 0 else "CC-4100",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_half_year_simulation_preview_and_apply_are_idempotent() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
seed_company(db)
|
||||||
|
config = SimulationConfig(target_employees=8, start_date=date(2026, 1, 1), months=6, seed=7)
|
||||||
|
|
||||||
|
preview = HalfYearExpenseSimulationSeeder(db, config).preview()
|
||||||
|
|
||||||
|
assert preview.mode == "dry-run"
|
||||||
|
assert preview.current_employee_count == 3
|
||||||
|
assert preview.employees_to_create == 5
|
||||||
|
assert preview.claims_to_create >= 24
|
||||||
|
assert preview.budget_allocations_to_create > 0
|
||||||
|
assert preview.budget_transactions_to_create > 0
|
||||||
|
|
||||||
|
applied = HalfYearExpenseSimulationSeeder(db, config).apply()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert applied.mode == "apply"
|
||||||
|
assert applied.employees_to_create == 5
|
||||||
|
assert db.scalar(select(func.count()).select_from(Employee)) == 8
|
||||||
|
assert db.scalar(select(func.count()).select_from(ExpenseClaim)) == applied.claims_to_create
|
||||||
|
assert (
|
||||||
|
db.scalar(select(func.count()).select_from(ExpenseClaimItem))
|
||||||
|
== applied.claim_items_to_create
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
db.scalar(select(func.count()).select_from(BudgetAllocation))
|
||||||
|
== applied.budget_allocations_to_create
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
db.scalar(select(func.count()).select_from(BudgetTransaction))
|
||||||
|
== applied.budget_transactions_to_create
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
db.scalar(select(func.count()).select_from(BudgetReservation))
|
||||||
|
== applied.budget_reservations_to_create
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
db.scalar(select(func.count()).select_from(RiskObservation))
|
||||||
|
== applied.risk_observations_to_create
|
||||||
|
)
|
||||||
|
|
||||||
|
repeated = HalfYearExpenseSimulationSeeder(db, config).apply()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert repeated.employees_to_create == 0
|
||||||
|
assert repeated.claims_to_create == 0
|
||||||
|
assert repeated.budget_allocations_to_create == 0
|
||||||
|
assert repeated.budget_transactions_to_create == 0
|
||||||
|
assert repeated.budget_reservations_to_create == 0
|
||||||
|
assert repeated.risk_observations_to_create == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_half_year_simulation_feeds_budget_summary() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
seed_company(db)
|
||||||
|
config = SimulationConfig(
|
||||||
|
target_employees=10,
|
||||||
|
start_date=date(2026, 1, 1),
|
||||||
|
months=6,
|
||||||
|
seed=11,
|
||||||
|
)
|
||||||
|
HalfYearExpenseSimulationSeeder(db, config).apply()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
summary = BudgetService(db).get_summary(fiscal_year=2026, period_key="2026Q2")
|
||||||
|
sim_claim_count = db.scalar(
|
||||||
|
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||||
|
)
|
||||||
|
sim_employee_count = db.scalar(
|
||||||
|
select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim_claim_count and sim_claim_count >= 30
|
||||||
|
assert sim_employee_count == 7
|
||||||
|
assert summary.trend
|
||||||
|
assert {item.period_key for item in summary.trend} == {"2026Q1", "2026Q2"}
|
||||||
|
assert summary.warning_count + summary.over_budget_count > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
seed_company(db)
|
||||||
|
db.add(
|
||||||
|
Employee(
|
||||||
|
id="emp-admin",
|
||||||
|
employee_no="ADMIN",
|
||||||
|
name="admin",
|
||||||
|
email="admin@xf.com",
|
||||||
|
grade="P8",
|
||||||
|
position="admin",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
config = SimulationConfig(
|
||||||
|
target_employees=100,
|
||||||
|
start_date=date(2026, 1, 1),
|
||||||
|
months=6,
|
||||||
|
seed=20260602,
|
||||||
|
)
|
||||||
|
HalfYearExpenseSimulationSeeder(db, config).apply()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
admin_claim_count = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ExpenseClaim)
|
||||||
|
.where(ExpenseClaim.employee_name == "admin")
|
||||||
|
)
|
||||||
|
visible_claim_count = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ExpenseClaim)
|
||||||
|
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||||
|
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
|
||||||
|
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert admin_claim_count == 0
|
||||||
|
assert visible_claim_count is not None
|
||||||
|
assert 400 <= visible_claim_count <= 500
|
||||||
@@ -416,6 +416,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
|||||||
"application_amount": "3000",
|
"application_amount": "3000",
|
||||||
"application_amount_label": "¥3,000",
|
"application_amount_label": "¥3,000",
|
||||||
"application_business_time": "2026-02-20 至 2026-02-23",
|
"application_business_time": "2026-02-20 至 2026-02-23",
|
||||||
|
"application_date": "2026-06-02T00:58:00Z",
|
||||||
|
"application_days": "4 天",
|
||||||
|
"application_transport_mode": "火车",
|
||||||
|
"application_lodging_daily_cap": "600元/天",
|
||||||
|
"application_subsidy_daily_cap": "120元/天",
|
||||||
|
"application_transport_policy": "按真实票据复核",
|
||||||
|
"application_policy_estimate": "交通 1,160元 + 住宿 2,400元 + 补贴 480元",
|
||||||
},
|
},
|
||||||
"expense_scene_selection": {
|
"expense_scene_selection": {
|
||||||
"expense_type": "travel",
|
"expense_type": "travel",
|
||||||
@@ -432,6 +439,7 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
|||||||
assert claim.location == "上海"
|
assert claim.location == "上海"
|
||||||
assert claim.amount == Decimal("0.00")
|
assert claim.amount == Decimal("0.00")
|
||||||
assert claim.invoice_count == 0
|
assert claim.invoice_count == 0
|
||||||
|
assert claim.occurred_at.date() == date(2026, 2, 20)
|
||||||
assert claim.items == []
|
assert claim.items == []
|
||||||
link_flag = next(
|
link_flag = next(
|
||||||
flag
|
flag
|
||||||
@@ -439,7 +447,221 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
|||||||
if isinstance(flag, dict) and flag.get("source") == "application_link"
|
if isinstance(flag, dict) and flag.get("source") == "application_link"
|
||||||
)
|
)
|
||||||
assert link_flag["application_claim_no"] == "AP-202606-001"
|
assert link_flag["application_claim_no"] == "AP-202606-001"
|
||||||
|
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
|
||||||
|
assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23"
|
||||||
|
assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
|
||||||
assert link_flag["application_detail"]["application_amount"] == "3000"
|
assert link_flag["application_detail"]["application_amount"] == "3000"
|
||||||
|
assert link_flag["application_detail"]["application_days"] == "4 天"
|
||||||
|
assert link_flag["application_detail"]["application_transport_mode"] == "火车"
|
||||||
|
assert link_flag["application_detail"]["application_lodging_daily_cap"] == "600元/天"
|
||||||
|
assert link_flag["application_detail"]["application_subsidy_daily_cap"] == "120元/天"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> None:
|
||||||
|
user_id = "linked-application-existing-placeholder@example.com"
|
||||||
|
message = (
|
||||||
|
"报销类型:差旅费\n"
|
||||||
|
"关联申请单:AP-202606-002 / 支撑国网仿生产服务器部署 / 上海 / ¥3,000\n"
|
||||||
|
"报销票据:草稿生成后在详情中上传"
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E5105",
|
||||||
|
name="关联员工",
|
||||||
|
email=user_id,
|
||||||
|
grade="P5",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.flush()
|
||||||
|
existing_claim = ExpenseClaim(
|
||||||
|
claim_no="RE-202606020001-PLACEHOLDER",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="关联员工",
|
||||||
|
department_name="技术部",
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel",
|
||||||
|
reason="支撑国网仿生产服务器部署",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("3000.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
existing_claim.items = [
|
||||||
|
ExpenseClaimItem(
|
||||||
|
claim_id=existing_claim.id,
|
||||||
|
item_date=date(2026, 2, 20),
|
||||||
|
item_type="travel",
|
||||||
|
item_reason="支撑国网仿生产服务器部署",
|
||||||
|
item_location="上海",
|
||||||
|
item_amount=Decimal("3000.00"),
|
||||||
|
invoice_id=None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
db.add(existing_claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query=message,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id=user_id,
|
||||||
|
message=message,
|
||||||
|
ontology=ontology,
|
||||||
|
context_json={
|
||||||
|
"name": "关联员工",
|
||||||
|
"draft_claim_id": existing_claim.id,
|
||||||
|
"user_input_text": message,
|
||||||
|
"review_action": "save_draft",
|
||||||
|
"review_form_values": {
|
||||||
|
"expense_type": "差旅费",
|
||||||
|
"amount": "¥3,000",
|
||||||
|
"reason": "支撑国网仿生产服务器部署",
|
||||||
|
"location": "上海",
|
||||||
|
"business_location": "上海",
|
||||||
|
"application_claim_id": "application-linked-existing-placeholder",
|
||||||
|
"application_claim_no": "AP-202606-002",
|
||||||
|
"application_reason": "支撑国网仿生产服务器部署",
|
||||||
|
"application_location": "上海",
|
||||||
|
"application_amount": "3000",
|
||||||
|
"application_amount_label": "¥3,000",
|
||||||
|
},
|
||||||
|
"expense_scene_selection": {
|
||||||
|
"expense_type": "travel",
|
||||||
|
"application_claim_id": "application-linked-existing-placeholder",
|
||||||
|
"application_claim_no": "AP-202606-002",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
||||||
|
assert claim is not None
|
||||||
|
assert claim.id == existing_claim.id
|
||||||
|
assert claim.amount == Decimal("0.00")
|
||||||
|
assert claim.invoice_count == 0
|
||||||
|
assert claim.items == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E5106",
|
||||||
|
name="关联差旅员工",
|
||||||
|
email="linked-application-allowance@example.com",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
claim = build_claim(expense_type="travel", location="上海")
|
||||||
|
claim.employee_id = employee.id
|
||||||
|
claim.employee_name = employee.name
|
||||||
|
claim.amount = Decimal("354.00")
|
||||||
|
claim.items[0].item_date = date(2026, 2, 20)
|
||||||
|
claim.items[0].item_type = "train_ticket"
|
||||||
|
claim.items[0].item_reason = "武汉-上海"
|
||||||
|
claim.items[0].item_location = "上海"
|
||||||
|
claim.items[0].item_amount = Decimal("354.00")
|
||||||
|
claim.risk_flags_json = [
|
||||||
|
{
|
||||||
|
"source": "application_link",
|
||||||
|
"application_claim_no": "AP-202606-003",
|
||||||
|
"application_detail": {
|
||||||
|
"application_time": "2026-02-20 至 2026-02-23",
|
||||||
|
"application_days": "4 天",
|
||||||
|
"application_location": "上海",
|
||||||
|
"application_transport_mode": "火车",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
service._sync_claim_from_items(claim)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(claim)
|
||||||
|
|
||||||
|
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
||||||
|
assert "4天" in allowance_item.item_reason
|
||||||
|
assert allowance_item.item_date == date(2026, 2, 23)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_travel_allowance_backfills_range_from_linked_application_claim() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E5107",
|
||||||
|
name="旧关联差旅员工",
|
||||||
|
email="linked-application-allowance-backfill@example.com",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.flush()
|
||||||
|
application_claim = ExpenseClaim(
|
||||||
|
claim_no="AP-202606-004",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_name="技术部",
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="支撑国网仿生产环境部署",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("3000.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
|
||||||
|
status="approved",
|
||||||
|
approval_stage="审批完成",
|
||||||
|
risk_flags_json=[
|
||||||
|
{
|
||||||
|
"source": "application_detail",
|
||||||
|
"application_detail": {
|
||||||
|
"time": "2026-02-20 至 2026-02-23",
|
||||||
|
"days": "4 天",
|
||||||
|
"location": "上海",
|
||||||
|
"reason": "支撑国网仿生产环境部署",
|
||||||
|
"transport_mode": "火车",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add(application_claim)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
claim = build_claim(expense_type="travel", location="上海")
|
||||||
|
claim.employee_id = employee.id
|
||||||
|
claim.employee_name = employee.name
|
||||||
|
claim.amount = Decimal("354.00")
|
||||||
|
claim.items[0].item_date = date(2026, 2, 20)
|
||||||
|
claim.items[0].item_type = "train_ticket"
|
||||||
|
claim.items[0].item_reason = "武汉-上海"
|
||||||
|
claim.items[0].item_location = "上海"
|
||||||
|
claim.items[0].item_amount = Decimal("354.00")
|
||||||
|
claim.risk_flags_json = [
|
||||||
|
{
|
||||||
|
"source": "application_link",
|
||||||
|
"application_claim_no": "AP-202606-004",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
service._sync_claim_from_items(claim)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(claim)
|
||||||
|
|
||||||
|
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
||||||
|
assert "4天" in allowance_item.item_reason
|
||||||
|
assert allowance_item.item_date == date(2026, 2, 23)
|
||||||
|
|
||||||
|
|
||||||
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
|
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
|
||||||
@@ -2858,6 +3080,83 @@ def test_list_claims_limits_finance_to_personal_records() -> None:
|
|||||||
assert claims[0].claim_no == "EXP-FIN-OWN"
|
assert claims[0].claim_no == "EXP-FIN-OWN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_claims_returns_company_reimbursements_for_finance_document_center() -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="finance@example.com",
|
||||||
|
name="财务",
|
||||||
|
role_codes=["finance"],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="EXP-FIN-COMPANY-SUBMITTED",
|
||||||
|
employee_name="乙",
|
||||||
|
department_name="市场部",
|
||||||
|
project_code="PRJ-MKT",
|
||||||
|
expense_type="travel",
|
||||||
|
reason="客户拜访差旅",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("1200.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="submitted",
|
||||||
|
approval_stage="finance_review",
|
||||||
|
risk_flags_json=[],
|
||||||
|
),
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="EXP-FIN-COMPANY-DRAFT",
|
||||||
|
employee_name="丙",
|
||||||
|
department_name="技术部",
|
||||||
|
project_code="PRJ-TECH",
|
||||||
|
expense_type="office",
|
||||||
|
reason="办公用品",
|
||||||
|
location="北京",
|
||||||
|
amount=Decimal("300.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
||||||
|
submitted_at=None,
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
risk_flags_json=[],
|
||||||
|
),
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="EXP-FIN-COMPANY-PAID",
|
||||||
|
employee_name="丁",
|
||||||
|
department_name="财务部",
|
||||||
|
project_code="PRJ-FIN",
|
||||||
|
expense_type="meal",
|
||||||
|
reason="客户沟通",
|
||||||
|
location="杭州",
|
||||||
|
amount=Decimal("500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||||
|
status="paid",
|
||||||
|
approval_stage="payment",
|
||||||
|
risk_flags_json=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
|
||||||
|
archived_nos = {
|
||||||
|
claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "EXP-FIN-COMPANY-SUBMITTED" in claim_nos
|
||||||
|
assert "EXP-FIN-COMPANY-DRAFT" not in claim_nos
|
||||||
|
assert "EXP-FIN-COMPANY-PAID" not in claim_nos
|
||||||
|
assert "EXP-FIN-COMPANY-PAID" in archived_nos
|
||||||
|
|
||||||
|
|
||||||
def test_list_claims_limits_executive_to_personal_records() -> None:
|
def test_list_claims_limits_executive_to_personal_records() -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="executive@example.com",
|
username="executive@example.com",
|
||||||
@@ -3822,6 +4121,12 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
|||||||
"reason": "支撑国网服务器上线部署",
|
"reason": "支撑国网服务器上线部署",
|
||||||
"days": "3 天",
|
"days": "3 天",
|
||||||
"transport_mode": "高铁",
|
"transport_mode": "高铁",
|
||||||
|
"lodging_daily_cap": "600元/天",
|
||||||
|
"subsidy_daily_cap": "120元/天",
|
||||||
|
"transport_policy": "按真实票据复核",
|
||||||
|
"policy_estimate": "交通按真实票据 + 住宿 1,800元 + 补贴 360元",
|
||||||
|
"rule_name": "差旅标准规则",
|
||||||
|
"rule_version": "2026.05",
|
||||||
"amount": "12000.00",
|
"amount": "12000.00",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3899,6 +4204,13 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
|||||||
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
|
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
|
||||||
and flag.get("application_detail", {}).get("application_days") == "3 天"
|
and flag.get("application_detail", {}).get("application_days") == "3 天"
|
||||||
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
|
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
|
||||||
|
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天"
|
||||||
|
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天"
|
||||||
|
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核"
|
||||||
|
and flag.get("application_detail", {}).get("application_policy_estimate")
|
||||||
|
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
|
||||||
|
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
|
||||||
|
and flag.get("application_detail", {}).get("application_rule_version") == "2026.05"
|
||||||
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
||||||
and flag.get("budget_opinion") == "预算额度可承接,同意。"
|
and flag.get("budget_opinion") == "预算额度可承接,同意。"
|
||||||
for flag in generated_draft.risk_flags_json
|
for flag in generated_draft.risk_flags_json
|
||||||
|
|||||||
52
server/tests/test_expense_claim_status_registry.py
Normal file
52
server/tests/test_expense_claim_status_registry.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from app.services.expense_claim_status_registry import (
|
||||||
|
claim_status_code,
|
||||||
|
normalize_expense_claim_state,
|
||||||
|
)
|
||||||
|
from app.services.expense_claim_workflow_constants import (
|
||||||
|
APPROVAL_DONE_STAGE,
|
||||||
|
ARCHIVE_ACCOUNTING_STAGE,
|
||||||
|
FINANCE_APPROVAL_STAGE,
|
||||||
|
PAYMENT_PAID_STAGE,
|
||||||
|
PAYMENT_PENDING_STAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_legacy_finance_review_to_submitted_finance_stage() -> None:
|
||||||
|
state = normalize_expense_claim_state(
|
||||||
|
"finance_review",
|
||||||
|
"finance_review",
|
||||||
|
claim_no="SIM-EXP-2026-0001",
|
||||||
|
expense_type="travel",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state.status == "submitted"
|
||||||
|
assert state.approval_stage == FINANCE_APPROVAL_STAGE
|
||||||
|
assert state.status_code == 20
|
||||||
|
assert state.changed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_reimbursement_archive_stage_differs_from_application_done() -> None:
|
||||||
|
reimbursement_state = normalize_expense_claim_state(
|
||||||
|
"approved",
|
||||||
|
"completed",
|
||||||
|
claim_no="SIM-EXP-2026-0002",
|
||||||
|
expense_type="travel",
|
||||||
|
)
|
||||||
|
application_state = normalize_expense_claim_state(
|
||||||
|
"approved",
|
||||||
|
"completed",
|
||||||
|
claim_no="AP-20260602-0001",
|
||||||
|
expense_type="travel_application",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE
|
||||||
|
assert application_state.approval_stage == APPROVAL_DONE_STAGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_payment_stages_by_status() -> None:
|
||||||
|
pending_state = normalize_expense_claim_state("pending_payment", "payment")
|
||||||
|
paid_state = normalize_expense_claim_state("paid", "payment")
|
||||||
|
|
||||||
|
assert pending_state.approval_stage == PAYMENT_PENDING_STAGE
|
||||||
|
assert paid_state.approval_stage == PAYMENT_PAID_STAGE
|
||||||
|
assert claim_status_code("paid") == 50
|
||||||
@@ -25,7 +25,7 @@ def build_session() -> Session:
|
|||||||
return session_factory()
|
return session_factory()
|
||||||
|
|
||||||
|
|
||||||
def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> None:
|
def test_finance_dashboard_service_aggregates_claim_budget_and_payment_data() -> None:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
@@ -85,6 +85,24 @@ def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> No
|
|||||||
created_at=now - timedelta(hours=1),
|
created_at=now - timedelta(hours=1),
|
||||||
updated_at=now - timedelta(hours=1),
|
updated_at=now - timedelta(hours=1),
|
||||||
),
|
),
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="AP-DASH-ADMIN-001",
|
||||||
|
employee_name="admin",
|
||||||
|
department_name="Finance",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="admin pre-approval should not enter reimbursement metrics",
|
||||||
|
location="Shanghai",
|
||||||
|
amount=Decimal("999999.00"),
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=now - timedelta(minutes=20),
|
||||||
|
submitted_at=now - timedelta(minutes=10),
|
||||||
|
status="paid",
|
||||||
|
approval_stage="payment",
|
||||||
|
risk_flags_json=[],
|
||||||
|
hermes_risk_flag=False,
|
||||||
|
created_at=now - timedelta(minutes=20),
|
||||||
|
updated_at=now - timedelta(minutes=10),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
db.add(
|
db.add(
|
||||||
@@ -144,12 +162,175 @@ def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> No
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert dashboard.has_real_data is True
|
assert dashboard.has_real_data is True
|
||||||
assert dashboard.totals["pendingCount"] == 1
|
assert dashboard.totals["reimbursementCount"] == 2
|
||||||
assert dashboard.totals["pendingAmount"] == 1200.0
|
assert dashboard.totals["reimbursementAmount"] == 2000.0
|
||||||
assert dashboard.totals["riskCount"] == 1
|
assert dashboard.totals["pendingPaymentAmount"] == 0.0
|
||||||
assert dashboard.trend["applications"][-1] >= 1
|
assert dashboard.trend["applications"][-1] >= 1
|
||||||
|
assert "AP-DASH-ADMIN-001" not in str(dashboard.trend)
|
||||||
assert dashboard.spend_by_category[0]["value"] == 1200.0
|
assert dashboard.spend_by_category[0]["value"] == 1200.0
|
||||||
assert dashboard.department_ranking[0]["name"] == "财务部"
|
assert dashboard.department_ranking[0]["name"] == "财务部"
|
||||||
assert dashboard.department_ranking[0]["amount"] == 1200.0
|
assert dashboard.department_ranking[0]["amount"] == 1200.0
|
||||||
|
assert dashboard.employee_ranking[0]["name"] == "陈雨晴"
|
||||||
|
assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001"
|
||||||
|
assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims)
|
||||||
assert dashboard.budget_summary["ratio"] == 40.0
|
assert dashboard.budget_summary["ratio"] == 40.0
|
||||||
assert dashboard.budget_summary["used"] == "¥4,000"
|
assert dashboard.budget_summary["used"] == "¥4,000"
|
||||||
|
metric_labels = {item["label"] for item in dashboard.budget_metrics}
|
||||||
|
assert {"预算池数量", "总预算", "已用预算", "可用预算", "预警预算池"}.issubset(
|
||||||
|
metric_labels
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="CLM-DASH-LABEL-001",
|
||||||
|
employee_name="林嘉宁",
|
||||||
|
department_name="市场部",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="客户拜访差旅",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("700.00"),
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=now - timedelta(hours=2),
|
||||||
|
submitted_at=now - timedelta(hours=1),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="finance_review",
|
||||||
|
risk_flags_json=[{"type": "budget_pressure"}],
|
||||||
|
hermes_risk_flag=False,
|
||||||
|
created_at=now - timedelta(hours=2),
|
||||||
|
updated_at=now - timedelta(hours=1),
|
||||||
|
),
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="CLM-DASH-LABEL-002",
|
||||||
|
employee_name="周思远",
|
||||||
|
department_name="财务部",
|
||||||
|
expense_type="meal",
|
||||||
|
reason="客户沟通",
|
||||||
|
location="杭州",
|
||||||
|
amount=Decimal("300.00"),
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=now - timedelta(days=1),
|
||||||
|
submitted_at=now - timedelta(days=1),
|
||||||
|
status="paid",
|
||||||
|
approval_stage="payment",
|
||||||
|
risk_flags_json=[],
|
||||||
|
hermes_risk_flag=False,
|
||||||
|
created_at=now - timedelta(days=1),
|
||||||
|
updated_at=now - timedelta(days=1),
|
||||||
|
),
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="CLM-DASH-LABEL-003",
|
||||||
|
employee_name="reimbursement-user",
|
||||||
|
department_name="甯傚満閮?,
|
||||||
|
expense_type="travel",
|
||||||
|
reason="real travel reimbursement",
|
||||||
|
location="Shanghai",
|
||||||
|
amount=Decimal("700.00"),
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=now - timedelta(hours=2),
|
||||||
|
submitted_at=now - timedelta(hours=1),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="finance_review",
|
||||||
|
risk_flags_json=[],
|
||||||
|
hermes_risk_flag=False,
|
||||||
|
created_at=now - timedelta(hours=2),
|
||||||
|
updated_at=now - timedelta(hours=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
RiskObservation(
|
||||||
|
observation_key="risk-dashboard-label-001",
|
||||||
|
subject_type="expense_claim",
|
||||||
|
subject_key="CLM-DASH-LABEL-001",
|
||||||
|
subject_label="CLM-DASH-LABEL-001",
|
||||||
|
claim_no="CLM-DASH-LABEL-001",
|
||||||
|
risk_type="policy",
|
||||||
|
risk_signal="missing_material",
|
||||||
|
title="材料不完整",
|
||||||
|
risk_level="medium",
|
||||||
|
status="pending_review",
|
||||||
|
created_at=now - timedelta(minutes=30),
|
||||||
|
updated_at=now - timedelta(minutes=30),
|
||||||
|
),
|
||||||
|
RiskObservation(
|
||||||
|
observation_key="risk-dashboard-label-002",
|
||||||
|
subject_type="expense_claim",
|
||||||
|
subject_key="CLM-DASH-LABEL-001",
|
||||||
|
subject_label="CLM-DASH-LABEL-001",
|
||||||
|
claim_no="CLM-DASH-LABEL-001",
|
||||||
|
risk_type="budget",
|
||||||
|
risk_signal="budget_pressure",
|
||||||
|
title="预算压力偏高",
|
||||||
|
risk_level="high",
|
||||||
|
status="pending_review",
|
||||||
|
created_at=now - timedelta(minutes=20),
|
||||||
|
updated_at=now - timedelta(minutes=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
allocation = BudgetAllocation(
|
||||||
|
budget_no="BUD-DASH-LABEL-001",
|
||||||
|
fiscal_year=now.year,
|
||||||
|
period_type="year",
|
||||||
|
period_key=f"{now.year}",
|
||||||
|
department_name="市场部",
|
||||||
|
subject_code="travel",
|
||||||
|
subject_name="差旅费",
|
||||||
|
original_amount=Decimal("1000.00"),
|
||||||
|
adjusted_amount=Decimal("0.00"),
|
||||||
|
status="active",
|
||||||
|
warning_threshold=Decimal("80.00"),
|
||||||
|
control_action="warn",
|
||||||
|
)
|
||||||
|
db.add(allocation)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
BudgetTransaction(
|
||||||
|
transaction_no="BTX-DASH-LABEL-001",
|
||||||
|
allocation_id=allocation.id,
|
||||||
|
source_type="expense_claim",
|
||||||
|
source_id="CLM-DASH-LABEL-003",
|
||||||
|
source_no="CLM-DASH-LABEL-003",
|
||||||
|
transaction_type="consume",
|
||||||
|
amount=Decimal("1250.00"),
|
||||||
|
before_available_amount=Decimal("1000.00"),
|
||||||
|
after_available_amount=Decimal("-250.00"),
|
||||||
|
operator="finance",
|
||||||
|
reason="测试超支",
|
||||||
|
created_at=now - timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
dashboard = FinanceDashboardService(db).build_dashboard(
|
||||||
|
range_key="近10日",
|
||||||
|
trend_range="近7天",
|
||||||
|
department_range="本月",
|
||||||
|
)
|
||||||
|
|
||||||
|
spend_names = {item["name"] for item in dashboard.spend_by_category}
|
||||||
|
focus_names = {item["name"] for item in dashboard.bottlenecks}
|
||||||
|
|
||||||
|
assert "差旅" in spend_names
|
||||||
|
assert "travel_application" not in str(dashboard.spend_by_category)
|
||||||
|
assert "风险" not in str(dashboard.exception_mix)
|
||||||
|
assert "异常" not in str(dashboard.exception_mix)
|
||||||
|
assert "missing material" not in str(dashboard.exception_mix).lower()
|
||||||
|
assert "budget pressure" not in str(dashboard.exception_mix).lower()
|
||||||
|
assert dashboard.trend["claimCount"][-1] == 1
|
||||||
|
assert dashboard.trend["claimAmount"][-1] == 700.0
|
||||||
|
assert dashboard.trend["applications"] == dashboard.trend["claimCount"]
|
||||||
|
assert dashboard.department_ranking[0]["name"] == "市场部"
|
||||||
|
assert dashboard.department_ranking[0]["amount"] == 700.0
|
||||||
|
assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names)
|
||||||
|
assert "风险金额" not in focus_names
|
||||||
|
assert "材料待补" not in focus_names
|
||||||
|
assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks)
|
||||||
|
assert len(dashboard.budget_metrics) == 6
|
||||||
|
|||||||
@@ -710,7 +710,8 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
|
|||||||
assert response.status == "blocked"
|
assert response.status == "blocked"
|
||||||
assert response.trace_summary.scenario == "expense"
|
assert response.trace_summary.scenario == "expense"
|
||||||
assert "费用申请" in result["answer"]
|
assert "费用申请" in result["answer"]
|
||||||
assert "| 行程时间 | 2026-05-25" in result["answer"]
|
assert "| 出发时间 | 2026-05-25 |" in result["answer"]
|
||||||
|
assert "| 返回时间 | 2026-05-27 |" in result["answer"]
|
||||||
assert "请先在下面选择报销场景" not in result["answer"]
|
assert "请先在下面选择报销场景" not in result["answer"]
|
||||||
assert result.get("review_payload") is None
|
assert result.get("review_payload") is None
|
||||||
|
|
||||||
@@ -773,8 +774,10 @@ def test_orchestrator_application_session_guides_transport_estimate_and_submit(
|
|||||||
assert "这是费用申请核对结果" in second.result["answer"]
|
assert "这是费用申请核对结果" in second.result["answer"]
|
||||||
assert "| 事由 | 支持上海国网服务器部署 |" in second.result["answer"]
|
assert "| 事由 | 支持上海国网服务器部署 |" in second.result["answer"]
|
||||||
assert "| 系统预估费用 |" in second.result["answer"]
|
assert "| 系统预估费用 |" in second.result["answer"]
|
||||||
assert "按 2026-05-25 参考票价" in second.result["answer"]
|
assert "| 交通费用口径 | 预估交通费用 2,330元 |" in second.result["answer"]
|
||||||
assert "2,330元" in second.result["answer"]
|
assert "2,330元" in second.result["answer"]
|
||||||
|
assert "参考票价" not in second.result["answer"]
|
||||||
|
assert "查询耗时" not in second.result["answer"]
|
||||||
assert "请核对上述信息无误" in second.result["answer"]
|
assert "请核对上述信息无误" in second.result["answer"]
|
||||||
assert "[确认](#application-submit)" in second.result["answer"]
|
assert "[确认](#application-submit)" in second.result["answer"]
|
||||||
assert second.status == "blocked"
|
assert second.status == "blocked"
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ def test_user_agent_application_context_uses_application_language() -> None:
|
|||||||
|
|
||||||
assert "费用申请" in response.answer
|
assert "费用申请" in response.answer
|
||||||
assert "| 字段 | 内容 |" in response.answer
|
assert "| 字段 | 内容 |" in response.answer
|
||||||
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
|
assert "| 出发时间 | 2026-05-25 |" in response.answer
|
||||||
|
assert "| 返回时间 | 2026-05-27 |" in response.answer
|
||||||
assert "支持上海国网服务器部署" in response.answer
|
assert "支持上海国网服务器部署" in response.answer
|
||||||
assert "当前还需要补充:出行方式" in response.answer
|
assert "当前还需要补充:出行方式" in response.answer
|
||||||
assert "请先在下面选择报销场景" not in response.answer
|
assert "请先在下面选择报销场景" not in response.answer
|
||||||
@@ -224,7 +225,8 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
|
|||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
response = build_application_user_agent_response(db, message)
|
response = build_application_user_agent_response(db, message)
|
||||||
|
|
||||||
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
|
assert "| 出发时间 | 2026-05-25 |" in response.answer
|
||||||
|
assert "| 返回时间 | 2026-05-27 |" in response.answer
|
||||||
assert "| 地点 | 上海市 |" in response.answer
|
assert "| 地点 | 上海市 |" in response.answer
|
||||||
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
||||||
assert "当前还需要先补充:申请事由" not in response.answer
|
assert "当前还需要先补充:申请事由" not in response.answer
|
||||||
@@ -250,7 +252,8 @@ def test_user_agent_application_normalizes_location_to_region_city() -> None:
|
|||||||
yili_response = build_application_user_agent_response(db, yili_message)
|
yili_response = build_application_user_agent_response(db, yili_message)
|
||||||
beijing_response = build_application_user_agent_response(db, beijing_message)
|
beijing_response = build_application_user_agent_response(db, beijing_message)
|
||||||
|
|
||||||
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
|
assert "| 出发时间 | 2026-05-25 |" in yili_response.answer
|
||||||
|
assert "| 返回时间 | 2026-05-27 |" in yili_response.answer
|
||||||
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
|
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
|
||||||
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
|
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
|
||||||
assert "伊犁出差" not in yili_response.answer
|
assert "伊犁出差" not in yili_response.answer
|
||||||
@@ -289,7 +292,8 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields()
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "| 行程时间 | 2026-05-25 |" in response.answer
|
assert "| 出发时间 | 2026-05-25 |" in response.answer
|
||||||
|
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 "当前还需要补充:出行方式" in response.answer
|
||||||
@@ -317,10 +321,10 @@ def test_user_agent_application_builds_system_estimate_after_transport_choice()
|
|||||||
assert "| 出行方式 | 飞机 |" in response.answer
|
assert "| 出行方式 | 飞机 |" in response.answer
|
||||||
assert "| 系统预估费用 |" in response.answer
|
assert "| 系统预估费用 |" in response.answer
|
||||||
assert "交通" in response.answer
|
assert "交通" in response.answer
|
||||||
assert "参考票价" in response.answer
|
assert "| 交通费用口径 | 预估交通费用 2,330元 |" in response.answer
|
||||||
assert "按 2026-05-25 参考票价" in response.answer
|
|
||||||
assert "2,330元" in response.answer
|
assert "2,330元" in response.answer
|
||||||
assert "查询耗时" in response.answer
|
assert "参考票价" not in response.answer
|
||||||
|
assert "查询耗时" not in response.answer
|
||||||
assert response.requires_confirmation is True
|
assert response.requires_confirmation is True
|
||||||
assert response.suggested_actions == []
|
assert response.suggested_actions == []
|
||||||
|
|
||||||
@@ -362,14 +366,64 @@ def test_user_agent_application_uses_selected_date_range_and_keeps_reason() -> N
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
|
assert "| 出发时间 | 2026-02-20 |" in response.answer
|
||||||
|
assert "| 返回时间 | 2026-02-23 |" in response.answer
|
||||||
assert "| 地点 | 上海市 |" in response.answer
|
assert "| 地点 | 上海市 |" in response.answer
|
||||||
assert "| 事由 | 支撑国网仿生产环境部署 |" in response.answer
|
assert "| 事由 | 支撑国网仿生产环境部署 |" in response.answer
|
||||||
assert "| 天数 | 4天 |" in response.answer
|
assert "| 天数 | 4天 |" in response.answer
|
||||||
|
assert "| 住宿上限/天 | 450元/天 |" in response.answer
|
||||||
|
assert "| 补贴标准/天 | 100元/天 |" in response.answer
|
||||||
|
assert "| 规则测算参考 | 交通 2,460元 + 住宿 1,800元 + 补贴 400元 = 4,660元(4天) |" in response.answer
|
||||||
assert "| 发生时间 |" not in response.answer
|
assert "| 发生时间 |" not in response.answer
|
||||||
assert "| 事由 | 2026-02-20 至 2026-02-23 |" not in response.answer
|
assert "| 事由 | 2026-02-20 至 2026-02-23 |" not in response.answer
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_application_keeps_labeled_reason_in_structured_travel_form() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
message = (
|
||||||
|
"发生时间:2026-02-20 至 2026-02-23\n"
|
||||||
|
"地点:上海\n"
|
||||||
|
"事由:支撑国网仿生产环境建设\n"
|
||||||
|
"天数:4天"
|
||||||
|
)
|
||||||
|
context_json = {
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "application",
|
||||||
|
"name": "曹笑竹",
|
||||||
|
"department_name": "技术部",
|
||||||
|
"position": "财务智能化产品经理",
|
||||||
|
"manager_name": "向万红",
|
||||||
|
"grade": "P5",
|
||||||
|
}
|
||||||
|
with session_factory() as db:
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query=message,
|
||||||
|
user_id="pytest-structured-application-reason@example.com",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response = UserAgentService(db).respond(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest-structured-application-reason@example.com",
|
||||||
|
message=message,
|
||||||
|
ontology=ontology,
|
||||||
|
context_json=context_json,
|
||||||
|
tool_payload={"clarification_required": ontology.clarification_required},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "| 申请类型 | 差旅费用申请 |" in response.answer
|
||||||
|
assert "| 出发时间 | 2026-02-20 |" in response.answer
|
||||||
|
assert "| 返回时间 | 2026-02-23 |" in response.answer
|
||||||
|
assert "| 地点 | 上海市 |" in response.answer
|
||||||
|
assert "| 事由 | 支撑国网仿生产环境建设 |" in response.answer
|
||||||
|
assert "| 天数 | 4天 |" in response.answer
|
||||||
|
assert "申请事由" not in response.answer
|
||||||
|
assert "当前还需要补充:出行方式" in response.answer
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_application_derives_days_from_selected_date_range() -> None:
|
def test_user_agent_application_derives_days_from_selected_date_range() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
message = "去上海出差,支撑国网仿生产服务器部署,火车"
|
message = "去上海出差,支撑国网仿生产服务器部署,火车"
|
||||||
@@ -418,7 +472,8 @@ def test_user_agent_application_derives_days_from_selected_date_range() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
|
assert "| 出发时间 | 2026-02-20 |" in response.answer
|
||||||
|
assert "| 返回时间 | 2026-02-23 |" in response.answer
|
||||||
assert "| 天数 | 4天 |" in response.answer
|
assert "| 天数 | 4天 |" in response.answer
|
||||||
assert "| 天数 | 待补充 |" not in response.answer
|
assert "| 天数 | 待补充 |" not in response.answer
|
||||||
assert "(4天)" in response.answer
|
assert "(4天)" in response.answer
|
||||||
@@ -452,7 +507,8 @@ def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "这是费用申请核对结果" in response.answer
|
assert "这是费用申请核对结果" in response.answer
|
||||||
assert "| 行程时间 | 2026-05-29 至 2026-05-31 |" in response.answer
|
assert "| 出发时间 | 2026-05-29 |" in response.answer
|
||||||
|
assert "| 返回时间 | 2026-05-31 |" in response.answer
|
||||||
assert response.requires_confirmation is True
|
assert response.requires_confirmation is True
|
||||||
|
|
||||||
|
|
||||||
@@ -547,7 +603,8 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
|||||||
"| 字段 | 内容 |\n"
|
"| 字段 | 内容 |\n"
|
||||||
"| --- | --- |\n"
|
"| --- | --- |\n"
|
||||||
"| 申请类型 | 差旅费用申请 |\n"
|
"| 申请类型 | 差旅费用申请 |\n"
|
||||||
"| 行程时间 | 2026-05-25 |\n"
|
"| 出发时间 | 2026-05-25 |\n"
|
||||||
|
"| 返回时间 | 2026-05-27 |\n"
|
||||||
"| 地点 | 上海市 |\n"
|
"| 地点 | 上海市 |\n"
|
||||||
"| 事由 | 支持上海国网服务器部署 |\n"
|
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||||
"| 天数 | 3天 |\n"
|
"| 天数 | 3天 |\n"
|
||||||
@@ -585,7 +642,8 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
|||||||
def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
|
def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
initial_message = (
|
initial_message = (
|
||||||
"行程时间:2026-05-25 至 2026-05-27\n"
|
"出发时间:2026-05-25\n"
|
||||||
|
"返回时间:2026-05-27\n"
|
||||||
"地点:上海\n"
|
"地点:上海\n"
|
||||||
"事由:支持上海国网服务器部署\n"
|
"事由:支持上海国网服务器部署\n"
|
||||||
"天数:3天\n"
|
"天数:3天\n"
|
||||||
@@ -597,7 +655,8 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
|
|||||||
"| 字段 | 内容 |\n"
|
"| 字段 | 内容 |\n"
|
||||||
"| --- | --- |\n"
|
"| --- | --- |\n"
|
||||||
"| 申请类型 | 差旅费用申请 |\n"
|
"| 申请类型 | 差旅费用申请 |\n"
|
||||||
"| 行程时间 | 2026-05-25 至 2026-05-27 |\n"
|
"| 出发时间 | 2026-05-25 |\n"
|
||||||
|
"| 返回时间 | 2026-05-27 |\n"
|
||||||
"| 地点 | 上海市 |\n"
|
"| 地点 | 上海市 |\n"
|
||||||
"| 事由 | 支持上海国网服务器部署 |\n"
|
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||||
"| 天数 | 3天 |\n"
|
"| 天数 | 3天 |\n"
|
||||||
@@ -1385,6 +1444,7 @@ def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
|
|||||||
"application_location": "北京",
|
"application_location": "北京",
|
||||||
"application_amount": "3000元",
|
"application_amount": "3000元",
|
||||||
"application_business_time": "2026-06-01 至 2026-06-03",
|
"application_business_time": "2026-06-01 至 2026-06-03",
|
||||||
|
"application_transport_mode": "火车",
|
||||||
},
|
},
|
||||||
"user_input_text": message,
|
"user_input_text": message,
|
||||||
}
|
}
|
||||||
@@ -1412,6 +1472,15 @@ def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
|
|||||||
assert slot_map["location"].value == "北京"
|
assert slot_map["location"].value == "北京"
|
||||||
assert slot_map["amount"].value == "3000.00元"
|
assert slot_map["amount"].value == "3000.00元"
|
||||||
assert slot_map["time_range"].value == "2026-06-01 至 2026-06-03"
|
assert slot_map["time_range"].value == "2026-06-01 至 2026-06-03"
|
||||||
|
assert UserAgentService._resolve_review_form_values(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest-linked-application-review@example.com",
|
||||||
|
message=message,
|
||||||
|
ontology=ontology,
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)["transport_mode"] == "火车"
|
||||||
assert "事由说明" not in response.review_payload.missing_slots
|
assert "事由说明" not in response.review_payload.missing_slots
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -136,12 +136,16 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-panel,
|
.trend-panel {
|
||||||
.rank-panel {
|
|
||||||
grid-column: span 6;
|
grid-column: span 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trend-count-panel,
|
||||||
.donut-panel,
|
.donut-panel,
|
||||||
|
.rank-panel,
|
||||||
|
.employee-rank-panel,
|
||||||
|
.top-claim-panel,
|
||||||
|
.budget-metrics-panel,
|
||||||
.bottleneck-panel,
|
.bottleneck-panel,
|
||||||
.budget-panel,
|
.budget-panel,
|
||||||
.model-panel,
|
.model-panel,
|
||||||
@@ -149,6 +153,12 @@
|
|||||||
grid-column: span 3;
|
grid-column: span 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottleneck-panel,
|
||||||
|
.budget-metrics-panel,
|
||||||
|
.budget-panel {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
|
||||||
.card-head {
|
.card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -404,7 +414,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottleneck-panel,
|
.bottleneck-panel,
|
||||||
|
.budget-metrics-panel,
|
||||||
.budget-panel,
|
.budget-panel,
|
||||||
|
.top-claim-panel,
|
||||||
.model-panel,
|
.model-panel,
|
||||||
.feedback-panel {
|
.feedback-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -477,6 +489,142 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.budget-metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f8fafc;
|
||||||
|
animation: listRowIn 460ms var(--ease) both;
|
||||||
|
animation-delay: var(--delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(var(--theme-primary-rgb, 58, 124, 165), .10);
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item span:not(.budget-metric-icon),
|
||||||
|
.budget-metric-item strong,
|
||||||
|
.budget-metric-item em {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item span:not(.budget-metric-icon) {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item strong {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item em {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item.warning {
|
||||||
|
border-color: rgba(245, 158, 11, .26);
|
||||||
|
background: rgba(245, 158, 11, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item.warning .budget-metric-icon {
|
||||||
|
background: rgba(245, 158, 11, .12);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item.danger {
|
||||||
|
border-color: rgba(239, 68, 68, .26);
|
||||||
|
background: rgba(239, 68, 68, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item.danger .budget-metric-icon {
|
||||||
|
background: rgba(239, 68, 68, .12);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-metric-item.success .budget-metric-icon {
|
||||||
|
background: rgba(var(--success-rgb), .10);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-claim-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-claim-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-claim-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-claim-row div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-claim-row div:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-claim-row strong,
|
||||||
|
.top-claim-row span {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-claim-row strong {
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-claim-row span {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.bottleneck-list {
|
.bottleneck-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -610,6 +758,7 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.kpi-card,
|
.kpi-card,
|
||||||
.dashboard-card,
|
.dashboard-card,
|
||||||
|
.budget-metric-item,
|
||||||
.bottleneck-row {
|
.bottleneck-row {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
@@ -630,11 +779,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend-panel,
|
.trend-panel,
|
||||||
.rank-panel {
|
.trend-count-panel,
|
||||||
|
.rank-panel,
|
||||||
|
.employee-rank-panel,
|
||||||
|
.top-claim-panel {
|
||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donut-panel,
|
.donut-panel,
|
||||||
|
.budget-metrics-panel,
|
||||||
.bottleneck-panel,
|
.bottleneck-panel,
|
||||||
.budget-panel,
|
.budget-panel,
|
||||||
.model-panel,
|
.model-panel,
|
||||||
@@ -694,8 +847,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend-panel,
|
.trend-panel,
|
||||||
|
.trend-count-panel,
|
||||||
.rank-panel,
|
.rank-panel,
|
||||||
|
.employee-rank-panel,
|
||||||
|
.top-claim-panel,
|
||||||
.donut-panel,
|
.donut-panel,
|
||||||
|
.budget-metrics-panel,
|
||||||
.bottleneck-panel,
|
.bottleneck-panel,
|
||||||
.budget-panel,
|
.budget-panel,
|
||||||
.model-panel,
|
.model-panel,
|
||||||
@@ -716,6 +873,10 @@
|
|||||||
grid-template-columns: 24px 64px minmax(0, 1fr);
|
grid-template-columns: 24px 64px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.budget-metric-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.rank-value {
|
.rank-value {
|
||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="trend-chart">
|
<div class="trend-chart">
|
||||||
<div class="chart-legend">
|
<div class="chart-legend">
|
||||||
<span><i :style="{ background: chartColors.primary }"></i>申请量(单)</span>
|
<span><i :style="{ background: activeColor }"></i>{{ legendLabel }}</span>
|
||||||
<span><i :style="{ background: chartColors.blue }"></i>审批完成量(单)</span>
|
|
||||||
<span><i :style="{ background: chartColors.purple }"></i>平均审批时长(小时)</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,22 +21,44 @@ use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasR
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
labels: { type: Array, required: true },
|
labels: { type: Array, required: true },
|
||||||
applications: { type: Array, required: true },
|
mode: { type: String, default: 'amount' },
|
||||||
approved: { type: Array, required: true },
|
claimCount: { type: Array, default: () => [] },
|
||||||
avgHours: { type: Array, required: true }
|
claimAmount: { type: Array, default: () => [] },
|
||||||
|
applications: { type: Array, default: () => [] },
|
||||||
|
approved: { type: Array, default: () => [] },
|
||||||
|
avgHours: { type: Array, default: () => [] }
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartElement = shallowRef(null)
|
const chartElement = shallowRef(null)
|
||||||
const themeColors = useThemeColors()
|
const themeColors = useThemeColors()
|
||||||
const chartColors = computed(() => ({
|
const chartColors = computed(() => ({
|
||||||
primary: themeColors.value.chartPrimary,
|
primary: themeColors.value.chartPrimary,
|
||||||
blue: themeColors.value.chartBlue,
|
blue: themeColors.value.chartBlue
|
||||||
purple: themeColors.value.chartPurple
|
|
||||||
}))
|
}))
|
||||||
|
const isCountMode = computed(() => props.mode === 'count')
|
||||||
|
|
||||||
|
const claimCountSeries = computed(() => (
|
||||||
|
props.claimCount.length ? props.claimCount : props.applications
|
||||||
|
))
|
||||||
|
const claimAmountSeries = computed(() => (
|
||||||
|
props.claimAmount.length ? props.claimAmount : props.approved
|
||||||
|
))
|
||||||
|
const activeSeries = computed(() => (
|
||||||
|
isCountMode.value ? claimCountSeries.value : claimAmountSeries.value
|
||||||
|
))
|
||||||
|
const activeColor = computed(() => (
|
||||||
|
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
||||||
|
))
|
||||||
|
const legendLabel = computed(() => (
|
||||||
|
isCountMode.value ? '报销数量(单)' : '报销金额(元)'
|
||||||
|
))
|
||||||
|
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
|
||||||
|
|
||||||
const ariaLabel = computed(() =>
|
const ariaLabel = computed(() =>
|
||||||
props.labels.map((label, index) => (
|
props.labels.map((label, index) => (
|
||||||
`${label}申请${props.applications[index] || 0}单,审批${props.approved[index] || 0}单,平均${props.avgHours[index] || 0}小时`
|
isCountMode.value
|
||||||
|
? `${label}报销${claimCountSeries.value[index] || 0}单`
|
||||||
|
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
|
||||||
)).join(';')
|
)).join(';')
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +71,7 @@ const chartOptions = computed(() => ({
|
|||||||
animationEasingUpdate: 'linear',
|
animationEasingUpdate: 'linear',
|
||||||
grid: {
|
grid: {
|
||||||
top: 18,
|
top: 18,
|
||||||
right: 38,
|
right: 24,
|
||||||
bottom: 22,
|
bottom: 22,
|
||||||
left: 36,
|
left: 36,
|
||||||
containLabel: true
|
containLabel: true
|
||||||
@@ -83,72 +103,46 @@ const chartOptions = computed(() => ({
|
|||||||
fontWeight: 700
|
fontWeight: 700
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxis: [
|
yAxis: {
|
||||||
{
|
|
||||||
type: 'value',
|
type: 'value',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 250,
|
max: Math.ceil(maxValue.value * 1.2),
|
||||||
splitNumber: 5,
|
splitNumber: 5,
|
||||||
axisLabel: {
|
name: isCountMode.value ? '单' : '元',
|
||||||
|
nameTextStyle: {
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 700
|
fontWeight: 700
|
||||||
},
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
|
||||||
|
},
|
||||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
min: 0,
|
|
||||||
max: 15,
|
|
||||||
splitNumber: 5,
|
|
||||||
axisLabel: {
|
|
||||||
color: '#64748b',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700
|
|
||||||
},
|
|
||||||
splitLine: { show: false }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '申请量(单)',
|
name: legendLabel.value,
|
||||||
type: 'bar',
|
type: isCountMode.value ? 'line' : 'bar',
|
||||||
data: props.applications,
|
data: activeSeries.value,
|
||||||
barWidth: 12,
|
barWidth: 16,
|
||||||
barGap: '28%',
|
smooth: isCountMode.value,
|
||||||
itemStyle: {
|
symbol: isCountMode.value ? 'circle' : 'none',
|
||||||
color: chartColors.value.primary,
|
|
||||||
borderRadius: [4, 4, 0, 0]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '审批完成量(单)',
|
|
||||||
type: 'bar',
|
|
||||||
data: props.approved,
|
|
||||||
barWidth: 12,
|
|
||||||
itemStyle: {
|
|
||||||
color: chartColors.value.blue,
|
|
||||||
borderRadius: [4, 4, 0, 0]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '平均审批时长(小时)',
|
|
||||||
type: 'line',
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: props.avgHours,
|
|
||||||
smooth: true,
|
|
||||||
symbol: 'circle',
|
|
||||||
symbolSize: 7,
|
symbolSize: 7,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: 2.5,
|
width: 2.5,
|
||||||
color: chartColors.value.purple
|
color: activeColor.value
|
||||||
},
|
},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: '#ffffff',
|
color: isCountMode.value ? '#ffffff' : activeColor.value,
|
||||||
borderColor: chartColors.value.purple,
|
borderColor: activeColor.value,
|
||||||
borderWidth: 2.5
|
borderWidth: isCountMode.value ? 2.5 : 0,
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
},
|
},
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
|
opacity: isCountMode.value ? 1 : 0,
|
||||||
color: {
|
color: {
|
||||||
type: 'linear',
|
type: 'linear',
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -156,10 +150,15 @@ const chartOptions = computed(() => ({
|
|||||||
x2: 0,
|
x2: 0,
|
||||||
y2: 1,
|
y2: 1,
|
||||||
colorStops: [
|
colorStops: [
|
||||||
{ offset: 0, color: toRgba(chartColors.value.purple, 0.14) },
|
{ offset: 0, color: toRgba(activeColor.value, 0.14) },
|
||||||
{ offset: 1, color: toRgba(chartColors.value.purple, 0.02) }
|
{ offset: 1, color: toRgba(activeColor.value, 0.02) }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
valueFormatter: (value) => (
|
||||||
|
isCountMode.value ? `${Number(value || 0)} 单` : formatCurrency(value)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -178,6 +177,20 @@ function toRgba(color, alpha) {
|
|||||||
}
|
}
|
||||||
return `rgba(58, 124, 165, ${alpha})`
|
return `rgba(58, 124, 165, ${alpha})`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value) {
|
||||||
|
const number = Number(value || 0)
|
||||||
|
if (number >= 1000000) return `¥${(number / 1000000).toFixed(1)}M`
|
||||||
|
if (number >= 1000) return `¥${(number / 1000).toFixed(1)}K`
|
||||||
|
return `¥${Math.round(number)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAxisCurrency(value) {
|
||||||
|
const number = Number(value || 0)
|
||||||
|
if (number >= 1000000) return `${(number / 1000000).toFixed(1)}M`
|
||||||
|
if (number >= 1000) return `${(number / 1000).toFixed(0)}K`
|
||||||
|
return `${Math.round(number)}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="page-tools">
|
||||||
<EnterpriseSelect
|
<EnterpriseSelect
|
||||||
v-if="showPageSize"
|
v-if="showPageSize"
|
||||||
class="page-size-select"
|
class="page-size-select"
|
||||||
@@ -46,11 +47,27 @@
|
|||||||
size="small"
|
size="small"
|
||||||
@change="setPageSize"
|
@change="setPageSize"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="page-jump">
|
||||||
|
<span>跳至</span>
|
||||||
|
<input
|
||||||
|
:value="pageInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
aria-label="输入页码跳转"
|
||||||
|
@blur="commitPageInput"
|
||||||
|
@input="updatePageInput"
|
||||||
|
@keydown.enter.prevent="commitPageInput"
|
||||||
|
/>
|
||||||
|
<span>页</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
import EnterpriseSelect from './EnterpriseSelect.vue'
|
import EnterpriseSelect from './EnterpriseSelect.vue'
|
||||||
|
|
||||||
@@ -73,12 +90,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-size-change'])
|
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-size-change'])
|
||||||
|
|
||||||
|
const pageInput = ref(String(props.currentPage || 1))
|
||||||
|
|
||||||
const pageItems = computed(() => {
|
const pageItems = computed(() => {
|
||||||
if (props.pages.length) {
|
const total = Math.max(1, Number(props.totalPages) || 1)
|
||||||
return props.pages
|
if (total <= 4) {
|
||||||
|
return Array.from({ length: total }, (_, index) => index + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from({ length: props.totalPages }, (_, index) => index + 1)
|
return [1, 2, 3, 'ellipsis', total]
|
||||||
})
|
})
|
||||||
|
|
||||||
const summaryText = computed(() => {
|
const summaryText = computed(() => {
|
||||||
@@ -104,4 +124,21 @@ function setPageSize(size) {
|
|||||||
emit('update:pageSize', size)
|
emit('update:pageSize', size)
|
||||||
emit('page-size-change', size)
|
emit('page-size-change', size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePageInput(event) {
|
||||||
|
pageInput.value = String(event.target.value || '').replace(/\D/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitPageInput() {
|
||||||
|
const nextPage = Math.min(Math.max(Number(pageInput.value) || props.currentPage || 1, 1), props.totalPages)
|
||||||
|
pageInput.value = String(nextPage)
|
||||||
|
setPage(nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.currentPage,
|
||||||
|
(page) => {
|
||||||
|
pageInput.value = String(page || 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,29 +5,26 @@ export function useLoginView() {
|
|||||||
const password = ref('')
|
const password = ref('')
|
||||||
const tenant = ref('远光软件股份有限公司')
|
const tenant = ref('远光软件股份有限公司')
|
||||||
const remember = ref(true)
|
const remember = ref(true)
|
||||||
|
const showPassword = ref(false)
|
||||||
const tenantOptions = [
|
|
||||||
{
|
|
||||||
label: '远光软件股份有限公司',
|
|
||||||
value: '远光软件股份有限公司'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
iconKey: 'recognition',
|
title: '智能审单',
|
||||||
title: '智能识别 自动归集',
|
desc: 'AI 自动识别票据与规则,提升准确率与处理效率',
|
||||||
desc: '票据智能识别,自动归集费用,减少人工录入'
|
icon: 'mdi mdi-file-document-outline',
|
||||||
|
tone: 'green'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconKey: 'workflow',
|
title: '异常预警',
|
||||||
title: '流程透明 合规可控',
|
desc: '多维风险识别与预警,主动防控报销风险',
|
||||||
desc: '内置审批规则引擎,流程透明,风险可控'
|
icon: 'mdi mdi-bell-outline',
|
||||||
|
tone: 'red'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconKey: 'insight',
|
title: 'SLA 监控',
|
||||||
title: '数据洞察 决策支持',
|
desc: '实时监控服务水位,保障审批和处理时效',
|
||||||
desc: '多维度费用分析,洞察业务,驱动决策'
|
icon: 'mdi mdi-sync',
|
||||||
|
tone: 'blue'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -52,8 +49,8 @@ export function useLoginView() {
|
|||||||
LogoMark,
|
LogoMark,
|
||||||
password,
|
password,
|
||||||
remember,
|
remember,
|
||||||
|
showPassword,
|
||||||
tenant,
|
tenant,
|
||||||
tenantOptions,
|
|
||||||
username
|
username
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,16 +37,18 @@ import {
|
|||||||
} from '../data/metrics.js'
|
} from '../data/metrics.js'
|
||||||
|
|
||||||
const emptyFinanceTotals = {
|
const emptyFinanceTotals = {
|
||||||
pendingCount: 0,
|
reimbursementAmount: 0,
|
||||||
pendingAmount: 0,
|
reimbursementCount: 0,
|
||||||
avgSla: 0,
|
pendingPaymentAmount: 0,
|
||||||
autoPassRate: 0,
|
avgClaimAmount: 0,
|
||||||
riskCount: 0,
|
budgetUsageRate: 0,
|
||||||
slaRate: 0
|
paymentClearanceRate: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyFinanceTrend = {
|
const emptyFinanceTrend = {
|
||||||
labels: [],
|
labels: [],
|
||||||
|
claimCount: [],
|
||||||
|
claimAmount: [],
|
||||||
applications: [],
|
applications: [],
|
||||||
approved: [],
|
approved: [],
|
||||||
avgHours: []
|
avgHours: []
|
||||||
@@ -63,6 +65,15 @@ const emptyFinanceBudgetSummary = {
|
|||||||
left: '¥0'
|
left: '¥0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyFinanceBudgetMetrics = [
|
||||||
|
{ label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
|
||||||
|
{ label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
|
||||||
|
{ label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
|
||||||
|
{ label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
|
||||||
|
{ label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
|
||||||
|
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
|
||||||
|
]
|
||||||
|
|
||||||
export function useOverviewView(options = {}) {
|
export function useOverviewView(options = {}) {
|
||||||
const activeTrendRange = ref(trendRanges[0])
|
const activeTrendRange = ref(trendRanges[0])
|
||||||
const activeDepartmentRange = ref(departmentRangeOptions[0])
|
const activeDepartmentRange = ref(departmentRangeOptions[0])
|
||||||
@@ -103,8 +114,9 @@ export function useOverviewView(options = {}) {
|
|||||||
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
|
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
|
||||||
|
|
||||||
const formatMetricValue = (metric, value) => {
|
const formatMetricValue = (metric, value) => {
|
||||||
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
|
if (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
|
||||||
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
|
return formatCurrency(Math.round(value))
|
||||||
|
}
|
||||||
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
|
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
|
||||||
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
|
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
|
||||||
return `${Math.round(value)}`
|
return `${Math.round(value)}`
|
||||||
@@ -311,12 +323,21 @@ export function useOverviewView(options = {}) {
|
|||||||
const financeDepartmentRanking = computed(() => (
|
const financeDepartmentRanking = computed(() => (
|
||||||
financeDashboardPayload.value?.departmentRanking || []
|
financeDashboardPayload.value?.departmentRanking || []
|
||||||
))
|
))
|
||||||
|
const financeEmployeeRanking = computed(() => (
|
||||||
|
financeDashboardPayload.value?.employeeRanking || []
|
||||||
|
))
|
||||||
|
const financeTopClaims = computed(() => (
|
||||||
|
financeDashboardPayload.value?.topClaims || []
|
||||||
|
))
|
||||||
const financeBottlenecks = computed(() => (
|
const financeBottlenecks = computed(() => (
|
||||||
financeDashboardPayload.value?.bottlenecks || []
|
financeDashboardPayload.value?.bottlenecks || []
|
||||||
))
|
))
|
||||||
const financeBudgetSummary = computed(() => (
|
const financeBudgetSummary = computed(() => (
|
||||||
financeDashboardPayload.value?.budgetSummary || emptyFinanceBudgetSummary
|
financeDashboardPayload.value?.budgetSummary || emptyFinanceBudgetSummary
|
||||||
))
|
))
|
||||||
|
const financeBudgetMetrics = computed(() => (
|
||||||
|
financeDashboardPayload.value?.budgetMetrics || emptyFinanceBudgetMetrics
|
||||||
|
))
|
||||||
|
|
||||||
const resolveSystemMetricMeta = (metric) => {
|
const resolveSystemMetricMeta = (metric) => {
|
||||||
const totals = systemDashboardTotals.value
|
const totals = systemDashboardTotals.value
|
||||||
@@ -508,13 +529,15 @@ export function useOverviewView(options = {}) {
|
|||||||
})))
|
})))
|
||||||
|
|
||||||
const rankedDepartments = computed(() => {
|
const rankedDepartments = computed(() => {
|
||||||
const rows = financeDepartmentRanking.value.map((item) => ({
|
const rows = financeDepartmentRanking.value
|
||||||
|
.filter((item) => !isMissingDimension(item.name))
|
||||||
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
amount: Number(item.amount || item.value || 0)
|
amount: Number(item.amount || item.value || 0)
|
||||||
}))
|
}))
|
||||||
const max = Math.max(...rows.map((item) => item.amount), 1)
|
const max = Math.max(...rows.map((item) => item.amount), 1)
|
||||||
|
|
||||||
return rows.slice(0, 5).map((item, index) => ({
|
return rows.slice(0, 6).map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
shortName: item.name,
|
shortName: item.name,
|
||||||
@@ -524,6 +547,32 @@ export function useOverviewView(options = {}) {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rankedEmployees = computed(() => {
|
||||||
|
const rows = financeEmployeeRanking.value
|
||||||
|
.filter((item) => !isMissingDimension(item.name))
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
amount: Number(item.amount || item.value || 0)
|
||||||
|
}))
|
||||||
|
const max = Math.max(...rows.map((item) => item.amount), 1)
|
||||||
|
|
||||||
|
return rows.slice(0, 6).map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
rank: index + 1,
|
||||||
|
shortName: item.name,
|
||||||
|
amountLabel: formatCurrency(item.amount),
|
||||||
|
width: `${Math.max((item.amount / max) * 100, 18)}%`,
|
||||||
|
color: item.color
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const topClaims = computed(() => (
|
||||||
|
financeTopClaims.value.map((item) => ({
|
||||||
|
...item,
|
||||||
|
amountLabel: item.amountLabel || formatCurrency(Number(item.amount || 0))
|
||||||
|
}))
|
||||||
|
))
|
||||||
|
|
||||||
const systemToolRankingItems = computed(() => systemToolRankings.map((item, index) => ({
|
const systemToolRankingItems = computed(() => systemToolRankings.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
@@ -670,8 +719,14 @@ export function useOverviewView(options = {}) {
|
|||||||
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
|
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMissingDimension(value) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
|
||||||
|
}
|
||||||
|
|
||||||
const bottlenecks = financeBottlenecks
|
const bottlenecks = financeBottlenecks
|
||||||
const budgetSummary = financeBudgetSummary
|
const budgetSummary = financeBudgetSummary
|
||||||
|
const budgetMetrics = financeBudgetMetrics
|
||||||
const spendByCategory = financeSpendByCategory
|
const spendByCategory = financeSpendByCategory
|
||||||
const exceptionMix = financeExceptionMix
|
const exceptionMix = financeExceptionMix
|
||||||
|
|
||||||
@@ -681,6 +736,7 @@ export function useOverviewView(options = {}) {
|
|||||||
activeTrend,
|
activeTrend,
|
||||||
activeTrendRange,
|
activeTrendRange,
|
||||||
bottlenecks,
|
bottlenecks,
|
||||||
|
budgetMetrics,
|
||||||
budgetSummary,
|
budgetSummary,
|
||||||
departmentRangeOptions,
|
departmentRangeOptions,
|
||||||
digitalEmployeeCategoryRows,
|
digitalEmployeeCategoryRows,
|
||||||
@@ -701,6 +757,7 @@ export function useOverviewView(options = {}) {
|
|||||||
kpiMetrics,
|
kpiMetrics,
|
||||||
metricBlueprints,
|
metricBlueprints,
|
||||||
rankedDepartments,
|
rankedDepartments,
|
||||||
|
rankedEmployees,
|
||||||
riskDashboard,
|
riskDashboard,
|
||||||
riskDashboardError,
|
riskDashboardError,
|
||||||
riskDashboardLoading,
|
riskDashboardLoading,
|
||||||
@@ -743,6 +800,7 @@ export function useOverviewView(options = {}) {
|
|||||||
systemToolRankings,
|
systemToolRankings,
|
||||||
systemToolTotal,
|
systemToolTotal,
|
||||||
systemTrendSeries,
|
systemTrendSeries,
|
||||||
|
topClaims,
|
||||||
trendRanges
|
trendRanges
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
|||||||
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||||
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
||||||
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||||
|
const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
|
||||||
|
'train_ticket',
|
||||||
|
'flight_ticket',
|
||||||
|
'ship_ticket',
|
||||||
|
'ferry_ticket',
|
||||||
|
'hotel_ticket',
|
||||||
|
'ride_ticket'
|
||||||
|
])
|
||||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||||
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
||||||
@@ -258,6 +266,83 @@ function resolveAttachmentDisplayName(value) {
|
|||||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRelatedApplicationContext(claim) {
|
||||||
|
return Boolean(findRelatedApplicationEvent(claim))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDocumentBackedRawExpenseItem(item) {
|
||||||
|
const invoiceId = normalizeText(item?.invoice_id || item?.invoiceId)
|
||||||
|
if (invoiceId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return DOCUMENT_BACKED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTravelDayCount(value) {
|
||||||
|
const matched = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
|
||||||
|
return matched ? parseNumber(matched[1]) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleApplicationAllowanceRawItem(item, claim) {
|
||||||
|
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||||
|
if (itemType !== 'travel_allowance') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const related = resolveRelatedApplicationInfo(claim)
|
||||||
|
const applicationDays = extractTravelDayCount(related?.days)
|
||||||
|
const itemDays = extractTravelDayCount(item?.item_reason || item?.itemReason)
|
||||||
|
return applicationDays > 0 && itemDays > 0 && applicationDays !== itemDays
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApplicationLinkPlaceholderRawItem(item, claim) {
|
||||||
|
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||||
|
if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimType = normalizeExpenseType(claim?.expense_type || claim?.expenseType)
|
||||||
|
if (itemType && claimType && itemType !== claimType) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = normalizeText(item?.item_reason || item?.itemReason)
|
||||||
|
if (!reason || reason === '待补充') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const related = resolveRelatedApplicationInfo(claim)
|
||||||
|
const linkedReasons = new Set([
|
||||||
|
normalizeText(claim?.reason),
|
||||||
|
normalizeText(related?.reason)
|
||||||
|
].filter(Boolean))
|
||||||
|
return linkedReasons.has(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterVisibleExpenseRawItems(items, claim) {
|
||||||
|
const rawItems = Array.isArray(items) ? items : []
|
||||||
|
if (!rawItems.length || !hasRelatedApplicationContext(claim)) {
|
||||||
|
return rawItems
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRealExpenseItem = rawItems.some((item) => (
|
||||||
|
isDocumentBackedRawExpenseItem(item)
|
||||||
|
&& !SYSTEM_GENERATED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
|
||||||
|
))
|
||||||
|
if (!hasRealExpenseItem) {
|
||||||
|
return rawItems.filter((item) => !isApplicationLinkPlaceholderRawItem(item, claim))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawItems.filter((item) => {
|
||||||
|
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||||
|
if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
|
||||||
|
return !isStaleApplicationAllowanceRawItem(item, claim)
|
||||||
|
}
|
||||||
|
return !isApplicationLinkPlaceholderRawItem(item, claim)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resolveApprovalMeta(status) {
|
function resolveApprovalMeta(status) {
|
||||||
const normalized = String(status || '').trim().toLowerCase()
|
const normalized = String(status || '').trim().toLowerCase()
|
||||||
|
|
||||||
@@ -617,6 +702,33 @@ function resolveApplicationField(flag = {}, detail = {}, snakeKey, camelKey = ''
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveApplicationValue(flag = {}, detail = {}, keys = []) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const detailValue = normalizeText(detail?.[key])
|
||||||
|
if (detailValue) {
|
||||||
|
return detailValue
|
||||||
|
}
|
||||||
|
const flagValue = normalizeText(flag?.[key])
|
||||||
|
if (flagValue) {
|
||||||
|
return flagValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDateRange(value) {
|
||||||
|
const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
|
||||||
|
if (!dates.length) {
|
||||||
|
return { startDate: '', endDate: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: dates[0],
|
||||||
|
endDate: dates[dates.length - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRelatedApplicationClaimNo(flag = {}) {
|
function resolveRelatedApplicationClaimNo(flag = {}) {
|
||||||
const detail = normalizeApplicationHandoffDetail(flag)
|
const detail = normalizeApplicationHandoffDetail(flag)
|
||||||
return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
|
return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
|
||||||
@@ -694,15 +806,41 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|
|||||||
const rawTime = normalizeText(
|
const rawTime = normalizeText(
|
||||||
detail.application_time
|
detail.application_time
|
||||||
|| detail.applicationTime
|
|| detail.applicationTime
|
||||||
|
|| detail.application_business_time
|
||||||
|
|| detail.applicationBusinessTime
|
||||||
|
|| detail.business_time
|
||||||
|
|| detail.businessTime
|
||||||
|
|| detail.time_range
|
||||||
|
|| detail.timeRange
|
||||||
|| detail.time
|
|| detail.time
|
||||||
|| detail.application_date
|
|| detail.application_date
|
||||||
|| detail.applicationDate
|
|| detail.applicationDate
|
||||||
|| relatedEvent.application_time
|
|| relatedEvent.application_time
|
||||||
|| relatedEvent.applicationTime
|
|| relatedEvent.applicationTime
|
||||||
|
|| relatedEvent.application_business_time
|
||||||
|
|| relatedEvent.applicationBusinessTime
|
||||||
|
|| relatedEvent.business_time
|
||||||
|
|| relatedEvent.businessTime
|
||||||
|
|| relatedEvent.time_range
|
||||||
|
|| relatedEvent.timeRange
|
||||||
|| relatedEvent.application_date
|
|| relatedEvent.application_date
|
||||||
|| relatedEvent.applicationDate
|
|| relatedEvent.applicationDate
|
||||||
|| claim?.occurred_at
|
|| claim?.occurred_at
|
||||||
)
|
)
|
||||||
|
const displayTime = formatDate(rawTime) || rawTime
|
||||||
|
const dateRange = extractDateRange(rawTime || displayTime)
|
||||||
|
const ruleName = resolveApplicationValue(relatedEvent, detail, [
|
||||||
|
'application_rule_name',
|
||||||
|
'applicationRuleName',
|
||||||
|
'rule_name',
|
||||||
|
'ruleName'
|
||||||
|
])
|
||||||
|
const ruleVersion = resolveApplicationValue(relatedEvent, detail, [
|
||||||
|
'application_rule_version',
|
||||||
|
'applicationRuleVersion',
|
||||||
|
'rule_version',
|
||||||
|
'ruleVersion'
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
|
id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
|
||||||
@@ -717,7 +855,9 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|
|||||||
|| relatedEvent.applicationDays
|
|| relatedEvent.applicationDays
|
||||||
),
|
),
|
||||||
location,
|
location,
|
||||||
time: formatDate(rawTime) || rawTime,
|
time: displayTime,
|
||||||
|
tripStartDate: dateRange.startDate,
|
||||||
|
tripEndDate: dateRange.endDate,
|
||||||
amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
|
amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
|
||||||
statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
|
statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
|
||||||
transportMode: normalizeText(
|
transportMode: normalizeText(
|
||||||
@@ -726,7 +866,34 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|
|||||||
|| detail.transport_mode
|
|| detail.transport_mode
|
||||||
|| relatedEvent.application_transport_mode
|
|| relatedEvent.application_transport_mode
|
||||||
|| relatedEvent.applicationTransportMode
|
|| relatedEvent.applicationTransportMode
|
||||||
)
|
),
|
||||||
|
lodgingDailyCap: resolveApplicationValue(relatedEvent, detail, [
|
||||||
|
'application_lodging_daily_cap',
|
||||||
|
'applicationLodgingDailyCap',
|
||||||
|
'lodging_daily_cap',
|
||||||
|
'lodgingDailyCap'
|
||||||
|
]),
|
||||||
|
subsidyDailyCap: resolveApplicationValue(relatedEvent, detail, [
|
||||||
|
'application_subsidy_daily_cap',
|
||||||
|
'applicationSubsidyDailyCap',
|
||||||
|
'subsidy_daily_cap',
|
||||||
|
'subsidyDailyCap'
|
||||||
|
]),
|
||||||
|
transportPolicy: resolveApplicationValue(relatedEvent, detail, [
|
||||||
|
'application_transport_policy',
|
||||||
|
'applicationTransportPolicy',
|
||||||
|
'transport_policy',
|
||||||
|
'transportPolicy'
|
||||||
|
]),
|
||||||
|
policyEstimate: resolveApplicationValue(relatedEvent, detail, [
|
||||||
|
'application_policy_estimate',
|
||||||
|
'applicationPolicyEstimate',
|
||||||
|
'policy_estimate',
|
||||||
|
'policyEstimate'
|
||||||
|
]),
|
||||||
|
ruleName,
|
||||||
|
ruleVersion,
|
||||||
|
ruleLabel: [ruleName, ruleVersion].filter(Boolean).join(' / ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,7 +1223,8 @@ function buildExpenseItems(claim, riskSummary) {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedItems = [...claim.items].sort((left, right) => {
|
const visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
|
||||||
|
const sortedItems = [...visibleItems].sort((left, right) => {
|
||||||
const leftType = normalizeExpenseType(left?.item_type)
|
const leftType = normalizeExpenseType(left?.item_type)
|
||||||
const rightType = normalizeExpenseType(right?.item_type)
|
const rightType = normalizeExpenseType(right?.item_type)
|
||||||
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
|
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
|
||||||
@@ -1121,9 +1289,17 @@ export function mapExpenseClaimToRequest(claim) {
|
|||||||
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
|
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
|
||||||
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
||||||
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
|
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
|
||||||
const expenseItems = buildExpenseItems(claim, riskSummary)
|
|
||||||
const applyDateTime = claim?.submitted_at || claim?.created_at
|
|
||||||
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
||||||
|
const expenseItems = buildExpenseItems(claim, riskSummary)
|
||||||
|
const visibleExpenseAmount = expenseItems.reduce((sum, item) => sum + parseNumber(item.itemAmount), 0)
|
||||||
|
const amountValue = relatedApplication
|
||||||
|
? expenseItems.length
|
||||||
|
? visibleExpenseAmount
|
||||||
|
: invoiceCount === 0
|
||||||
|
? 0
|
||||||
|
: parseNumber(claim?.amount)
|
||||||
|
: parseNumber(claim?.amount)
|
||||||
|
const applyDateTime = claim?.submitted_at || claim?.created_at
|
||||||
const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim()
|
const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim()
|
||||||
const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
|
const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
|
||||||
|
|
||||||
@@ -1162,7 +1338,7 @@ export function mapExpenseClaimToRequest(claim) {
|
|||||||
submittedAt: applyDateTime || '',
|
submittedAt: applyDateTime || '',
|
||||||
createdAt: claim?.created_at || '',
|
createdAt: claim?.created_at || '',
|
||||||
updatedAt: claim?.updated_at || '',
|
updatedAt: claim?.updated_at || '',
|
||||||
amount: parseNumber(claim?.amount),
|
amount: amountValue,
|
||||||
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
|
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
|
||||||
invoiceCount,
|
invoiceCount,
|
||||||
workflowNode,
|
workflowNode,
|
||||||
|
|||||||
@@ -1,62 +1,60 @@
|
|||||||
export const metricBlueprints = [
|
export const metricBlueprints = [
|
||||||
{
|
{
|
||||||
key: 'pendingCount',
|
key: 'reimbursementAmount',
|
||||||
label: '待审批单据',
|
label: '本期报销金额',
|
||||||
unit: '单',
|
|
||||||
accent: 'var(--theme-primary)',
|
accent: 'var(--theme-primary)',
|
||||||
icon: 'mdi mdi-file-document-outline',
|
icon: 'mdi mdi-cash-multiple',
|
||||||
trend: 'down',
|
|
||||||
change: '12.5%',
|
|
||||||
delta: '较昨日 -18 单'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'pendingAmount',
|
|
||||||
label: '待处理金额',
|
|
||||||
accent: 'var(--chart-blue)',
|
|
||||||
icon: 'mdi mdi-wallet',
|
|
||||||
trend: 'up',
|
trend: 'up',
|
||||||
change: '8.3%',
|
change: '8.4%',
|
||||||
delta: '较昨日 +¥27,400'
|
delta: '较上一周期 +¥42.8K'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'avgSla',
|
key: 'reimbursementCount',
|
||||||
label: '平均审批时长',
|
label: '报销单数',
|
||||||
unit: 'h',
|
|
||||||
accent: 'var(--chart-purple)',
|
|
||||||
icon: 'mdi mdi-clock-outline',
|
|
||||||
trend: 'down',
|
|
||||||
change: '14.8%',
|
|
||||||
delta: '较昨日 -1.2h'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'autoPassRate',
|
|
||||||
label: '自动审单通过率',
|
|
||||||
unit: '%',
|
|
||||||
accent: 'var(--success)',
|
|
||||||
icon: 'mdi mdi-shield-outline',
|
|
||||||
trend: 'up',
|
|
||||||
change: '6.2%',
|
|
||||||
delta: '较昨日 +4.6%'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'riskCount',
|
|
||||||
label: '异常预警单',
|
|
||||||
unit: '单',
|
unit: '单',
|
||||||
accent: 'var(--danger)',
|
accent: 'var(--chart-blue)',
|
||||||
icon: 'mdi mdi-alert',
|
icon: 'mdi mdi-file-document-outline',
|
||||||
trend: 'up',
|
trend: 'up',
|
||||||
change: '16.7%',
|
change: '6.1%',
|
||||||
delta: '较昨日 +2 单'
|
delta: '较上一周期 +23 单'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'slaRate',
|
key: 'pendingPaymentAmount',
|
||||||
label: 'SLA 达成率',
|
label: '待付款金额',
|
||||||
|
accent: 'var(--chart-purple)',
|
||||||
|
icon: 'mdi mdi-bank-transfer-out',
|
||||||
|
trend: 'down',
|
||||||
|
change: '4.7%',
|
||||||
|
delta: '较上一周期 -¥18.3K'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgClaimAmount',
|
||||||
|
label: '单均金额',
|
||||||
|
accent: 'var(--chart-amber)',
|
||||||
|
icon: 'mdi mdi-calculator-variant-outline',
|
||||||
|
trend: 'up',
|
||||||
|
change: '2.8%',
|
||||||
|
delta: '较上一周期 +¥180'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budgetUsageRate',
|
||||||
|
label: '预算使用率',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
accent: 'var(--success)',
|
accent: 'var(--success)',
|
||||||
icon: 'mdi mdi-check-circle',
|
icon: 'mdi mdi-chart-arc',
|
||||||
trend: 'up',
|
trend: 'up',
|
||||||
change: '3.1%',
|
change: '3.2%',
|
||||||
delta: '较昨日 +2.9%'
|
delta: '预算池汇总'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'paymentClearanceRate',
|
||||||
|
label: '付款完成率',
|
||||||
|
unit: '%',
|
||||||
|
accent: 'var(--success)',
|
||||||
|
icon: 'mdi mdi-check-circle-outline',
|
||||||
|
trend: 'up',
|
||||||
|
change: '5.5%',
|
||||||
|
delta: '已付款 / 有效单据'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -127,20 +125,26 @@ export const trendRanges = ['近12天', '近7天', '近30天']
|
|||||||
export const trendSeries = {
|
export const trendSeries = {
|
||||||
'近12天': {
|
'近12天': {
|
||||||
labels: ['07-01', '07-02', '07-03', '07-04', '07-05', '07-06', '07-07', '07-08', '07-09', '07-10', '07-12'],
|
labels: ['07-01', '07-02', '07-03', '07-04', '07-05', '07-06', '07-07', '07-08', '07-09', '07-10', '07-12'],
|
||||||
applications: [140, 105, 175, 195, 155, 70, 65, 60, 185, 200, 220],
|
claimCount: [14, 11, 18, 20, 16, 7, 7, 6, 19, 20, 22],
|
||||||
approved: [110, 85, 130, 125, 110, 60, 55, 50, 145, 150, 170],
|
claimAmount: [38600, 31200, 49600, 55200, 44800, 19600, 20800, 18200, 56300, 60400, 68100],
|
||||||
|
applications: [14, 11, 18, 20, 16, 7, 7, 6, 19, 20, 22],
|
||||||
|
approved: [11, 9, 13, 13, 11, 6, 6, 5, 15, 15, 17],
|
||||||
avgHours: [10, 8, 9, 7, 7, 6.8, 6, 6.5, 7, 8, 7.5]
|
avgHours: [10, 8, 9, 7, 7, 6.8, 6, 6.5, 7, 8, 7.5]
|
||||||
},
|
},
|
||||||
'近7天': {
|
'近7天': {
|
||||||
labels: ['04-23', '04-24', '04-25', '04-26', '04-27', '04-28', '04-29'],
|
labels: ['04-23', '04-24', '04-25', '04-26', '04-27', '04-28', '04-29'],
|
||||||
applications: [72, 68, 109, 121, 134, 142, 128],
|
claimCount: [7, 7, 11, 12, 13, 14, 13],
|
||||||
approved: [58, 54, 92, 101, 116, 121, 110],
|
claimAmount: [22100, 20600, 33800, 36200, 41600, 43800, 39700],
|
||||||
|
applications: [7, 7, 11, 12, 13, 14, 13],
|
||||||
|
approved: [6, 5, 9, 10, 12, 12, 11],
|
||||||
avgHours: [6.9, 6.5, 6.8, 7.1, 7.4, 7.0, 6.8]
|
avgHours: [6.9, 6.5, 6.8, 7.1, 7.4, 7.0, 6.8]
|
||||||
},
|
},
|
||||||
'近30天': {
|
'近30天': {
|
||||||
labels: ['03-31', '04-03', '04-06', '04-09', '04-12', '04-15', '04-18', '04-21', '04-24', '04-27'],
|
labels: ['03-31', '04-03', '04-06', '04-09', '04-12', '04-15', '04-18', '04-21', '04-24', '04-27'],
|
||||||
applications: [82, 90, 96, 114, 120, 111, 126, 132, 119, 138],
|
claimCount: [8, 9, 10, 11, 12, 11, 13, 13, 12, 14],
|
||||||
approved: [68, 76, 80, 95, 100, 93, 102, 110, 101, 117],
|
claimAmount: [24600, 27900, 29200, 35100, 38200, 33600, 40100, 42800, 36500, 44700],
|
||||||
|
applications: [8, 9, 10, 11, 12, 11, 13, 13, 12, 14],
|
||||||
|
approved: [7, 8, 8, 10, 10, 9, 10, 11, 10, 12],
|
||||||
avgHours: [9.2, 8.8, 8.4, 8.0, 7.7, 7.4, 7.2, 6.9, 6.8, 6.7]
|
avgHours: [9.2, 8.8, 8.4, 8.0, 7.7, 7.4, 7.2, 6.9, 6.8, 6.7]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,38 +157,38 @@ export const spendByCategory = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const exceptionMix = [
|
export const exceptionMix = [
|
||||||
{ name: '住宿超标', value: 5, color: 'var(--danger)' },
|
{ name: '已付款', value: 68, color: 'var(--success)' },
|
||||||
{ name: '重复报销', value: 3, color: 'var(--warning)' },
|
{ name: '待付款', value: 18, color: 'var(--chart-amber)' },
|
||||||
{ name: '行程缺失', value: 3, color: 'var(--chart-purple)' },
|
{ name: '审批中', value: 12, color: 'var(--theme-primary)' },
|
||||||
{ name: '发票异常', value: 3, color: 'var(--chart-blue)' }
|
{ name: '已入账', value: 9, color: 'var(--chart-blue)' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const departmentRangeOptions = ['本周', '本月', '本季度']
|
export const departmentRangeOptions = ['本周', '本月', '本季度']
|
||||||
|
|
||||||
export const bottlenecks = [
|
export const bottlenecks = [
|
||||||
{
|
{
|
||||||
name: '李文静',
|
name: '预算超支',
|
||||||
role: '财务经理',
|
role: '预算控制',
|
||||||
duration: '12.4 h',
|
duration: '3 个池',
|
||||||
status: '较慢',
|
status: '¥42.6K',
|
||||||
tone: 'danger',
|
tone: 'danger',
|
||||||
avatar: '李'
|
avatar: '超'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '王志强',
|
name: '待付款',
|
||||||
role: '财务专员',
|
role: '资金计划',
|
||||||
duration: '8.7 h',
|
duration: '¥86.3K',
|
||||||
status: '偏慢',
|
status: '18 单',
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
avatar: '王'
|
avatar: '付'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '刘思雨',
|
name: '高额单据',
|
||||||
role: '费用审核员',
|
role: '费用集中度',
|
||||||
duration: '5.2 h',
|
duration: '¥18.6K',
|
||||||
status: '正常',
|
status: '本期最高',
|
||||||
tone: 'success',
|
tone: 'warning',
|
||||||
avatar: '刘'
|
avatar: '高'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ const FINANCE_DASHBOARD_FALLBACK = {
|
|||||||
spendByCategory: null,
|
spendByCategory: null,
|
||||||
exceptionMix: null,
|
exceptionMix: null,
|
||||||
departmentRanking: null,
|
departmentRanking: null,
|
||||||
|
employeeRanking: null,
|
||||||
|
topClaims: null,
|
||||||
bottlenecks: null,
|
bottlenecks: null,
|
||||||
budgetSummary: null,
|
budgetSummary: null,
|
||||||
|
budgetMetrics: null,
|
||||||
hasRealData: false
|
hasRealData: false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +69,11 @@ function normalizeFinanceDashboardPayload(payload = {}) {
|
|||||||
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
|
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
|
||||||
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
|
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
|
||||||
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
|
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
|
||||||
|
employeeRanking: payload.employee_ranking || payload.employeeRanking || null,
|
||||||
|
topClaims: payload.top_claims || payload.topClaims || null,
|
||||||
bottlenecks: payload.bottlenecks || null,
|
bottlenecks: payload.bottlenecks || null,
|
||||||
budgetSummary: payload.budget_summary || payload.budgetSummary || null
|
budgetSummary: payload.budget_summary || payload.budgetSummary || null,
|
||||||
|
budgetMetrics: payload.budget_metrics || payload.budgetMetrics || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,92 @@ function pickDetailValue(detail, request, keys = [], fallback = '') {
|
|||||||
return normalizeText(fallback)
|
return normalizeText(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTravelApplicationDetail(detail = {}, request = {}) {
|
||||||
|
const typeText = [
|
||||||
|
detail.application_type,
|
||||||
|
detail.applicationType,
|
||||||
|
request.typeCode,
|
||||||
|
request.typeLabel,
|
||||||
|
request.documentTypeLabel
|
||||||
|
].map(normalizeText).join(' ')
|
||||||
|
return /travel_application|差旅|出差/.test(typeText)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEntertainmentApplicationDetail(detail = {}, request = {}) {
|
||||||
|
const typeText = [
|
||||||
|
detail.application_type,
|
||||||
|
detail.applicationType,
|
||||||
|
request.typeCode,
|
||||||
|
request.typeLabel
|
||||||
|
].map(normalizeText).join(' ')
|
||||||
|
return /entertainment|招待/.test(typeText)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDateRange(value) {
|
||||||
|
const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
|
||||||
|
return {
|
||||||
|
startDate: dates[0] || '',
|
||||||
|
endDate: dates[dates.length - 1] || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDayCount(value) {
|
||||||
|
const match = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
|
||||||
|
return match ? Number(match[1]) || 0 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateText, days) {
|
||||||
|
if (!dateText || days <= 1) {
|
||||||
|
return dateText
|
||||||
|
}
|
||||||
|
const date = new Date(`${dateText}T00:00:00`)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return dateText
|
||||||
|
}
|
||||||
|
date.setDate(date.getDate() + days - 1)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApplicationTimeRows(detail, request) {
|
||||||
|
const timeValue = pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
|
||||||
|
if (!isProvided(timeValue)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTravelApplicationDetail(detail, request)) {
|
||||||
|
const days = extractDayCount(pickDetailValue(detail, request, ['days']))
|
||||||
|
const range = extractDateRange(timeValue)
|
||||||
|
const startDate = range.startDate || timeValue
|
||||||
|
const endDate = range.endDate && range.endDate !== range.startDate
|
||||||
|
? range.endDate
|
||||||
|
: addDays(range.startDate, days)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'trip_start_time',
|
||||||
|
label: '出发时间',
|
||||||
|
value: startDate
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'trip_return_time',
|
||||||
|
label: '返回时间',
|
||||||
|
value: endDate
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
label: isEntertainmentApplicationDetail(detail, request) ? '招待时间' : '申请时间',
|
||||||
|
value: timeValue
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export function buildApplicationDetailFactItems(request = {}) {
|
export function buildApplicationDetailFactItems(request = {}) {
|
||||||
const detail = resolveApplicationDetailPayload(request)
|
const detail = resolveApplicationDetailPayload(request)
|
||||||
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
|
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
|
||||||
@@ -39,11 +125,7 @@ export function buildApplicationDetailFactItems(request = {}) {
|
|||||||
label: '申请类型',
|
label: '申请类型',
|
||||||
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
|
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
|
||||||
},
|
},
|
||||||
{
|
...buildApplicationTimeRows(detail, request),
|
||||||
key: 'time',
|
|
||||||
label: '发生时间',
|
|
||||||
value: pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'location',
|
key: 'location',
|
||||||
label: '地点',
|
label: '地点',
|
||||||
@@ -107,6 +189,12 @@ export function buildApplicationDetailFactItems(request = {}) {
|
|||||||
|
|
||||||
export function buildRelatedApplicationFactItems(request = {}) {
|
export function buildRelatedApplicationFactItems(request = {}) {
|
||||||
const related = request.relatedApplication || {}
|
const related = request.relatedApplication || {}
|
||||||
|
const relatedRange = extractDateRange(related.time)
|
||||||
|
const relatedStartDate = normalizeText(related.tripStartDate) || relatedRange.startDate
|
||||||
|
const relatedEndDate = normalizeText(related.tripEndDate) || relatedRange.endDate || addDays(
|
||||||
|
relatedStartDate,
|
||||||
|
extractDayCount(related.days)
|
||||||
|
)
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
key: 'claim_no',
|
key: 'claim_no',
|
||||||
@@ -119,6 +207,16 @@ export function buildRelatedApplicationFactItems(request = {}) {
|
|||||||
label: '申请内容',
|
label: '申请内容',
|
||||||
value: related.content
|
value: related.content
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'trip_start_date',
|
||||||
|
label: '出发时间',
|
||||||
|
value: relatedStartDate
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'trip_end_date',
|
||||||
|
label: '返回时间',
|
||||||
|
value: relatedEndDate
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'days',
|
key: 'days',
|
||||||
label: '申请天数',
|
label: '申请天数',
|
||||||
@@ -135,9 +233,37 @@ export function buildRelatedApplicationFactItems(request = {}) {
|
|||||||
value: related.location
|
value: related.location
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'time',
|
key: 'transport_mode',
|
||||||
label: '申请时间',
|
label: '出行方式',
|
||||||
value: related.time
|
value: related.transportMode
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lodging_daily_cap',
|
||||||
|
label: '住宿上限/天',
|
||||||
|
value: related.lodgingDailyCap,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'subsidy_daily_cap',
|
||||||
|
label: '补贴标准/天',
|
||||||
|
value: related.subsidyDailyCap,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transport_policy',
|
||||||
|
label: '交通费用口径',
|
||||||
|
value: related.transportPolicy
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'policy_estimate',
|
||||||
|
label: '规则测算参考',
|
||||||
|
value: related.policyEstimate,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rule',
|
||||||
|
label: '规则依据',
|
||||||
|
value: related.ruleLabel
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
@@ -145,11 +271,6 @@ export function buildRelatedApplicationFactItems(request = {}) {
|
|||||||
value: related.amountLabel,
|
value: related.amountLabel,
|
||||||
highlight: true,
|
highlight: true,
|
||||||
emphasis: true
|
emphasis: true
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'transport_mode',
|
|
||||||
label: '出行方式',
|
|
||||||
value: related.transportMode
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function buildMockApplicationTransportEstimate({
|
|||||||
simulatedLatencyMs,
|
simulatedLatencyMs,
|
||||||
source: 'mock_ticket_price_query_v1',
|
source: 'mock_ticket_price_query_v1',
|
||||||
confidence: 'mock',
|
confidence: 'mock',
|
||||||
basisText: `已查询 ${queryLabel} ${mode}参考票价,按${bandLabel}往返 ${amountDisplay}元估算(查询耗时 ${simulatedLatencyMs}ms)`
|
basisText: `预估交通费用 ${amountDisplay}元`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ export function resolveExpenseTypeCode(ontology) {
|
|||||||
return String(entity?.normalized_value || entity?.value || 'other').trim() || 'other'
|
return String(entity?.normalized_value || entity?.value || 'other').trim() || 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeStructuredTravelApplication(prompt) {
|
||||||
|
const text = String(prompt || '')
|
||||||
|
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(text)
|
||||||
|
&& /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(text)
|
||||||
|
&& /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApplicationExpenseTypeCode(ontology, prompt) {
|
||||||
|
const code = resolveExpenseTypeCode(ontology)
|
||||||
|
if (code !== 'other') return code
|
||||||
|
return looksLikeStructuredTravelApplication(prompt) ? 'travel' : code
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveExpenseTypeLabel(code) {
|
export function resolveExpenseTypeLabel(code) {
|
||||||
return EXPENSE_TYPE_LABELS[String(code || '').trim()] || EXPENSE_TYPE_LABELS.other
|
return EXPENSE_TYPE_LABELS[String(code || '').trim()] || EXPENSE_TYPE_LABELS.other
|
||||||
}
|
}
|
||||||
@@ -358,7 +371,7 @@ export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
|
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
|
||||||
const expenseTypeCode = resolveExpenseTypeCode(ontology)
|
const expenseTypeCode = resolveApplicationExpenseTypeCode(ontology, prompt)
|
||||||
const amount = resolveApplicationAmount(ontology)
|
const amount = resolveApplicationAmount(ontology)
|
||||||
const documentTypeEntity = resolveEntity(ontology, 'document_type')
|
const documentTypeEntity = resolveEntity(ontology, 'document_type')
|
||||||
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
|
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
|||||||
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
||||||
|
|
||||||
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
||||||
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核'
|
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
|
||||||
|
|
||||||
export function resolveApplicationTimeLabel(applicationType = '') {
|
export function resolveApplicationTimeLabel(applicationType = '') {
|
||||||
const label = String(applicationType || '').trim()
|
const label = String(applicationType || '').trim()
|
||||||
if (/差旅|出差/.test(label)) return '行程时间'
|
if (/差旅|出差/.test(label)) return '出发时间'
|
||||||
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
|
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
|
||||||
return '申请时间'
|
return '申请时间'
|
||||||
}
|
}
|
||||||
@@ -47,10 +47,36 @@ function resolveApplicationFieldLabel(item, fields = {}) {
|
|||||||
return item.label
|
return item.label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTravelApplicationType(applicationType = '') {
|
||||||
|
return /差旅|出差/.test(String(applicationType || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApplicationTripDateParts(fields = {}) {
|
||||||
|
const timeText = String(fields.time || '').trim()
|
||||||
|
const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||||
|
const startDate = normalizeDateText(matchedDates[0] || timeText)
|
||||||
|
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||||
|
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||||
|
? explicitEndDate
|
||||||
|
: buildEndDateFromDays(startDate, fields.days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate,
|
||||||
|
endDate: inferredEndDate || explicitEndDate || startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function compactText(value) {
|
function compactText(value) {
|
||||||
return String(value || '').replace(/\s+/g, '')
|
return String(value || '').replace(/\s+/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeStructuredTravelApplication(text) {
|
||||||
|
const source = String(text || '')
|
||||||
|
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(source)
|
||||||
|
&& /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(source)
|
||||||
|
&& /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
|
||||||
|
}
|
||||||
|
|
||||||
function resolveFirstMatch(text, patterns = []) {
|
function resolveFirstMatch(text, patterns = []) {
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = text.match(pattern)
|
const match = text.match(pattern)
|
||||||
@@ -106,6 +132,7 @@ function resolvePreviewToday(options = {}) {
|
|||||||
|
|
||||||
function resolveApplicationType(text) {
|
function resolveApplicationType(text) {
|
||||||
const compact = compactText(text)
|
const compact = compactText(text)
|
||||||
|
if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
|
||||||
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
|
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
|
||||||
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
|
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
|
||||||
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
|
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
|
||||||
@@ -224,7 +251,7 @@ function buildTransportPolicyText(transportMode, location = '', transportEstimat
|
|||||||
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||||
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
||||||
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||||
return `${estimate.basisText},报销阶段按真实票据复核`
|
return estimate.basisText
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureApplicationPolicyFields(fields = {}) {
|
function ensureApplicationPolicyFields(fields = {}) {
|
||||||
@@ -437,9 +464,8 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
|
|||||||
allowanceAmount: result?.allowance_amount
|
allowanceAmount: result?.allowance_amount
|
||||||
})
|
})
|
||||||
const transportEstimate = systemEstimate.transportEstimate
|
const transportEstimate = systemEstimate.transportEstimate
|
||||||
const queryLabel = transportEstimate?.queryDate || '出行日期待确认'
|
|
||||||
const transportText = transportEstimate
|
const transportText = transportEstimate
|
||||||
? `交通 ${systemEstimate.transportAmountDisplay}元(按 ${queryLabel} 参考票价) + `
|
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
|
||||||
: ''
|
: ''
|
||||||
const totalAmount = systemEstimate.totalAmountDisplay
|
const totalAmount = systemEstimate.totalAmountDisplay
|
||||||
const amount = totalAmount ? `${totalAmount}元` : fields.amount
|
const amount = totalAmount ? `${totalAmount}元` : fields.amount
|
||||||
@@ -499,7 +525,6 @@ export function refreshApplicationPreviewTransportEstimate(preview = {}) {
|
|||||||
const hotelAmount = formatPolicyMoney(hotelAmountSource)
|
const hotelAmount = formatPolicyMoney(hotelAmountSource)
|
||||||
const allowanceAmount = formatPolicyMoney(allowanceAmountSource)
|
const allowanceAmount = formatPolicyMoney(allowanceAmountSource)
|
||||||
const hasPolicyAmounts = parseMoneyNumber(hotelAmountSource) > 0 || parseMoneyNumber(allowanceAmountSource) > 0
|
const hasPolicyAmounts = parseMoneyNumber(hotelAmountSource) > 0 || parseMoneyNumber(allowanceAmountSource) > 0
|
||||||
const queryLabel = transportEstimate.queryDate || '出行日期待确认'
|
|
||||||
const nextFields = {
|
const nextFields = {
|
||||||
...fields,
|
...fields,
|
||||||
transportPolicy: buildTransportPolicyText(fields.transportMode, location, transportEstimate, fields.time),
|
transportPolicy: buildTransportPolicyText(fields.transportMode, location, transportEstimate, fields.time),
|
||||||
@@ -513,7 +538,7 @@ export function refreshApplicationPreviewTransportEstimate(preview = {}) {
|
|||||||
if (hasPolicyAmounts) {
|
if (hasPolicyAmounts) {
|
||||||
const days = Number(policyResult.days) || parseApplicationDaysValue(fields.days) || 1
|
const days = Number(policyResult.days) || parseApplicationDaysValue(fields.days) || 1
|
||||||
const totalAmount = systemEstimate.totalAmountDisplay
|
const totalAmount = systemEstimate.totalAmountDisplay
|
||||||
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}元(按 ${queryLabel} 参考票价) + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
|
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}元 + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
|
||||||
nextFields.amount = totalAmount ? `${totalAmount}元` : nextFields.amount
|
nextFields.amount = totalAmount ? `${totalAmount}元` : nextFields.amount
|
||||||
nextFields.policyTotalAmount = totalAmount ? `${totalAmount}元` : ''
|
nextFields.policyTotalAmount = totalAmount ? `${totalAmount}元` : ''
|
||||||
}
|
}
|
||||||
@@ -639,17 +664,41 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
|
|||||||
export function buildApplicationPreviewRows(preview = {}) {
|
export function buildApplicationPreviewRows(preview = {}) {
|
||||||
const normalized = normalizeApplicationPreview(preview)
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
const fields = normalized.fields || {}
|
const fields = normalized.fields || {}
|
||||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.map((item) => {
|
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.flatMap((item) => {
|
||||||
|
if (item.key === 'time' && isTravelApplicationType(fields.applicationType)) {
|
||||||
|
const tripDates = resolveApplicationTripDateParts(fields)
|
||||||
|
const rawValue = fields[item.key]
|
||||||
|
const missing = item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
label: '出发时间',
|
||||||
|
value: tripDates.startDate || '待补充',
|
||||||
|
editable: item.editable !== false,
|
||||||
|
highlight: Boolean(item.highlight),
|
||||||
|
missing
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time_return',
|
||||||
|
label: '返回时间',
|
||||||
|
value: tripDates.endDate || '待补充',
|
||||||
|
editable: false,
|
||||||
|
highlight: Boolean(item.highlight),
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const rawValue = fields[item.key]
|
const rawValue = fields[item.key]
|
||||||
const value = String(rawValue || '').trim() || '待补充'
|
const value = String(rawValue || '').trim() || '待补充'
|
||||||
return {
|
return [{
|
||||||
...item,
|
...item,
|
||||||
label: resolveApplicationFieldLabel(item, fields),
|
label: resolveApplicationFieldLabel(item, fields),
|
||||||
value,
|
value,
|
||||||
editable: item.editable !== false,
|
editable: item.editable !== false,
|
||||||
highlight: Boolean(item.highlight),
|
highlight: Boolean(item.highlight),
|
||||||
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
||||||
}
|
}]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="login-page">
|
<main class="login-page">
|
||||||
<section class="login-visual" aria-label="智能费用管理运营能力">
|
<header class="page-brand">
|
||||||
<div class="visual-brand">
|
|
||||||
<LogoMark />
|
<LogoMark />
|
||||||
<strong>{{ displayCompanyName }}</strong>
|
<strong>{{ displayCompanyName }}</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<p class="eyebrow-text">Smart Expense Operations</p>
|
||||||
|
<h1>企业报销智能运营台</h1>
|
||||||
|
<p class="hero-lead">让报销审批更智能、更高效</p>
|
||||||
|
<p class="hero-sub">智能审单 · 自动化审批 · 风险预警 · SLA 监控 · 数据驱动决策</p>
|
||||||
|
|
||||||
|
<div class="hero-stage" aria-hidden="true">
|
||||||
|
<span class="flow-line flow-a"></span>
|
||||||
|
<span class="flow-line flow-b"></span>
|
||||||
|
<span class="flow-line flow-c"></span>
|
||||||
|
|
||||||
|
<div class="metric-card amount">
|
||||||
|
<span>报销金额趋势</span>
|
||||||
|
<strong>¥ 61,600</strong>
|
||||||
|
<small>较昨日 <b class="up">+8.3%</b></small>
|
||||||
|
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="visual-copy">
|
<div class="document-card">
|
||||||
<p>智能费用管理</p>
|
<span>报销单</span>
|
||||||
<h1>让企业财务更高效、更合规、更可控</h1>
|
<i></i><i></i><i></i>
|
||||||
<span>以智能化流程驱动费用全生命周期管理,助力企业降本增效,稳健前行。</span>
|
<b class="doc-check"><i class="mdi mdi-check"></i></b>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="visual-feature-list" aria-label="核心能力">
|
<img class="shield-art" src="../assets/security-shield.png" alt="" />
|
||||||
|
|
||||||
|
<div class="round-badge ai">AI</div>
|
||||||
|
|
||||||
|
<div class="metric-card risk">
|
||||||
|
<span>风险预警</span>
|
||||||
|
<strong><i class="mdi mdi-alert"></i> 14 单</strong>
|
||||||
|
<small>较昨日 <b class="danger">+16.7%</b></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card audit">
|
||||||
|
<span>审批效率</span>
|
||||||
|
<strong>78%</strong>
|
||||||
|
<small>较昨日 <b class="up">+6.2%</b></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card sla">
|
||||||
|
<span>SLA 达成率</span>
|
||||||
|
<strong>96%</strong>
|
||||||
|
<small>较昨日 <b class="up">+3.1%</b></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-strip" aria-label="核心能力">
|
||||||
<article v-for="item in features" :key="item.title">
|
<article v-for="item in features" :key="item.title">
|
||||||
<span class="visual-feature-icon">
|
<span :class="item.tone"><i :class="item.icon"></i></span>
|
||||||
<ElIcon><component :is="item.icon" /></ElIcon>
|
|
||||||
</span>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ item.title }}</strong>
|
<strong>{{ item.title }}</strong>
|
||||||
<p>{{ item.desc }}</p>
|
<p>{{ item.desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img class="visual-main-asset" :src="loginMainVisualImage" alt="" aria-hidden="true" />
|
|
||||||
<img class="visual-chart-asset" :src="loginChartPanelsImage" alt="" aria-hidden="true" />
|
|
||||||
|
|
||||||
<footer class="visual-footer">
|
|
||||||
<span>© 2024 智能费用管理平台</span>
|
|
||||||
<i></i>
|
|
||||||
<span>服务热线:400-888-8888</span>
|
|
||||||
</footer>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="login-panel" aria-label="登录表单">
|
<section class="login-card" aria-label="登录表单">
|
||||||
<div class="login-card">
|
|
||||||
<div class="card-brand">
|
<div class="card-brand">
|
||||||
<LogoMark />
|
<LogoMark />
|
||||||
<strong>{{ displayCompanyName }}</strong>
|
<strong>{{ displayCompanyName }}</strong>
|
||||||
@@ -43,110 +71,80 @@
|
|||||||
|
|
||||||
<header class="card-head">
|
<header class="card-head">
|
||||||
<h2>欢迎登录</h2>
|
<h2>欢迎登录</h2>
|
||||||
<p>智能费用管理平台</p>
|
<p>使用员工邮箱或管理员账号进入系统</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="login-form" @submit.prevent="submitLogin">
|
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
||||||
<label class="form-field">
|
<label class="field">
|
||||||
<span class="sr-only">账号</span>
|
<span class="sr-only">账号</span>
|
||||||
<ElInput
|
<i class="mdi mdi-account-outline"></i>
|
||||||
v-model="username"
|
<input v-model="username" type="text" placeholder="请输入员工邮箱 / 管理员账号" autocomplete="username" required />
|
||||||
class="login-input"
|
|
||||||
:prefix-icon="User"
|
|
||||||
autocomplete="username"
|
|
||||||
clearable
|
|
||||||
placeholder="请输入账号"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-field">
|
<label class="field">
|
||||||
<span class="sr-only">密码</span>
|
<span class="sr-only">密码</span>
|
||||||
<ElInput
|
<i class="mdi mdi-lock-outline"></i>
|
||||||
|
<input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
class="login-input"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
:prefix-icon="Lock"
|
placeholder="请输入登录密码"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
placeholder="请输入密码"
|
required
|
||||||
show-password
|
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
class="field-icon-btn"
|
||||||
|
type="button"
|
||||||
|
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
>
|
||||||
|
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-off'"></i>
|
||||||
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-field">
|
<label class="field">
|
||||||
<span class="sr-only">所属企业</span>
|
<span class="sr-only">企业或租户</span>
|
||||||
<ElSelect
|
<i class="mdi mdi-office-building"></i>
|
||||||
v-model="tenant"
|
<select v-model="tenant" class="tenant-select" aria-label="请选择企业或租户">
|
||||||
class="login-select"
|
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
|
||||||
popper-class="login-tenant-popper"
|
</select>
|
||||||
placeholder="请选择所属企业"
|
<span class="field-select-chevron" aria-hidden="true">
|
||||||
:suffix-icon="OfficeBuilding"
|
<i class="mdi mdi-chevron-down"></i>
|
||||||
>
|
</span>
|
||||||
<ElOption
|
|
||||||
v-for="option in tenantOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:label="option.label"
|
|
||||||
:value="option.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="form-meta">
|
<div class="form-meta">
|
||||||
<ElCheckbox v-model="remember" class="login-checkbox">记住账号</ElCheckbox>
|
<label class="remember">
|
||||||
<button type="button" class="link-button" @click="emit('recover-password')">忘记密码?</button>
|
<input v-model="remember" type="checkbox" />
|
||||||
|
<span>记住账号</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
|
||||||
|
|
||||||
<ElButton
|
<button class="submit-btn" type="submit" :disabled="submitting">
|
||||||
class="login-submit"
|
{{ submitting ? '登录中...' : '登录' }}
|
||||||
type="primary"
|
</button>
|
||||||
native-type="submit"
|
|
||||||
:loading="submitting"
|
|
||||||
:disabled="submitting"
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</ElButton>
|
|
||||||
|
|
||||||
<ElButton
|
<div class="divider"><span>或</span></div>
|
||||||
class="login-sso"
|
|
||||||
:icon="Grid"
|
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')">
|
||||||
:disabled="submitting"
|
<i class="mdi mdi-shield-outline"></i>
|
||||||
@click="emit('sso-login')"
|
<span>SSO 单点登录</span>
|
||||||
>
|
</button>
|
||||||
SSO 单点登录
|
|
||||||
</ElButton>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<footer class="security-note">
|
<footer class="security-note">
|
||||||
登录即表示您已阅读并同意
|
<i class="mdi mdi-lock-outline"></i>
|
||||||
<button type="button">《用户协议》</button>
|
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span>
|
||||||
和
|
|
||||||
<button type="button">《隐私政策》</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
|
||||||
import { ElCheckbox } from 'element-plus/es/components/checkbox/index.mjs'
|
|
||||||
import { ElIcon } from 'element-plus/es/components/icon/index.mjs'
|
|
||||||
import { ElInput } from 'element-plus/es/components/input/index.mjs'
|
|
||||||
import { ElOption, ElSelect } from 'element-plus/es/components/select/index.mjs'
|
|
||||||
import {
|
|
||||||
Connection,
|
|
||||||
DataAnalysis,
|
|
||||||
DocumentChecked,
|
|
||||||
Grid,
|
|
||||||
Lock,
|
|
||||||
OfficeBuilding,
|
|
||||||
User
|
|
||||||
} from '@element-plus/icons-vue'
|
|
||||||
|
|
||||||
import loginChartPanelsImage from '../assets/login-reference-chart-panels.png'
|
|
||||||
import loginMainVisualImage from '../assets/login-reference-main-visual.png'
|
|
||||||
import { useLoginView } from '../composables/useLoginView.js'
|
import { useLoginView } from '../composables/useLoginView.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -168,32 +166,7 @@ const emit = defineEmits(['login', 'recover-password', 'sso-login'])
|
|||||||
|
|
||||||
const displayCompanyName = computed(() => props.companyName || '易财费控')
|
const displayCompanyName = computed(() => props.companyName || '易财费控')
|
||||||
|
|
||||||
const {
|
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
|
||||||
features,
|
|
||||||
LogoMark,
|
|
||||||
password,
|
|
||||||
remember,
|
|
||||||
tenant,
|
|
||||||
tenantOptions,
|
|
||||||
username
|
|
||||||
} = useLoginView()
|
|
||||||
|
|
||||||
const featureIconMap = {
|
|
||||||
recognition: DocumentChecked,
|
|
||||||
workflow: Connection,
|
|
||||||
insight: DataAnalysis
|
|
||||||
}
|
|
||||||
|
|
||||||
features.forEach((item) => {
|
|
||||||
item.icon = featureIconMap[item.iconKey] || DocumentChecked
|
|
||||||
})
|
|
||||||
|
|
||||||
function submitLogin() {
|
|
||||||
emit('login', {
|
|
||||||
username: username.value,
|
|
||||||
password: password.value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/views/login-view.css"></style>
|
<style scoped src="../assets/styles/views/login-view.css"></style>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<div class="content-grid top-grid">
|
<div class="content-grid top-grid">
|
||||||
<article class="panel dashboard-card trend-panel">
|
<article class="panel dashboard-card trend-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>每日报销金额 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
<EnterpriseSelect
|
<EnterpriseSelect
|
||||||
v-model="activeTrendRange"
|
v-model="activeTrendRange"
|
||||||
class="card-select"
|
class="card-select"
|
||||||
@@ -37,10 +37,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TrendChart
|
<TrendChart
|
||||||
|
mode="amount"
|
||||||
:labels="activeTrend.labels"
|
:labels="activeTrend.labels"
|
||||||
:applications="activeTrend.applications"
|
:claim-count="activeTrend.claimCount"
|
||||||
:approved="activeTrend.approved"
|
:claim-amount="activeTrend.claimAmount"
|
||||||
:avg-hours="activeTrend.avgHours"
|
/>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel dashboard-card trend-count-panel">
|
||||||
|
<div class="card-head">
|
||||||
|
<h3>每日报销数量 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrendChart
|
||||||
|
mode="count"
|
||||||
|
:labels="activeTrend.labels"
|
||||||
|
:claim-count="activeTrend.claimCount"
|
||||||
|
:claim-amount="activeTrend.claimAmount"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -51,20 +64,12 @@
|
|||||||
<DonutChart :items="spendLegend" :center-value="spendCenterValue" center-label="费用总额" />
|
<DonutChart :items="spendLegend" :center-value="spendCenterValue" center-label="费用总额" />
|
||||||
<p class="panel-note">* 百分比按当前时间范围内的费用金额计算</p>
|
<p class="panel-note">* 百分比按当前时间范围内的费用金额计算</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel dashboard-card donut-panel">
|
|
||||||
<div class="card-head">
|
|
||||||
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
|
|
||||||
</div>
|
|
||||||
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
|
|
||||||
<p class="panel-note">* 近 30 天数据</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-grid bottom-grid">
|
<div class="content-grid bottom-grid">
|
||||||
<article class="panel dashboard-card rank-panel">
|
<article class="panel dashboard-card rank-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>部门报销排行(待处理金额)<i class="mdi mdi-information-outline"></i></h3>
|
<h3>部门报销排行(费用金额)<i class="mdi mdi-information-outline"></i></h3>
|
||||||
<EnterpriseSelect
|
<EnterpriseSelect
|
||||||
v-model="activeDepartmentRange"
|
v-model="activeDepartmentRange"
|
||||||
class="card-select"
|
class="card-select"
|
||||||
@@ -77,33 +82,58 @@
|
|||||||
<BarChart :items="rankedDepartments" />
|
<BarChart :items="rankedDepartments" />
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel dashboard-card bottleneck-panel">
|
<article class="panel dashboard-card employee-rank-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>审批瓶颈(平均处理时长) <i class="mdi mdi-information-outline"></i></h3>
|
<h3>个人报销排行(本月)<i class="mdi mdi-information-outline"></i></h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bottleneck-list">
|
<BarChart :items="rankedEmployees" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel dashboard-card top-claim-panel">
|
||||||
|
<div class="card-head">
|
||||||
|
<h3>本月高额单据 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="top-claim-list">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in bottlenecks"
|
v-for="item in topClaims"
|
||||||
:key="item.name"
|
:key="item.claimNo"
|
||||||
class="bottleneck-row"
|
class="top-claim-row"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.claimNo }}</strong>
|
||||||
|
<span>{{ item.employeeName }} · {{ item.departmentName || '未归属部门' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.amountLabel }}</strong>
|
||||||
|
<span>{{ item.expenseTypeLabel }} · {{ item.statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel dashboard-card budget-metrics-panel">
|
||||||
|
<div class="card-head">
|
||||||
|
<h3>预算指标 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="budget-metric-grid">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in budgetMetrics"
|
||||||
|
:key="item.label"
|
||||||
|
class="budget-metric-item"
|
||||||
|
:class="item.tone"
|
||||||
:style="{ '--delay': `${index * 70}ms` }"
|
:style="{ '--delay': `${index * 70}ms` }"
|
||||||
>
|
>
|
||||||
<div class="reviewer">
|
<span class="budget-metric-icon"><i :class="item.icon"></i></span>
|
||||||
<div class="reviewer-avatar">{{ item.avatar }}</div>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ item.name }}</strong>
|
<span>{{ item.label }}</span>
|
||||||
<span>{{ item.role }}</span>
|
<strong>{{ item.value }}</strong>
|
||||||
</div>
|
<em>{{ item.detail }}</em>
|
||||||
</div>
|
|
||||||
<div class="reviewer-stats">
|
|
||||||
<strong>{{ item.duration }}</strong>
|
|
||||||
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel dashboard-card budget-panel">
|
<article class="panel dashboard-card budget-panel">
|
||||||
@@ -326,7 +356,7 @@ const {
|
|||||||
activeRiskWindowDays,
|
activeRiskWindowDays,
|
||||||
activeTrend,
|
activeTrend,
|
||||||
activeTrendRange,
|
activeTrendRange,
|
||||||
bottlenecks,
|
budgetMetrics,
|
||||||
budgetSummary,
|
budgetSummary,
|
||||||
departmentRangeOptions,
|
departmentRangeOptions,
|
||||||
digitalEmployeeCategoryRows,
|
digitalEmployeeCategoryRows,
|
||||||
@@ -338,16 +368,15 @@ const {
|
|||||||
digitalEmployeeTaskRanking,
|
digitalEmployeeTaskRanking,
|
||||||
kpiMetrics,
|
kpiMetrics,
|
||||||
rankedDepartments,
|
rankedDepartments,
|
||||||
|
rankedEmployees,
|
||||||
riskDashboard,
|
riskDashboard,
|
||||||
riskDashboardError,
|
riskDashboardError,
|
||||||
riskDashboardLoading,
|
riskDashboardLoading,
|
||||||
riskDailyTrendRows,
|
riskDailyTrendRows,
|
||||||
riskLegend,
|
|
||||||
riskKpiMetrics,
|
riskKpiMetrics,
|
||||||
riskLevelLegend,
|
riskLevelLegend,
|
||||||
riskSignalRanking,
|
riskSignalRanking,
|
||||||
riskSourceLegend,
|
riskSourceLegend,
|
||||||
riskTotal,
|
|
||||||
riskWindowOptions,
|
riskWindowOptions,
|
||||||
setRiskWindowDays,
|
setRiskWindowDays,
|
||||||
spendCenterValue,
|
spendCenterValue,
|
||||||
@@ -362,6 +391,7 @@ const {
|
|||||||
systemUsageDurationRows,
|
systemUsageDurationRows,
|
||||||
systemUsageDurationSummary,
|
systemUsageDurationSummary,
|
||||||
systemUserTokenUsage,
|
systemUserTokenUsage,
|
||||||
|
topClaims,
|
||||||
trendRanges
|
trendRanges
|
||||||
} = useOverviewView(props)
|
} = useOverviewView(props)
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default {
|
|||||||
const rows = demoDepartments
|
const rows = demoDepartments
|
||||||
const max = Math.max(...rows.map((item) => item.amount), 1)
|
const max = Math.max(...rows.map((item) => item.amount), 1)
|
||||||
|
|
||||||
return rows.slice(0, 5).map((item, index) => ({
|
return rows.slice(0, 6).map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
shortName: item.name,
|
shortName: item.name,
|
||||||
|
|||||||
@@ -83,13 +83,38 @@ function normalizeApplicationDateText(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeApplicationBusinessTime(claim) {
|
function normalizeApplicationBusinessTime(claim) {
|
||||||
const start = normalizeApplicationDateText(claim?.start_date || claim?.startDate || claim?.begin_date || claim?.beginDate)
|
const detail = resolveApplicationDetailPayload(claim)
|
||||||
const end = normalizeApplicationDateText(claim?.end_date || claim?.endDate || claim?.finish_date || claim?.finishDate)
|
const start = normalizeApplicationDateText(
|
||||||
|
detail.start_date
|
||||||
|
|| detail.startDate
|
||||||
|
|| detail.departure_date
|
||||||
|
|| detail.departureDate
|
||||||
|
|| claim?.start_date
|
||||||
|
|| claim?.startDate
|
||||||
|
|| claim?.begin_date
|
||||||
|
|| claim?.beginDate
|
||||||
|
)
|
||||||
|
const end = normalizeApplicationDateText(
|
||||||
|
detail.end_date
|
||||||
|
|| detail.endDate
|
||||||
|
|| detail.return_date
|
||||||
|
|| detail.returnDate
|
||||||
|
|| claim?.end_date
|
||||||
|
|| claim?.endDate
|
||||||
|
|| claim?.finish_date
|
||||||
|
|| claim?.finishDate
|
||||||
|
)
|
||||||
if (start && end && start !== end) {
|
if (start && end && start !== end) {
|
||||||
return `${start} 至 ${end}`
|
return `${start} 至 ${end}`
|
||||||
}
|
}
|
||||||
return normalizeApplicationDateText(
|
return normalizeApplicationDateText(
|
||||||
start
|
start
|
||||||
|
|| detail.application_business_time
|
||||||
|
|| detail.applicationBusinessTime
|
||||||
|
|| detail.business_time
|
||||||
|
|| detail.businessTime
|
||||||
|
|| detail.time_range
|
||||||
|
|| detail.timeRange
|
||||||
|| claim?.business_time
|
|| claim?.business_time
|
||||||
|| claim?.businessTime
|
|| claim?.businessTime
|
||||||
|| claim?.time_range
|
|| claim?.time_range
|
||||||
@@ -101,6 +126,21 @@ function normalizeApplicationBusinessTime(claim) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveApplicationDetailPayload(claim) {
|
||||||
|
const flags = Array.isArray(claim?.risk_flags_json)
|
||||||
|
? claim.risk_flags_json
|
||||||
|
: Array.isArray(claim?.riskFlags)
|
||||||
|
? claim.riskFlags
|
||||||
|
: []
|
||||||
|
const detailFlag = flags.find((flag) => (
|
||||||
|
flag &&
|
||||||
|
typeof flag === 'object' &&
|
||||||
|
normalizeLower(flag.source) === 'application_detail'
|
||||||
|
))
|
||||||
|
const detail = detailFlag?.application_detail || detailFlag?.applicationDetail || {}
|
||||||
|
return detail && typeof detail === 'object' ? detail : {}
|
||||||
|
}
|
||||||
|
|
||||||
function toTimestamp(value) {
|
function toTimestamp(value) {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
||||||
@@ -231,20 +271,51 @@ export function isUsableRequiredApplicationClaim(claim) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRequiredApplicationCandidate(claim) {
|
export function normalizeRequiredApplicationCandidate(claim) {
|
||||||
|
const detail = resolveApplicationDetailPayload(claim)
|
||||||
const claimNo = normalizeText(claim?.claim_no || claim?.claimNo)
|
const claimNo = normalizeText(claim?.claim_no || claim?.claimNo)
|
||||||
const location = normalizeText(claim?.location || claim?.business_location || claim?.businessLocation)
|
const location = normalizeText(
|
||||||
const amountText = formatAmount(claim?.amount || claim?.budget_amount || claim?.budgetAmount)
|
detail.location
|
||||||
|
|| detail.application_location
|
||||||
|
|| claim?.location
|
||||||
|
|| claim?.business_location
|
||||||
|
|| claim?.businessLocation
|
||||||
|
)
|
||||||
|
const amount = normalizeText(
|
||||||
|
detail.amount
|
||||||
|
|| detail.application_amount
|
||||||
|
|| claim?.amount
|
||||||
|
|| claim?.budget_amount
|
||||||
|
|| claim?.budgetAmount
|
||||||
|
)
|
||||||
|
const amountText = formatAmount(amount)
|
||||||
const status = normalizeApplicationStatus(claim)
|
const status = normalizeApplicationStatus(claim)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
|
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
|
||||||
claim_no: claimNo,
|
claim_no: claimNo,
|
||||||
expense_type: normalizeExpenseType(claim),
|
expense_type: normalizeExpenseType(claim),
|
||||||
reason: normalizeText(claim?.reason || claim?.business_reason || claim?.description || claim?.title),
|
reason: normalizeText(detail.reason || detail.application_reason || claim?.reason || claim?.business_reason || claim?.description || claim?.title),
|
||||||
location,
|
location,
|
||||||
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
|
amount,
|
||||||
amount_label: amountText,
|
amount_label: amountText,
|
||||||
business_time: normalizeApplicationBusinessTime(claim),
|
business_time: normalizeText(
|
||||||
|
detail.application_business_time
|
||||||
|
|| detail.applicationBusinessTime
|
||||||
|
|| detail.business_time
|
||||||
|
|| detail.businessTime
|
||||||
|
|| detail.time_range
|
||||||
|
|| detail.timeRange
|
||||||
|
|| detail.time
|
||||||
|
|| detail.application_time
|
||||||
|
) || normalizeApplicationBusinessTime(claim),
|
||||||
|
days: normalizeText(detail.days || detail.application_days),
|
||||||
|
transport_mode: normalizeText(detail.transport_mode || detail.application_transport_mode),
|
||||||
|
lodging_daily_cap: normalizeText(detail.lodging_daily_cap || detail.application_lodging_daily_cap),
|
||||||
|
subsidy_daily_cap: normalizeText(detail.subsidy_daily_cap || detail.application_subsidy_daily_cap),
|
||||||
|
transport_policy: normalizeText(detail.transport_policy || detail.application_transport_policy),
|
||||||
|
policy_estimate: normalizeText(detail.policy_estimate || detail.application_policy_estimate),
|
||||||
|
rule_name: normalizeText(detail.rule_name || detail.application_rule_name),
|
||||||
|
rule_version: normalizeText(detail.rule_version || detail.application_rule_version),
|
||||||
status,
|
status,
|
||||||
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
|
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
|
||||||
application_date: normalizeApplicationDate(claim)
|
application_date: normalizeApplicationDate(claim)
|
||||||
@@ -296,6 +367,14 @@ export function buildRequiredApplicationActions(applications, actionType) {
|
|||||||
application_amount: application.amount,
|
application_amount: application.amount,
|
||||||
application_amount_label: application.amount_label,
|
application_amount_label: application.amount_label,
|
||||||
application_business_time: application.business_time,
|
application_business_time: application.business_time,
|
||||||
|
application_days: application.days,
|
||||||
|
application_transport_mode: application.transport_mode,
|
||||||
|
application_lodging_daily_cap: application.lodging_daily_cap,
|
||||||
|
application_subsidy_daily_cap: application.subsidy_daily_cap,
|
||||||
|
application_transport_policy: application.transport_policy,
|
||||||
|
application_policy_estimate: application.policy_estimate,
|
||||||
|
application_rule_name: application.rule_name,
|
||||||
|
application_rule_version: application.rule_version,
|
||||||
application_status: application.status,
|
application_status: application.status,
|
||||||
application_status_label: application.status_label,
|
application_status_label: application.status_label,
|
||||||
application_date: application.application_date
|
application_date: application.application_date
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ function normalizeApplicationCandidates(applications) {
|
|||||||
amount: normalizeText(item.amount || item.application_amount),
|
amount: normalizeText(item.amount || item.application_amount),
|
||||||
amount_label: normalizeText(item.amount_label || item.application_amount_label),
|
amount_label: normalizeText(item.amount_label || item.application_amount_label),
|
||||||
business_time: normalizeText(item.business_time || item.application_business_time),
|
business_time: normalizeText(item.business_time || item.application_business_time),
|
||||||
|
days: normalizeText(item.days || item.application_days),
|
||||||
|
transport_mode: normalizeText(item.transport_mode || item.application_transport_mode),
|
||||||
|
lodging_daily_cap: normalizeText(item.lodging_daily_cap || item.application_lodging_daily_cap),
|
||||||
|
subsidy_daily_cap: normalizeText(item.subsidy_daily_cap || item.application_subsidy_daily_cap),
|
||||||
|
transport_policy: normalizeText(item.transport_policy || item.application_transport_policy),
|
||||||
|
policy_estimate: normalizeText(item.policy_estimate || item.application_policy_estimate),
|
||||||
|
rule_name: normalizeText(item.rule_name || item.application_rule_name),
|
||||||
|
rule_version: normalizeText(item.rule_version || item.application_rule_version),
|
||||||
status: normalizeText(item.status || item.application_status),
|
status: normalizeText(item.status || item.application_status),
|
||||||
status_label: normalizeText(item.status_label || item.application_status_label),
|
status_label: normalizeText(item.status_label || item.application_status_label),
|
||||||
application_date: normalizeText(item.application_date)
|
application_date: normalizeText(item.application_date)
|
||||||
@@ -264,6 +272,14 @@ export function selectGuidedRequiredApplication(state, application = {}) {
|
|||||||
application_amount: application.application_amount || application.amount || '',
|
application_amount: application.application_amount || application.amount || '',
|
||||||
application_amount_label: application.application_amount_label || application.amount_label || '',
|
application_amount_label: application.application_amount_label || application.amount_label || '',
|
||||||
application_business_time: application.application_business_time || application.business_time || '',
|
application_business_time: application.application_business_time || application.business_time || '',
|
||||||
|
application_days: application.application_days || application.days || '',
|
||||||
|
application_transport_mode: application.application_transport_mode || application.transport_mode || '',
|
||||||
|
application_lodging_daily_cap: application.application_lodging_daily_cap || application.lodging_daily_cap || '',
|
||||||
|
application_subsidy_daily_cap: application.application_subsidy_daily_cap || application.subsidy_daily_cap || '',
|
||||||
|
application_transport_policy: application.application_transport_policy || application.transport_policy || '',
|
||||||
|
application_policy_estimate: application.application_policy_estimate || application.policy_estimate || '',
|
||||||
|
application_rule_name: application.application_rule_name || application.rule_name || '',
|
||||||
|
application_rule_version: application.application_rule_version || application.rule_version || '',
|
||||||
application_status_label: application.application_status_label || application.status_label || '',
|
application_status_label: application.application_status_label || application.status_label || '',
|
||||||
application_date: application.application_date || ''
|
application_date: application.application_date || ''
|
||||||
}),
|
}),
|
||||||
@@ -412,6 +428,7 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
|||||||
const applicationLocation = values.application_location || ''
|
const applicationLocation = values.application_location || ''
|
||||||
const applicationAmount = values.application_amount || values.application_amount_label || ''
|
const applicationAmount = values.application_amount || values.application_amount_label || ''
|
||||||
const applicationBusinessTime = values.application_business_time || ''
|
const applicationBusinessTime = values.application_business_time || ''
|
||||||
|
const applicationTransportMode = values.application_transport_mode || ''
|
||||||
const fieldLines = []
|
const fieldLines = []
|
||||||
if (linkedApplication) {
|
if (linkedApplication) {
|
||||||
const applicationParts = buildApplicationSummaryParts(values)
|
const applicationParts = buildApplicationSummaryParts(values)
|
||||||
@@ -440,6 +457,7 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
|||||||
business_location: values.location || applicationLocation || '',
|
business_location: values.location || applicationLocation || '',
|
||||||
time_range: values.time_range || applicationBusinessTime || '',
|
time_range: values.time_range || applicationBusinessTime || '',
|
||||||
business_time: values.time_range || applicationBusinessTime || '',
|
business_time: values.time_range || applicationBusinessTime || '',
|
||||||
|
transport_mode: values.transport_mode || applicationTransportMode || '',
|
||||||
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
|
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
|
||||||
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
|
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
|
||||||
application_claim_id: values.application_claim_id || '',
|
application_claim_id: values.application_claim_id || '',
|
||||||
@@ -449,6 +467,14 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
|||||||
application_amount: values.application_amount || '',
|
application_amount: values.application_amount || '',
|
||||||
application_amount_label: values.application_amount_label || '',
|
application_amount_label: values.application_amount_label || '',
|
||||||
application_business_time: values.application_business_time || '',
|
application_business_time: values.application_business_time || '',
|
||||||
|
application_days: values.application_days || '',
|
||||||
|
application_transport_mode: values.application_transport_mode || '',
|
||||||
|
application_lodging_daily_cap: values.application_lodging_daily_cap || '',
|
||||||
|
application_subsidy_daily_cap: values.application_subsidy_daily_cap || '',
|
||||||
|
application_transport_policy: values.application_transport_policy || '',
|
||||||
|
application_policy_estimate: values.application_policy_estimate || '',
|
||||||
|
application_rule_name: values.application_rule_name || '',
|
||||||
|
application_rule_version: values.application_rule_version || '',
|
||||||
application_date: values.application_date || ''
|
application_date: values.application_date || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function buildTransportEstimatePendingPreview(preview = {}) {
|
|||||||
...preview,
|
...preview,
|
||||||
fields: {
|
fields: {
|
||||||
...fields,
|
...fields,
|
||||||
transportPolicy: '正在查询交通参考票价...',
|
transportPolicy: '正在预估交通费用...',
|
||||||
policyEstimate: '正在同步费用测算...',
|
policyEstimate: '正在同步费用测算...',
|
||||||
transportEstimatedAmount: '查询中'
|
transportEstimatedAmount: '查询中'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,8 +288,8 @@ export function useTravelReimbursementGuidedFlow({
|
|||||||
const applicationId = normalizeText(current.values.application_claim_id)
|
const applicationId = normalizeText(current.values.application_claim_id)
|
||||||
const applicationReason = normalizeText(current.values.application_reason)
|
const applicationReason = normalizeText(current.values.application_reason)
|
||||||
const applicationLocation = normalizeText(current.values.application_location)
|
const applicationLocation = normalizeText(current.values.application_location)
|
||||||
const applicationAmount = normalizeText(current.values.application_amount || current.values.application_amount_label)
|
|
||||||
const applicationBusinessTime = normalizeText(current.values.application_business_time)
|
const applicationBusinessTime = normalizeText(current.values.application_business_time)
|
||||||
|
const applicationTransportMode = normalizeText(current.values.application_transport_mode)
|
||||||
if (!originalMessage || !expenseTypeLabel || !applicationNo) {
|
if (!originalMessage || !expenseTypeLabel || !applicationNo) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -326,14 +326,23 @@ export function useTravelReimbursementGuidedFlow({
|
|||||||
business_location: applicationLocation,
|
business_location: applicationLocation,
|
||||||
time_range: applicationBusinessTime,
|
time_range: applicationBusinessTime,
|
||||||
business_time: applicationBusinessTime,
|
business_time: applicationBusinessTime,
|
||||||
amount: applicationAmount,
|
transport_mode: applicationTransportMode,
|
||||||
|
amount: '',
|
||||||
application_claim_id: applicationId,
|
application_claim_id: applicationId,
|
||||||
application_claim_no: applicationNo,
|
application_claim_no: applicationNo,
|
||||||
application_reason: applicationReason,
|
application_reason: applicationReason,
|
||||||
application_location: applicationLocation,
|
application_location: applicationLocation,
|
||||||
application_amount: current.values.application_amount || '',
|
application_amount: current.values.application_amount || '',
|
||||||
application_amount_label: current.values.application_amount_label || '',
|
application_amount_label: current.values.application_amount_label || '',
|
||||||
application_business_time: applicationBusinessTime
|
application_business_time: applicationBusinessTime,
|
||||||
|
application_days: current.values.application_days || '',
|
||||||
|
application_transport_mode: current.values.application_transport_mode || '',
|
||||||
|
application_lodging_daily_cap: current.values.application_lodging_daily_cap || '',
|
||||||
|
application_subsidy_daily_cap: current.values.application_subsidy_daily_cap || '',
|
||||||
|
application_transport_policy: current.values.application_transport_policy || '',
|
||||||
|
application_policy_estimate: current.values.application_policy_estimate || '',
|
||||||
|
application_rule_name: current.values.application_rule_name || '',
|
||||||
|
application_rule_version: current.values.application_rule_version || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,9 +164,10 @@ test('application preview renders ordered editable rows and submit text uses edi
|
|||||||
const rows = buildApplicationPreviewRows(editedPreview)
|
const rows = buildApplicationPreviewRows(editedPreview)
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
rows.map((row) => row.label),
|
rows.map((row) => row.label),
|
||||||
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '行程时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
|
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '出发时间', '返回时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
|
||||||
)
|
)
|
||||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /行程时间:2026-05-25 至 2026-05-28/)
|
assert.match(buildApplicationPreviewSubmitText(editedPreview), /出发时间:2026-05-25/)
|
||||||
|
assert.match(buildApplicationPreviewSubmitText(editedPreview), /返回时间:2026-05-28/)
|
||||||
assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/)
|
assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/)
|
||||||
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
|
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
|
||||||
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
|
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
|
||||||
@@ -212,7 +213,7 @@ test('application estimate builds deterministic mock transport amount and total'
|
|||||||
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
||||||
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
|
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
|
||||||
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
|
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
|
||||||
assert.match(datedTrainEstimate.basisText, /查询耗时 \d+ms/)
|
assert.equal(datedTrainEstimate.basisText, '预估交通费用 1,100元')
|
||||||
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
||||||
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
||||||
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
|
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
|
||||||
@@ -247,16 +248,43 @@ test('application preview uses selected date range and business-specific time la
|
|||||||
const rows = buildApplicationPreviewRows(preview)
|
const rows = buildApplicationPreviewRows(preview)
|
||||||
const submitText = buildApplicationPreviewSubmitText(preview)
|
const submitText = buildApplicationPreviewSubmitText(preview)
|
||||||
|
|
||||||
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '行程时间')
|
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '出发时间')
|
||||||
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||||
assert.equal(preview.fields.days, '4天')
|
assert.equal(preview.fields.days, '4天')
|
||||||
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
|
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
|
||||||
assert.equal(rows.find((row) => row.key === 'time')?.label, '行程时间')
|
assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间')
|
||||||
assert.match(submitText, /行程时间:2026-02-20 至 2026-02-23/)
|
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
|
||||||
|
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
|
||||||
|
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
|
||||||
|
assert.match(submitText, /出发时间:2026-02-20/)
|
||||||
|
assert.match(submitText, /返回时间:2026-02-23/)
|
||||||
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
|
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
|
||||||
assert.doesNotMatch(submitText, /发生时间:/)
|
assert.doesNotMatch(submitText, /发生时间:/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application preview keeps labeled reason in structured travel form', () => {
|
||||||
|
const preview = buildLocalApplicationPreview([
|
||||||
|
'发生时间:2026-02-20 至 2026-02-23',
|
||||||
|
'地点:上海',
|
||||||
|
'事由:支撑国网仿生产环境建设',
|
||||||
|
'天数:4天'
|
||||||
|
].join('\n'), {
|
||||||
|
name: '曹笑竹',
|
||||||
|
grade: 'P5'
|
||||||
|
})
|
||||||
|
const rows = buildApplicationPreviewRows(preview)
|
||||||
|
|
||||||
|
assert.equal(preview.fields.applicationType, '差旅费用申请')
|
||||||
|
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||||
|
assert.equal(preview.fields.location, '上海')
|
||||||
|
assert.equal(preview.fields.reason, '支撑国网仿生产环境建设')
|
||||||
|
assert.equal(preview.fields.days, '4天')
|
||||||
|
assert.equal(rows.find((row) => row.key === 'reason')?.value, '支撑国网仿生产环境建设')
|
||||||
|
assert.equal(rows.find((row) => row.key === 'reason')?.missing, false)
|
||||||
|
assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间')
|
||||||
|
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
|
||||||
|
})
|
||||||
|
|
||||||
test('application preview cleans empty time labels and keeps only business reason', () => {
|
test('application preview cleans empty time labels and keeps only business reason', () => {
|
||||||
const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', {
|
const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', {
|
||||||
name: '李文静',
|
name: '李文静',
|
||||||
@@ -622,7 +650,7 @@ test('application duplicate confirmation flow marks submit step as blocked dupli
|
|||||||
status: 'succeeded',
|
status: 'succeeded',
|
||||||
result: {
|
result: {
|
||||||
answer: [
|
answer: [
|
||||||
'检测到同一申请人、同一申请类型、同一行程时间已存在申请单,系统没有重复创建。',
|
'检测到同一申请人、同一申请类型、同一出发时间已存在申请单,系统没有重复创建。',
|
||||||
'已有申请单号:AP-20260602010101-ABCDEFGH',
|
'已有申请单号:AP-20260602010101-ABCDEFGH',
|
||||||
'当前节点:直属领导审批'
|
'当前节点:直属领导审批'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
@@ -679,9 +707,8 @@ test('application preview merges rule center travel estimate into highlighted ro
|
|||||||
|
|
||||||
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
||||||
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
|
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
|
||||||
assert.match(estimatedPreview.fields.transportPolicy, /参考票价/)
|
assert.equal(estimatedPreview.fields.transportPolicy, '预估交通费用 1,100元')
|
||||||
assert.match(estimatedPreview.fields.transportPolicy, /2026-05-25/)
|
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
||||||
assert.match(estimatedPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
|
||||||
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
|
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
|
||||||
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
|
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
|
||||||
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
|
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
|
||||||
@@ -734,8 +761,8 @@ test('application preview editor refreshes transport estimate after mode change'
|
|||||||
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
||||||
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
|
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
|
||||||
assert.equal(message.applicationPreview.fields.amount, '4,490元')
|
assert.equal(message.applicationPreview.fields.amount, '4,490元')
|
||||||
assert.match(message.applicationPreview.fields.transportPolicy, /已查询 2026-05-25 飞机参考票价/)
|
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
|
||||||
assert.match(message.applicationPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
||||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
||||||
assert.ok(persistCount >= 2)
|
assert.ok(persistCount >= 2)
|
||||||
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
|
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ test('expense application fields use labeled reason and filter resolved missing
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-27')
|
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-27')
|
||||||
|
assert.equal(fields.expenseTypeCode, 'travel')
|
||||||
|
assert.equal(fields.expenseTypeLabel, '差旅费')
|
||||||
assert.equal(fields.location, '上海')
|
assert.equal(fields.location, '上海')
|
||||||
assert.equal(fields.reason, '支撑国网服务器部署')
|
assert.equal(fields.reason, '支撑国网服务器部署')
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
||||||
|
import {
|
||||||
|
buildApplicationDetailFactItems,
|
||||||
|
buildRelatedApplicationFactItems
|
||||||
|
} from '../src/utils/expenseApplicationDetail.js'
|
||||||
|
|
||||||
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
||||||
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
||||||
@@ -110,6 +114,55 @@ test('application claims are mapped as application documents', () => {
|
|||||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
|
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('travel application detail splits trip time into departure and return rows', () => {
|
||||||
|
const request = mapExpenseClaimToRequest({
|
||||||
|
id: 'claim-application-trip-time',
|
||||||
|
claim_no: 'AP-20260602103045-TRIPTIME',
|
||||||
|
employee_name: '张三',
|
||||||
|
department_name: '交付部',
|
||||||
|
manager_name: 'Leader Li',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '支撑国网仿生产环境部署',
|
||||||
|
location: '上海',
|
||||||
|
amount: 3000,
|
||||||
|
invoice_count: 0,
|
||||||
|
occurred_at: '2026-02-20T00:00:00.000Z',
|
||||||
|
submitted_at: '2026-02-20T02:00:00.000Z',
|
||||||
|
created_at: '2026-02-20T01:30:00.000Z',
|
||||||
|
updated_at: '2026-02-20T02:00:00.000Z',
|
||||||
|
status: 'submitted',
|
||||||
|
approval_stage: '直属领导审批',
|
||||||
|
risk_flags_json: [
|
||||||
|
{
|
||||||
|
source: 'application_detail',
|
||||||
|
application_detail: {
|
||||||
|
application_type: '差旅费用申请',
|
||||||
|
time: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '支撑国网仿生产环境部署',
|
||||||
|
days: '4 天',
|
||||||
|
transport_mode: '火车',
|
||||||
|
amount: '3000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const factItems = buildApplicationDetailFactItems(request)
|
||||||
|
assert.deepEqual(
|
||||||
|
factItems
|
||||||
|
.filter((item) => ['trip_start_time', 'trip_return_time'].includes(item.key))
|
||||||
|
.map((item) => [item.label, item.value]),
|
||||||
|
[
|
||||||
|
['出发时间', '2026-02-20'],
|
||||||
|
['返回时间', '2026-02-23']
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert.equal(factItems.some((item) => item.label === '发生时间'), false)
|
||||||
|
assert.equal(factItems.some((item) => item.label === '行程时间'), false)
|
||||||
|
})
|
||||||
|
|
||||||
test('application claims wait for department P8 budget monitor after leader approval', () => {
|
test('application claims wait for department P8 budget monitor after leader approval', () => {
|
||||||
const request = mapExpenseClaimToRequest({
|
const request = mapExpenseClaimToRequest({
|
||||||
id: 'claim-application-budget',
|
id: 'claim-application-budget',
|
||||||
@@ -679,11 +732,17 @@ test('paid reimbursement marks payment progress step as complete', () => {
|
|||||||
application_type: '差旅费用申请',
|
application_type: '差旅费用申请',
|
||||||
application_content: '差旅费用申请 / 北京',
|
application_content: '差旅费用申请 / 北京',
|
||||||
application_reason: '支撑国网仿生产环境部署',
|
application_reason: '支撑国网仿生产环境部署',
|
||||||
application_days: '3 天',
|
application_days: '4 天',
|
||||||
application_location: '北京',
|
application_location: '北京',
|
||||||
application_amount: '3000',
|
application_amount: '3000',
|
||||||
application_time: '2026-05-20T00:00:00.000Z',
|
application_time: '2026-05-20 至 2026-05-23',
|
||||||
application_transport_mode: '高铁'
|
application_transport_mode: '高铁',
|
||||||
|
application_lodging_daily_cap: '600元/天',
|
||||||
|
application_subsidy_daily_cap: '120元/天',
|
||||||
|
application_transport_policy: '按真实票据复核',
|
||||||
|
application_policy_estimate: '交通按真实票据 + 住宿 2,400元 + 补贴 480元',
|
||||||
|
application_rule_name: '差旅标准规则',
|
||||||
|
application_rule_version: '2026.05'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -709,8 +768,36 @@ test('paid reimbursement marks payment progress step as complete', () => {
|
|||||||
assert.equal(linkedStep.time, '已关联 APP-20260520-001')
|
assert.equal(linkedStep.time, '已关联 APP-20260520-001')
|
||||||
assert.equal(request.relatedApplication.claimNo, 'APP-20260520-001')
|
assert.equal(request.relatedApplication.claimNo, 'APP-20260520-001')
|
||||||
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
|
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
|
||||||
assert.equal(request.relatedApplication.days, '3 天')
|
assert.equal(request.relatedApplication.days, '4 天')
|
||||||
|
assert.equal(request.relatedApplication.time, '2026-05-20 至 2026-05-23')
|
||||||
|
assert.equal(request.relatedApplication.tripStartDate, '2026-05-20')
|
||||||
|
assert.equal(request.relatedApplication.tripEndDate, '2026-05-23')
|
||||||
|
assert.equal(request.relatedApplication.transportMode, '高铁')
|
||||||
|
assert.equal(request.relatedApplication.lodgingDailyCap, '600元/天')
|
||||||
|
assert.equal(request.relatedApplication.subsidyDailyCap, '120元/天')
|
||||||
|
assert.equal(request.relatedApplication.transportPolicy, '按真实票据复核')
|
||||||
|
assert.equal(request.relatedApplication.policyEstimate, '交通按真实票据 + 住宿 2,400元 + 补贴 480元')
|
||||||
|
assert.equal(request.relatedApplication.ruleLabel, '差旅标准规则 / 2026.05')
|
||||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||||
|
assert.deepEqual(
|
||||||
|
buildRelatedApplicationFactItems(request).map((item) => [item.label, item.value]),
|
||||||
|
[
|
||||||
|
['关联单据单号', 'APP-20260520-001'],
|
||||||
|
['申请内容', '差旅费用申请 / 北京'],
|
||||||
|
['出发时间', '2026-05-20'],
|
||||||
|
['返回时间', '2026-05-23'],
|
||||||
|
['申请天数', '4 天'],
|
||||||
|
['申请事由', '支撑国网仿生产环境部署'],
|
||||||
|
['申请地点', '北京'],
|
||||||
|
['出行方式', '高铁'],
|
||||||
|
['住宿上限/天', '600元/天'],
|
||||||
|
['补贴标准/天', '120元/天'],
|
||||||
|
['交通费用口径', '按真实票据复核'],
|
||||||
|
['规则测算参考', '交通按真实票据 + 住宿 2,400元 + 补贴 480元'],
|
||||||
|
['规则依据', '差旅标准规则 / 2026.05'],
|
||||||
|
['预计金额', '¥3,000']
|
||||||
|
]
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('reimbursement detail resolves linked application from guided entry context', () => {
|
test('reimbursement detail resolves linked application from guided entry context', () => {
|
||||||
@@ -739,7 +826,14 @@ test('reimbursement detail resolves linked application from guided entry context
|
|||||||
application_reason: '支撑国网仿生产环境部署',
|
application_reason: '支撑国网仿生产环境部署',
|
||||||
application_location: '北京',
|
application_location: '北京',
|
||||||
application_amount: '3000',
|
application_amount: '3000',
|
||||||
application_amount_label: '¥3,000'
|
application_amount_label: '¥3,000',
|
||||||
|
application_business_time: '2026-05-20 至 2026-05-23',
|
||||||
|
application_days: '4 天',
|
||||||
|
application_transport_mode: '高铁',
|
||||||
|
application_lodging_daily_cap: '600元/天',
|
||||||
|
application_subsidy_daily_cap: '120元/天',
|
||||||
|
application_transport_policy: '按真实票据复核',
|
||||||
|
application_policy_estimate: '交通按真实票据 + 住宿 2,400元 + 补贴 480元'
|
||||||
},
|
},
|
||||||
expense_scene_selection: {
|
expense_scene_selection: {
|
||||||
application_claim_no: 'AP-202605-001'
|
application_claim_no: 'AP-202605-001'
|
||||||
@@ -752,7 +846,136 @@ test('reimbursement detail resolves linked application from guided entry context
|
|||||||
assert.equal(request.relatedApplication.claimNo, 'AP-202605-001')
|
assert.equal(request.relatedApplication.claimNo, 'AP-202605-001')
|
||||||
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
|
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
|
||||||
assert.equal(request.relatedApplication.location, '北京')
|
assert.equal(request.relatedApplication.location, '北京')
|
||||||
|
assert.equal(request.relatedApplication.time, '2026-05-20 至 2026-05-23')
|
||||||
|
assert.equal(request.relatedApplication.tripStartDate, '2026-05-20')
|
||||||
|
assert.equal(request.relatedApplication.tripEndDate, '2026-05-23')
|
||||||
|
assert.equal(request.relatedApplication.days, '4 天')
|
||||||
|
assert.equal(request.relatedApplication.transportMode, '高铁')
|
||||||
|
assert.equal(request.relatedApplication.lodgingDailyCap, '600元/天')
|
||||||
|
assert.equal(request.relatedApplication.subsidyDailyCap, '120元/天')
|
||||||
|
assert.equal(request.relatedApplication.policyEstimate, '交通按真实票据 + 住宿 2,400元 + 补贴 480元')
|
||||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||||
|
assert.deepEqual(request.expenseItems, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reimbursement detail hides stale application placeholder and allowance rows before receipts', () => {
|
||||||
|
const request = mapExpenseClaimToRequest({
|
||||||
|
id: 'claim-linked-stale-placeholder',
|
||||||
|
claim_no: 'EXP-20260520-010',
|
||||||
|
employee_name: '张三',
|
||||||
|
department_name: '交付部',
|
||||||
|
expense_type: 'travel',
|
||||||
|
reason: '支撑国网仿生产环境部署',
|
||||||
|
location: '上海',
|
||||||
|
amount: 3480,
|
||||||
|
invoice_count: 0,
|
||||||
|
occurred_at: '2026-02-20T00:00:00.000Z',
|
||||||
|
created_at: '2026-05-20T01:30:00.000Z',
|
||||||
|
updated_at: '2026-05-20T02:00:00.000Z',
|
||||||
|
status: 'draft',
|
||||||
|
approval_stage: '待提交',
|
||||||
|
risk_flags_json: [
|
||||||
|
{
|
||||||
|
source: 'application_link',
|
||||||
|
event_type: 'expense_reimbursement_application_linked',
|
||||||
|
review_form_values: {
|
||||||
|
application_claim_id: 'application-guided-stale',
|
||||||
|
application_claim_no: 'AP-202605-010',
|
||||||
|
application_reason: '支撑国网仿生产环境部署',
|
||||||
|
application_location: '上海',
|
||||||
|
application_amount: '3000',
|
||||||
|
application_amount_label: '¥3,000',
|
||||||
|
application_business_time: '2026-02-20 至 2026-02-23',
|
||||||
|
application_days: '4 天',
|
||||||
|
application_transport_mode: '火车'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'placeholder-travel',
|
||||||
|
item_type: 'travel',
|
||||||
|
item_reason: '支撑国网仿生产环境部署',
|
||||||
|
item_location: '上海',
|
||||||
|
item_amount: 3000,
|
||||||
|
item_date: '2026-02-20',
|
||||||
|
invoice_id: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stale-allowance',
|
||||||
|
item_type: 'travel_allowance',
|
||||||
|
item_reason: '系统自动计算出差补贴:上海市,1天,120.00元/天',
|
||||||
|
item_location: '直辖市/特区',
|
||||||
|
item_amount: 120,
|
||||||
|
item_date: '2026-02-20',
|
||||||
|
invoice_id: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(request.relatedApplication.claimNo, 'AP-202605-010')
|
||||||
|
assert.equal(request.relatedApplication.days, '4 天')
|
||||||
|
assert.equal(request.amount, 0)
|
||||||
|
assert.deepEqual(request.expenseItems, [])
|
||||||
|
assert.equal(request.expenseTableSummary, '暂无费用明细')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reimbursement detail hides stale allowance when linked application days differ', () => {
|
||||||
|
const request = mapExpenseClaimToRequest({
|
||||||
|
id: 'claim-linked-stale-allowance',
|
||||||
|
claim_no: 'EXP-20260520-011',
|
||||||
|
employee_name: '张三',
|
||||||
|
department_name: '交付部',
|
||||||
|
expense_type: 'travel',
|
||||||
|
reason: '支撑国网仿生产环境部署',
|
||||||
|
location: '上海',
|
||||||
|
amount: 474,
|
||||||
|
invoice_count: 1,
|
||||||
|
occurred_at: '2026-02-20T00:00:00.000Z',
|
||||||
|
created_at: '2026-05-20T01:30:00.000Z',
|
||||||
|
updated_at: '2026-05-20T02:00:00.000Z',
|
||||||
|
status: 'draft',
|
||||||
|
approval_stage: '待提交',
|
||||||
|
risk_flags_json: [
|
||||||
|
{
|
||||||
|
source: 'application_link',
|
||||||
|
event_type: 'expense_reimbursement_application_linked',
|
||||||
|
application_claim_no: 'AP-202605-011',
|
||||||
|
application_detail: {
|
||||||
|
application_reason: '支撑国网仿生产环境部署',
|
||||||
|
application_location: '上海',
|
||||||
|
application_time: '2026-02-20 至 2026-02-23',
|
||||||
|
application_days: '4 天',
|
||||||
|
application_transport_mode: '火车'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'outbound-train',
|
||||||
|
item_type: 'train_ticket',
|
||||||
|
item_reason: '武汉-上海',
|
||||||
|
item_location: '上海',
|
||||||
|
item_amount: 354,
|
||||||
|
item_date: '2026-02-20',
|
||||||
|
invoice_id: 'ticket-1.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stale-allowance',
|
||||||
|
item_type: 'travel_allowance',
|
||||||
|
item_reason: '系统自动计算出差补贴:上海市,1天,120.00元/天',
|
||||||
|
item_location: '直辖市/特区',
|
||||||
|
item_amount: 120,
|
||||||
|
item_date: '2026-02-20',
|
||||||
|
invoice_id: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(request.relatedApplication.days, '4 天')
|
||||||
|
assert.deepEqual(request.expenseItems.map((item) => item.id), ['outbound-train'])
|
||||||
|
assert.equal(request.amount, 354)
|
||||||
|
assert.equal(request.expenseTableSummary, '共 1 条费用明细,已关联 1 张票据')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('current direct manager step shows how long the claim has stayed there', () => {
|
test('current direct manager step shows how long the claim has stayed there', () => {
|
||||||
|
|||||||
@@ -232,7 +232,19 @@ test('guided reimbursement requires application selection for travel and enterta
|
|||||||
amount: 1800,
|
amount: 1800,
|
||||||
occurred_at: '2026-05-20T08:00:00Z',
|
occurred_at: '2026-05-20T08:00:00Z',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
created_at: '2026-05-20T08:00:00Z'
|
created_at: '2026-06-02T00:58:00Z',
|
||||||
|
risk_flags_json: [{
|
||||||
|
source: 'application_detail',
|
||||||
|
application_detail: {
|
||||||
|
application_business_time: '2026-05-20 至 2026-05-23',
|
||||||
|
days: '4 天',
|
||||||
|
transport_mode: '火车',
|
||||||
|
lodging_daily_cap: '600元/天',
|
||||||
|
subsidy_daily_cap: '120元/天',
|
||||||
|
transport_policy: '按真实票据复核',
|
||||||
|
policy_estimate: '住宿 2,400元 + 补贴 480元'
|
||||||
|
}
|
||||||
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'app-meal',
|
id: 'app-meal',
|
||||||
@@ -285,6 +297,11 @@ test('guided reimbursement requires application selection for travel and enterta
|
|||||||
assert.equal(state.stepKey, 'summary')
|
assert.equal(state.stepKey, 'summary')
|
||||||
assert.equal(isGuidedReimbursementReadyForReview(state), true)
|
assert.equal(isGuidedReimbursementReadyForReview(state), true)
|
||||||
assert.equal(state.values.application_claim_no, 'AP-202605-001')
|
assert.equal(state.values.application_claim_no, 'AP-202605-001')
|
||||||
|
assert.equal(state.values.application_business_time, '2026-05-20 至 2026-05-23')
|
||||||
|
assert.equal(state.values.application_days, '4 天')
|
||||||
|
assert.equal(state.values.application_transport_mode, '火车')
|
||||||
|
assert.equal(state.values.application_lodging_daily_cap, '600元/天')
|
||||||
|
assert.equal(state.values.application_subsidy_daily_cap, '120元/天')
|
||||||
const summaryText = buildGuidedReimbursementSummaryText(state)
|
const summaryText = buildGuidedReimbursementSummaryText(state)
|
||||||
assert.match(summaryText, /关联申请单:AP-202605-001/)
|
assert.match(summaryText, /关联申请单:AP-202605-001/)
|
||||||
assert.match(summaryText, /草稿详情中上传对应票据/)
|
assert.match(summaryText, /草稿详情中上传对应票据/)
|
||||||
@@ -297,7 +314,12 @@ test('guided reimbursement requires application selection for travel and enterta
|
|||||||
assert.equal(submitOptions.extraContext.review_form_values.business_location, '上海')
|
assert.equal(submitOptions.extraContext.review_form_values.business_location, '上海')
|
||||||
assert.equal(submitOptions.extraContext.review_form_values.amount, '')
|
assert.equal(submitOptions.extraContext.review_form_values.amount, '')
|
||||||
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800')
|
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800')
|
||||||
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20')
|
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20 至 2026-05-23')
|
||||||
|
assert.equal(submitOptions.extraContext.review_form_values.application_days, '4 天')
|
||||||
|
assert.equal(submitOptions.extraContext.review_form_values.transport_mode, '火车')
|
||||||
|
assert.equal(submitOptions.extraContext.review_form_values.application_transport_mode, '火车')
|
||||||
|
assert.equal(submitOptions.extraContext.review_form_values.application_lodging_daily_cap, '600元/天')
|
||||||
|
assert.equal(submitOptions.extraContext.review_form_values.application_subsidy_daily_cap, '120元/天')
|
||||||
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
|
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
|
||||||
assert.match(submitOptions.rawText, /关联申请单:AP-202605-001/)
|
assert.match(submitOptions.rawText, /关联申请单:AP-202605-001/)
|
||||||
assert.doesNotMatch(submitOptions.rawText, /事由:待补充/)
|
assert.doesNotMatch(submitOptions.rawText, /事由:待补充/)
|
||||||
@@ -377,6 +399,8 @@ test('guided flow is local until final confirmation or collected query handoff',
|
|||||||
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
|
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
|
||||||
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/)
|
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/)
|
||||||
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/)
|
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/)
|
||||||
|
assert.doesNotMatch(guidedFlowScript, /amount:\s*applicationAmount/)
|
||||||
|
assert.match(guidedFlowScript, /amount:\s*''/)
|
||||||
assert.match(guidedFlowScript, /if \(!applications\.length\) \{[\s\S]*guidedFlowState\.value = createEmptyGuidedFlowState\(\)[\s\S]*meta: \['缺少可关联申请单'\][\s\S]*\}\)/)
|
assert.match(guidedFlowScript, /if \(!applications\.length\) \{[\s\S]*guidedFlowState\.value = createEmptyGuidedFlowState\(\)[\s\S]*meta: \['缺少可关联申请单'\][\s\S]*\}\)/)
|
||||||
assert.doesNotMatch(guidedFlowScript, /meta: \['缺少可关联申请单'\],[\s\S]{0,120}suggestedActions: buildGuidedExpenseTypeActions\(\)/)
|
assert.doesNotMatch(guidedFlowScript, /meta: \['缺少可关联申请单'\],[\s\S]{0,120}suggestedActions: buildGuidedExpenseTypeActions\(\)/)
|
||||||
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
|
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
|
||||||
|
|||||||
Reference in New Issue
Block a user