feat: 财务看板口径重构与半年模拟数据及报销状态注册表

- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 16:22:59 +08:00
parent ca691f3ee0
commit 0c74b4ab4a
54 changed files with 6810 additions and 1238 deletions

View 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 趋势和自定义日期范围。
- 如果后续要支持页面一键生成,需要另行设计权限、审计和清理机制。

View 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`

View 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、构建和容器内测试证据。

View 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`
- 待开发:如确认要数字状态码,新增状态字典/状态码迁移方案。

View 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`

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

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

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

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

View File

@@ -17,5 +17,8 @@ class FinanceDashboardRead(BaseModel):
spend_by_category: list[dict[str, Any]] = Field(default_factory=list)
exception_mix: list[dict[str, Any]] = Field(default_factory=list)
department_ranking: list[dict[str, Any]] = Field(default_factory=list)
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)
budget_summary: dict[str, Any] = Field(default_factory=dict)
budget_metrics: list[dict[str, Any]] = Field(default_factory=list)

View File

@@ -163,24 +163,13 @@ def build_application_system_estimate(
lodging_display = format_application_money(lodging)
allowance_display = format_application_money(allowance)
total_display = format_application_money(total_amount)
band_label = {
"premium": "一线/高频城市",
"remote": "远途地区",
"coastal": "沿海城市",
"default": "普通城市",
}[location_band]
query_label = query_date or "出行日期待确认"
return {
"amount": f"{total_display}",
"lodging_daily_cap": f"{format_application_money(lodging_daily)}元/天",
"subsidy_daily_cap": f"{format_application_money(allowance_daily)}元/天",
"transport_policy": (
f"已查询 {query_label} {mode}参考票价,按{band_label}往返 {transport_display}元预估"
f"(查询耗时 {simulated_latency_ms}ms报销阶段按真实票据复核"
),
"transport_policy": f"预估交通费用 {transport_display}",
"policy_estimate": (
f"交通 {transport_display}(按 {query_label} 参考票价) + 住宿 {lodging_display}"
f"交通 {transport_display}元 + 住宿 {lodging_display}"
f" + 补贴 {allowance_display}元 = {total_display}元({days}天)"
),
"matched_city": str(location or "").strip(),

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

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

View 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}",
)

View File

@@ -31,7 +31,12 @@ BUDGET_MONITOR_APPROVAL_GRADE = "P8"
CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
ARCHIVED_REIMBURSEMENT_STAGES = (ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE, "completed")
ARCHIVED_REIMBURSEMENT_STAGES = (
ARCHIVE_ACCOUNTING_STAGE,
PAYMENT_PAID_STAGE,
"payment",
"completed",
)
class ExpenseClaimAccessPolicy:
@@ -640,9 +645,23 @@ class ExpenseClaimAccessPolicy:
include_approval_scope: bool = False,
) -> Any:
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:
role_codes = self.normalize_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes:
conditions.append(ExpenseClaim.status.in_(("submitted", PAYMENT_PENDING_STATUS, "returned")))
elif "finance" in role_codes:

View File

@@ -64,6 +64,12 @@ class ExpenseClaimApplicationHandoffMixin:
"application_amount": application_amount,
"application_time": application_time,
"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(

View File

@@ -327,6 +327,10 @@ class ExpenseClaimDraftFlowMixin:
)
self._sync_claim_from_items(claim)
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)
else:
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_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(
self,
*,
@@ -405,23 +464,10 @@ class ExpenseClaimDraftFlowMixin:
) -> bool:
if document_specs or attachment_count > 0:
return False
if claim is not None and list(claim.items or []):
return False
if self._build_application_link_flag(context_json) is None:
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 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
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_reason = pick("application_reason", "applicationReason", "reason")
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_label = pick("application_status_label", "applicationStatusLabel")
@@ -517,7 +582,17 @@ class ExpenseClaimDraftFlowMixin:
"application_location": application_location,
"application_amount": application_amount,
"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,
"expense_scene_selection": scene_selection,

View File

@@ -158,21 +158,139 @@ class ExpenseClaimItemSyncMixin:
end_date = start_date
days = (end_date - start_date).days + 1
application_days = self._resolve_travel_allowance_days_from_application_link(claim)
explicit_days = max(
(self._extract_travel_day_count(item.item_reason) for item in business_items),
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:
days = explicit_days
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
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:
days = existing_days
end_date = start_date + timedelta(days=days - 1)
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
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
if item is None:

View File

@@ -314,7 +314,13 @@ class ExpenseClaimOntologyResolverMixin:
) -> datetime | None:
review_form_values = context_json.get("review_form_values")
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()
if not value:
continue
@@ -322,7 +328,9 @@ class ExpenseClaimOntologyResolverMixin:
parsed = date.fromisoformat(value)
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
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
if start_date:
@@ -333,6 +341,21 @@ class ExpenseClaimOntologyResolverMixin:
pass
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
def _resolve_amount(
entities: list[OntologyEntity],

View 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

View File

@@ -12,9 +12,9 @@ from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.budget import BudgetAllocation
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.services.budget_support import BudgetSupportMixin
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
SLA_TARGET_HOURS = Decimal("8.0")
@@ -30,6 +30,17 @@ PENDING_STATUSES = {
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
EXPENSE_TYPE_ALIASES = {
"travel_application": "travel",
"business_travel": "travel",
"trip": "travel",
"traffic": "travel",
"transportation": "travel",
"hotel": "travel",
"accommodation": "travel",
"business_meal": "meal",
"communication_fee": "communication",
}
CHART_COLORS = [
"var(--theme-primary)",
"var(--chart-blue)",
@@ -55,6 +66,17 @@ RISK_SIGNAL_LABELS = {
"location_mismatch": "地点不一致",
"amount_outlier": "金额异常",
"preapproval_absent": "缺少事前申请",
"missing_material": "材料不完整",
"budget_pressure": "预算压力偏高",
"budget_overrun": "预算超支",
"budget_warning": "预算预警",
"over_budget": "预算超支",
"invoice_abnormal": "发票异常",
"invoice_missing": "缺少发票",
"missing_invoice": "缺少发票",
"policy_violation": "政策不符",
"abnormal_frequency": "频次异常",
"manual_review": "人工复核",
}
@@ -83,31 +105,34 @@ class FinanceDashboardService(BudgetSupportMixin):
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
department_start, department_end = self._resolve_department_scope(department_range, now)
claims = self._fetch_claims()
observations = self._fetch_risk_observations()
claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
]
scope_claims = self._claims_between(claims, start, end)
previous_claims = self._claims_between(claims, previous_start, start)
trend_claims = self._claims_between(claims, trend_start, trend_end)
department_claims = self._claims_between(claims, department_start, department_end)
scope_observations = self._observations_between(observations, start, end)
totals = self._totals(scope_claims, scope_observations, now)
previous_totals = self._totals(previous_claims, [], now)
totals = self._totals(scope_claims)
previous_totals = self._totals(previous_claims)
return FinanceDashboardRead(
range_key=resolved_key,
start_date=start.date().isoformat(),
end_date=(end - timedelta(days=1)).date().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,
metric_meta=self._metric_meta(totals, previous_totals),
trend=self._trend(trend_labels, trend_claims, now),
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),
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_metrics=self._budget_metrics(now.year),
)
def _ensure_storage_ready(self) -> None:
@@ -117,10 +142,6 @@ class FinanceDashboardService(BudgetSupportMixin):
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
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]:
stmt = (
select(BudgetAllocation)
@@ -192,50 +213,49 @@ class FinanceDashboardService(BudgetSupportMixin):
) -> list[ExpenseClaim]:
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(
self,
claims: list[ExpenseClaim],
observations: list[RiskObservation],
now: datetime,
) -> dict[str, Any]:
active_claims = [claim for claim in claims if self._status(claim) not in {"draft", "deleted"}]
pending_claims = [claim for claim in active_claims if self._status(claim) in PENDING_STATUSES]
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)}
observation_keys = {
str(item.claim_no or item.subject_key or item.id).strip()
for item in observations
if str(item.status or "").strip().lower() != "false_positive"
}
sla_hours = [self._claim_sla_hours(claim, now) for claim in active_claims if claim.submitted_at]
sla_met = sum(1 for hours in sla_hours if hours <= SLA_TARGET_HOURS)
clean_success = sum(1 for claim in success_claims if not self._has_claim_risk(claim))
active_claims = [
claim for claim in claims if self._status(claim) not in {"draft", "deleted"}
]
spend_claims = [
claim for claim in active_claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
]
pending_payment_claims = [
claim for claim in spend_claims if self._status(claim) == "pending_payment"
]
paid_claims = [claim for claim in spend_claims if self._status(claim) == "paid"]
total_amount = sum((self._claim_amount(claim) for claim in spend_claims), Decimal("0.00"))
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 {
"pendingCount": len(pending_claims),
"pendingAmount": self._decimal_number(sum((self._claim_amount(claim) for claim in pending_claims), Decimal("0.00"))),
"avgSla": self._decimal_number(self._average(sla_hours)),
"autoPassRate": self._percent(clean_success, len(active_claims)),
"riskCount": len({key for key in risk_claim_keys | observation_keys if key}),
"slaRate": self._percent(sla_met, len(sla_hours)),
"reimbursementAmount": self._decimal_number(total_amount),
"reimbursementCount": len(spend_claims),
"pendingPaymentAmount": self._decimal_number(pending_payment_amount),
"avgClaimAmount": self._decimal_number(avg_amount),
"budgetUsageRate": float(budget_summary.get("ratio") or 0),
"paymentClearanceRate": self._percent(len(paid_claims), len(spend_claims)),
}
def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
unit_by_key = {
"pendingCount": "",
"pendingAmount": "",
"avgSla": "h",
"autoPassRate": "%",
"riskCount": "",
"slaRate": "%",
"reimbursementAmount": "",
"reimbursementCount": "",
"pendingPaymentAmount": "",
"avgClaimAmount": "",
"budgetUsageRate": "%",
"paymentClearanceRate": "%",
}
meta: dict[str, Any] = {}
for key, current_value in current.items():
@@ -257,28 +277,34 @@ class FinanceDashboardService(BudgetSupportMixin):
claims: list[ExpenseClaim],
now: datetime,
) -> dict[str, Any]:
applications = [0 for _ in labels]
approved = [0 for _ in labels]
claim_count = [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]
index = {label: idx for idx, label in enumerate(labels)}
for claim in claims:
if self._status(claim) == "draft":
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
label = self._date_label(self._claim_time(claim).date())
if label not in index:
continue
bucket = index[label]
applications[bucket] += 1
claim_count[bucket] += 1
claim_amount[bucket] += self._claim_amount(claim)
if self._status(claim) in SUCCESS_STATUSES:
approved[bucket] += 1
success_count[bucket] += 1
if claim.submitted_at:
hours[bucket].append(self._claim_sla_hours(claim, now))
return {
"labels": labels,
"applications": applications,
"approved": approved,
"claimCount": claim_count,
"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],
}
@@ -287,79 +313,178 @@ class FinanceDashboardService(BudgetSupportMixin):
for claim in claims:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
label = EXPENSE_TYPE_LABELS.get(str(claim.expense_type or "").strip(), claim.expense_type)
buckets[str(label or "其他费用")] += self._claim_amount(claim)
buckets[self._expense_type_label(claim.expense_type)] += self._claim_amount(claim)
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
def _exception_mix(
self,
claims: list[ExpenseClaim],
observations: list[RiskObservation],
) -> list[dict[str, Any]]:
def _payment_status_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
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:
if self._status(claim) in {"draft", "deleted"}:
status = self._status(claim)
if status in {"draft", "deleted"}:
continue
for label in self._claim_risk_labels(claim):
buckets[label] += 1
buckets[self._finance_status_label(status)] += 1
rows = [
{"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
def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
for claim in claims:
if self._status(claim) not in PENDING_STATUSES:
status = self._status(claim)
if status in EXCLUDED_SPEND_STATUSES:
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 = [
{
"name": name,
"amount": 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)],
}
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
def _bottlenecks(self, claims: list[ExpenseClaim], now: datetime) -> list[dict[str, Any]]:
buckets: dict[str, list[Decimal]] = defaultdict(list)
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
departments: dict[str, str] = {}
for claim in claims:
if self._status(claim) not in PENDING_STATUSES:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
stage = self._stage_label(claim)
buckets[stage].append(self._claim_sla_hours(claim, now))
employee_name = str(claim.employee_name or "").strip()
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]] = []
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(
return [
{
"name": stage,
"role": "审批节点",
"duration": f"{self._decimal_number(avg_hours):.1f} h",
"status": self._duration_status(avg_hours),
"tone": self._duration_tone(avg_hours),
"avatar": stage[:1] or str(index + 1),
"name": name,
"department": departments.get(name, ""),
"amount": self._decimal_number(amount),
"value": self._decimal_number(amount),
"count": counts[name],
"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]:
allocations = self._fetch_budget_allocations(fiscal_year)
@@ -384,6 +509,149 @@ class FinanceDashboardService(BudgetSupportMixin):
"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:
return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at)
@@ -410,10 +678,14 @@ class FinanceDashboardService(BudgetSupportMixin):
labels.append("风险扫描命中")
for flag in self._risk_flags(claim):
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:
label = str(flag or "").strip()
labels.append(label or "规则异常")
label = self._risk_signal_label(flag)
labels.append(self._display_risk_label(label))
return labels
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()
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:
return str(claim.status or "").strip().lower()

View File

@@ -14,8 +14,10 @@ from app.schemas.user_agent import (
UserAgentResponse,
UserAgentSuggestedAction,
)
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
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 (
build_document_number,
generate_unique_expense_claim_no,
@@ -25,6 +27,11 @@ from app.services.user_agent_application_dates import (
resolve_application_days_from_time_range,
)
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
APPLICATION_CONTEXT_VALUES = {
@@ -35,7 +42,7 @@ APPLICATION_CONTEXT_VALUES = {
"preapproval",
}
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
APPLICATION_TIME_LABELS = ("行程时间", "出发时间", "返回时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
APPLICATION_FIELD_LABELS = (
"申请类型",
"费用类型",
@@ -202,7 +209,7 @@ class UserAgentApplicationMixin:
facts: dict[str, str],
step: 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":
missing_fields = self._resolve_application_missing_fields(facts)
@@ -234,7 +241,7 @@ class UserAgentApplicationMixin:
if step == "duplicate":
application_no = str(facts.get("application_no") or "").strip()
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(
[
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
@@ -247,7 +254,7 @@ class UserAgentApplicationMixin:
return "\n\n".join(
[
"这是费用申请核对结果,请核对:",
self._build_application_summary_table(facts),
build_application_summary_table(facts),
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
]
)
@@ -375,9 +382,71 @@ class UserAgentApplicationMixin:
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
if 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)
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
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
preview = context_json.get("application_preview")
@@ -496,6 +565,17 @@ class UserAgentApplicationMixin:
@staticmethod
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(
message,
APPLICATION_TIME_LABELS,
@@ -543,6 +623,13 @@ class UserAgentApplicationMixin:
@staticmethod
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
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)
match = re.search(
rf"(?:{label_pattern})[:]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[:]|[\n;]|$)",
@@ -644,7 +731,7 @@ class UserAgentApplicationMixin:
return ""
text = re.sub(
r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[:]\s*",
r"^(?:行程时间|出发时间|返回时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[:]\s*",
"",
text,
)
@@ -843,73 +930,6 @@ class UserAgentApplicationMixin:
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(
self,
payload: UserAgentRequest,
@@ -1204,7 +1224,7 @@ class UserAgentApplicationMixin:
return UserAgentDraftPayload(
draft_type="expense_application",
title=str(facts.get("application_type") or "费用申请").strip() or "费用申请",
body=self._build_application_summary(facts),
body=build_application_summary(facts),
confirmation_required=False,
claim_id=claim.id,
claim_no=claim.claim_no,

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

View File

@@ -190,6 +190,11 @@ class UserAgentReviewSlotMixin:
if not cleaned_key:
continue
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

View 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

View File

@@ -416,6 +416,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
"application_amount": "3000",
"application_amount_label": "¥3,000",
"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_type": "travel",
@@ -432,6 +439,7 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
assert claim.location == "上海"
assert claim.amount == Decimal("0.00")
assert claim.invoice_count == 0
assert claim.occurred_at.date() == date(2026, 2, 20)
assert claim.items == []
link_flag = next(
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"
)
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_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:
@@ -2858,6 +3080,83 @@ def test_list_claims_limits_finance_to_personal_records() -> None:
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:
current_user = CurrentUserContext(
username="executive@example.com",
@@ -3822,6 +4121,12 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
"reason": "支撑国网服务器上线部署",
"days": "3 天",
"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",
},
},
@@ -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_days") == "3 天"
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("budget_opinion") == "预算额度可承接,同意。"
for flag in generated_draft.risk_flags_json

View 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

View File

@@ -25,7 +25,7 @@ def build_session() -> Session:
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)
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),
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(
@@ -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.totals["pendingCount"] == 1
assert dashboard.totals["pendingAmount"] == 1200.0
assert dashboard.totals["riskCount"] == 1
assert dashboard.totals["reimbursementCount"] == 2
assert dashboard.totals["reimbursementAmount"] == 2000.0
assert dashboard.totals["pendingPaymentAmount"] == 0.0
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.department_ranking[0]["name"] == "财务部"
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["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

View File

@@ -710,7 +710,8 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
assert response.status == "blocked"
assert response.trace_summary.scenario == "expense"
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 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 "按 2026-05-25 参考票价" 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 "[确认](#application-submit)" in second.result["answer"]
assert second.status == "blocked"

View File

@@ -209,7 +209,8 @@ def test_user_agent_application_context_uses_application_language() -> None:
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 "请先在下面选择报销场景" 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:
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 "当前还需要先补充:申请事由" 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)
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 "伊犁出差" 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
@@ -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 "按 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.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 "| 天数 | 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 "| 事由 | 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:
session_factory = build_session_factory()
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 "| 天数 | 待补充 |" not 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 "| 行程时间 | 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
@@ -547,7 +603,8 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 行程时间 | 2026-05-25 |\n"
"| 出发时间 | 2026-05-25 |\n"
"| 返回时间 | 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\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:
session_factory = build_session_factory()
initial_message = (
"行程时间2026-05-25 至 2026-05-27\n"
"出发时间2026-05-25\n"
"返回时间2026-05-27\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天\n"
@@ -597,7 +655,8 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 行程时间 | 2026-05-25 至 2026-05-27 |\n"
"| 出发时间 | 2026-05-25 |\n"
"| 返回时间 | 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
@@ -1385,6 +1444,7 @@ def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
"application_location": "北京",
"application_amount": "3000元",
"application_business_time": "2026-06-01 至 2026-06-03",
"application_transport_mode": "火车",
},
"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["amount"].value == "3000.00元"
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

File diff suppressed because it is too large Load Diff

View File

@@ -136,12 +136,16 @@
transform: translateY(-1px);
}
.trend-panel,
.rank-panel {
.trend-panel {
grid-column: span 6;
}
.trend-count-panel,
.donut-panel,
.rank-panel,
.employee-rank-panel,
.top-claim-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel,
.model-panel,
@@ -149,6 +153,12 @@
grid-column: span 3;
}
.bottleneck-panel,
.budget-metrics-panel,
.budget-panel {
grid-column: span 6;
}
.card-head {
display: flex;
align-items: center;
@@ -404,7 +414,9 @@
}
.bottleneck-panel,
.budget-metrics-panel,
.budget-panel,
.top-claim-panel,
.model-panel,
.feedback-panel {
display: flex;
@@ -477,6 +489,142 @@
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 {
flex: 1;
display: grid;
@@ -610,6 +758,7 @@
@media (prefers-reduced-motion: reduce) {
.kpi-card,
.dashboard-card,
.budget-metric-item,
.bottleneck-row {
animation: none;
}
@@ -630,11 +779,15 @@
}
.trend-panel,
.rank-panel {
.trend-count-panel,
.rank-panel,
.employee-rank-panel,
.top-claim-panel {
grid-column: span 12;
}
.donut-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel,
.model-panel,
@@ -694,8 +847,12 @@
}
.trend-panel,
.trend-count-panel,
.rank-panel,
.employee-rank-panel,
.top-claim-panel,
.donut-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel,
.model-panel,
@@ -716,6 +873,10 @@
grid-template-columns: 24px 64px minmax(0, 1fr);
}
.budget-metric-grid {
grid-template-columns: 1fr;
}
.rank-value {
grid-column: 2 / -1;
}

View File

@@ -1,9 +1,7 @@
<template>
<div class="trend-chart">
<div class="chart-legend">
<span><i :style="{ background: chartColors.primary }"></i>申请量</span>
<span><i :style="{ background: chartColors.blue }"></i>审批完成量</span>
<span><i :style="{ background: chartColors.purple }"></i>平均审批时长小时</span>
<span><i :style="{ background: activeColor }"></i>{{ legendLabel }}</span>
</div>
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
@@ -23,22 +21,44 @@ use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasR
const props = defineProps({
labels: { type: Array, required: true },
applications: { type: Array, required: true },
approved: { type: Array, required: true },
avgHours: { type: Array, required: true }
mode: { type: String, default: 'amount' },
claimCount: { type: Array, default: () => [] },
claimAmount: { type: Array, default: () => [] },
applications: { type: Array, default: () => [] },
approved: { type: Array, default: () => [] },
avgHours: { type: Array, default: () => [] }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue,
purple: themeColors.value.chartPurple
blue: themeColors.value.chartBlue
}))
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(() =>
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('')
)
@@ -51,7 +71,7 @@ const chartOptions = computed(() => ({
animationEasingUpdate: 'linear',
grid: {
top: 18,
right: 38,
right: 24,
bottom: 22,
left: 36,
containLabel: true
@@ -83,72 +103,46 @@ const chartOptions = computed(() => ({
fontWeight: 700
}
},
yAxis: [
{
yAxis: {
type: 'value',
min: 0,
max: 250,
max: Math.ceil(maxValue.value * 1.2),
splitNumber: 5,
axisLabel: {
name: isCountMode.value ? '单' : '元',
nameTextStyle: {
color: '#64748b',
fontSize: 11,
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)' } }
},
{
type: 'value',
min: 0,
max: 15,
splitNumber: 5,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { show: false }
}
],
series: [
{
name: '申请量(单)',
type: 'bar',
data: props.applications,
barWidth: 12,
barGap: '28%',
itemStyle: {
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',
name: legendLabel.value,
type: isCountMode.value ? 'line' : 'bar',
data: activeSeries.value,
barWidth: 16,
smooth: isCountMode.value,
symbol: isCountMode.value ? 'circle' : 'none',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: chartColors.value.purple
color: activeColor.value
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.purple,
borderWidth: 2.5
color: isCountMode.value ? '#ffffff' : activeColor.value,
borderColor: activeColor.value,
borderWidth: isCountMode.value ? 2.5 : 0,
borderRadius: [4, 4, 0, 0]
},
areaStyle: {
opacity: isCountMode.value ? 1 : 0,
color: {
type: 'linear',
x: 0,
@@ -156,10 +150,15 @@ const chartOptions = computed(() => ({
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(chartColors.value.purple, 0.14) },
{ offset: 1, color: toRgba(chartColors.value.purple, 0.02) }
{ offset: 0, color: toRgba(activeColor.value, 0.14) },
{ 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})`
}
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>
<style scoped>

View File

@@ -38,6 +38,7 @@
</button>
</div>
<div class="page-tools">
<EnterpriseSelect
v-if="showPageSize"
class="page-size-select"
@@ -46,11 +47,27 @@
size="small"
@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>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import EnterpriseSelect from './EnterpriseSelect.vue'
@@ -73,12 +90,15 @@ const props = defineProps({
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-size-change'])
const pageInput = ref(String(props.currentPage || 1))
const pageItems = computed(() => {
if (props.pages.length) {
return props.pages
const total = Math.max(1, Number(props.totalPages) || 1)
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(() => {
@@ -104,4 +124,21 @@ function setPageSize(size) {
emit('update:pageSize', 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>

View File

@@ -5,29 +5,26 @@ export function useLoginView() {
const password = ref('')
const tenant = ref('远光软件股份有限公司')
const remember = ref(true)
const tenantOptions = [
{
label: '远光软件股份有限公司',
value: '远光软件股份有限公司'
}
]
const showPassword = ref(false)
const features = [
{
iconKey: 'recognition',
title: '智能识别 自动归集',
desc: '票据智能识别,自动归集费用,减少人工录入'
title: '智能审单',
desc: 'AI 自动识别票据与规则,提升准确率与处理效率',
icon: 'mdi mdi-file-document-outline',
tone: 'green'
},
{
iconKey: 'workflow',
title: '流程透明 合规可控',
desc: '内置审批规则引擎,流程透明,风险可控'
title: '异常预警',
desc: '多维风险识别与预警,主动防控报销风险',
icon: 'mdi mdi-bell-outline',
tone: 'red'
},
{
iconKey: 'insight',
title: '数据洞察 决策支持',
desc: '多维度费用分析,洞察业务,驱动决策'
title: 'SLA 监控',
desc: '实时监控服务水位,保障审批和处理时效',
icon: 'mdi mdi-sync',
tone: 'blue'
}
]
@@ -52,8 +49,8 @@ export function useLoginView() {
LogoMark,
password,
remember,
showPassword,
tenant,
tenantOptions,
username
}
}

View File

@@ -37,16 +37,18 @@ import {
} from '../data/metrics.js'
const emptyFinanceTotals = {
pendingCount: 0,
pendingAmount: 0,
avgSla: 0,
autoPassRate: 0,
riskCount: 0,
slaRate: 0
reimbursementAmount: 0,
reimbursementCount: 0,
pendingPaymentAmount: 0,
avgClaimAmount: 0,
budgetUsageRate: 0,
paymentClearanceRate: 0
}
const emptyFinanceTrend = {
labels: [],
claimCount: [],
claimAmount: [],
applications: [],
approved: [],
avgHours: []
@@ -63,6 +65,15 @@ const emptyFinanceBudgetSummary = {
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 = {}) {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
@@ -103,8 +114,9 @@ export function useOverviewView(options = {}) {
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
if (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
return formatCurrency(Math.round(value))
}
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
return `${Math.round(value)}`
@@ -311,12 +323,21 @@ export function useOverviewView(options = {}) {
const financeDepartmentRanking = computed(() => (
financeDashboardPayload.value?.departmentRanking || []
))
const financeEmployeeRanking = computed(() => (
financeDashboardPayload.value?.employeeRanking || []
))
const financeTopClaims = computed(() => (
financeDashboardPayload.value?.topClaims || []
))
const financeBottlenecks = computed(() => (
financeDashboardPayload.value?.bottlenecks || []
))
const financeBudgetSummary = computed(() => (
financeDashboardPayload.value?.budgetSummary || emptyFinanceBudgetSummary
))
const financeBudgetMetrics = computed(() => (
financeDashboardPayload.value?.budgetMetrics || emptyFinanceBudgetMetrics
))
const resolveSystemMetricMeta = (metric) => {
const totals = systemDashboardTotals.value
@@ -508,13 +529,15 @@ export function useOverviewView(options = {}) {
})))
const rankedDepartments = computed(() => {
const rows = financeDepartmentRanking.value.map((item) => ({
const rows = financeDepartmentRanking.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, 5).map((item, index) => ({
return rows.slice(0, 6).map((item, index) => ({
...item,
rank: index + 1,
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) => ({
...item,
rank: index + 1,
@@ -670,8 +719,14 @@ export function useOverviewView(options = {}) {
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 budgetSummary = financeBudgetSummary
const budgetMetrics = financeBudgetMetrics
const spendByCategory = financeSpendByCategory
const exceptionMix = financeExceptionMix
@@ -681,6 +736,7 @@ export function useOverviewView(options = {}) {
activeTrend,
activeTrendRange,
bottlenecks,
budgetMetrics,
budgetSummary,
departmentRangeOptions,
digitalEmployeeCategoryRows,
@@ -701,6 +757,7 @@ export function useOverviewView(options = {}) {
kpiMetrics,
metricBlueprints,
rankedDepartments,
rankedEmployees,
riskDashboard,
riskDashboardError,
riskDashboardLoading,
@@ -743,6 +800,7 @@ export function useOverviewView(options = {}) {
systemToolRankings,
systemToolTotal,
systemTrendSeries,
topClaims,
trendRanges
}
}

View File

@@ -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 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 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_REIMBURSEMENT = 'reimbursement'
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
@@ -258,6 +266,83 @@ function resolveAttachmentDisplayName(value) {
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) {
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 = {}) {
const detail = normalizeApplicationHandoffDetail(flag)
return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
@@ -694,15 +806,41 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
const rawTime = normalizeText(
detail.application_time
|| detail.applicationTime
|| detail.application_business_time
|| detail.applicationBusinessTime
|| detail.business_time
|| detail.businessTime
|| detail.time_range
|| detail.timeRange
|| detail.time
|| detail.application_date
|| detail.applicationDate
|| relatedEvent.application_time
|| relatedEvent.applicationTime
|| relatedEvent.application_business_time
|| relatedEvent.applicationBusinessTime
|| relatedEvent.business_time
|| relatedEvent.businessTime
|| relatedEvent.time_range
|| relatedEvent.timeRange
|| relatedEvent.application_date
|| relatedEvent.applicationDate
|| 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 {
id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
@@ -717,7 +855,9 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|| relatedEvent.applicationDays
),
location,
time: formatDate(rawTime) || rawTime,
time: displayTime,
tripStartDate: dateRange.startDate,
tripEndDate: dateRange.endDate,
amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
transportMode: normalizeText(
@@ -726,7 +866,34 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|| detail.transport_mode
|| relatedEvent.application_transport_mode
|| 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 []
}
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 rightType = normalizeExpenseType(right?.item_type)
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 invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
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 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 employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
@@ -1162,7 +1338,7 @@ export function mapExpenseClaimToRequest(claim) {
submittedAt: applyDateTime || '',
createdAt: claim?.created_at || '',
updatedAt: claim?.updated_at || '',
amount: parseNumber(claim?.amount),
amount: amountValue,
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
invoiceCount,
workflowNode,

View File

@@ -1,62 +1,60 @@
export const metricBlueprints = [
{
key: 'pendingCount',
label: '待审批单据',
unit: '单',
key: 'reimbursementAmount',
label: '本期报销金额',
accent: 'var(--theme-primary)',
icon: 'mdi mdi-file-document-outline',
trend: 'down',
change: '12.5%',
delta: '较昨日 -18 单'
},
{
key: 'pendingAmount',
label: '待处理金额',
accent: 'var(--chart-blue)',
icon: 'mdi mdi-wallet',
icon: 'mdi mdi-cash-multiple',
trend: 'up',
change: '8.3%',
delta: '较昨日 +¥27,400'
change: '8.4%',
delta: '较上一周期 +¥42.8K'
},
{
key: 'avgSla',
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: '异常预警单',
key: 'reimbursementCount',
label: '报销单数',
unit: '单',
accent: 'var(--danger)',
icon: 'mdi mdi-alert',
accent: 'var(--chart-blue)',
icon: 'mdi mdi-file-document-outline',
trend: 'up',
change: '16.7%',
delta: '较昨日 +2 单'
change: '6.1%',
delta: '较上一周期 +23 单'
},
{
key: 'slaRate',
label: 'SLA 达成率',
key: 'pendingPaymentAmount',
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: '%',
accent: 'var(--success)',
icon: 'mdi mdi-check-circle',
icon: 'mdi mdi-chart-arc',
trend: 'up',
change: '3.1%',
delta: '较昨日 +2.9%'
change: '3.2%',
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 = {
'近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],
approved: [110, 85, 130, 125, 110, 60, 55, 50, 145, 150, 170],
claimCount: [14, 11, 18, 20, 16, 7, 7, 6, 19, 20, 22],
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]
},
'近7天': {
labels: ['04-23', '04-24', '04-25', '04-26', '04-27', '04-28', '04-29'],
applications: [72, 68, 109, 121, 134, 142, 128],
approved: [58, 54, 92, 101, 116, 121, 110],
claimCount: [7, 7, 11, 12, 13, 14, 13],
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]
},
'近30天': {
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],
approved: [68, 76, 80, 95, 100, 93, 102, 110, 101, 117],
claimCount: [8, 9, 10, 11, 12, 11, 13, 13, 12, 14],
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]
}
}
@@ -153,38 +157,38 @@ export const spendByCategory = [
]
export const exceptionMix = [
{ name: '住宿超标', value: 5, color: 'var(--danger)' },
{ name: '重复报销', value: 3, color: 'var(--warning)' },
{ name: '行程缺失', value: 3, color: 'var(--chart-purple)' },
{ name: '发票异常', value: 3, color: 'var(--chart-blue)' }
{ name: '已付款', value: 68, color: 'var(--success)' },
{ name: '待付款', value: 18, color: 'var(--chart-amber)' },
{ name: '审批中', value: 12, color: 'var(--theme-primary)' },
{ name: '已入账', value: 9, color: 'var(--chart-blue)' }
]
export const departmentRangeOptions = ['本周', '本月', '本季度']
export const bottlenecks = [
{
name: '李文静',
role: '财务经理',
duration: '12.4 h',
status: '较慢',
name: '预算超支',
role: '预算控制',
duration: '3 个池',
status: '¥42.6K',
tone: 'danger',
avatar: ''
avatar: ''
},
{
name: '王志强',
role: '财务专员',
duration: '8.7 h',
status: '偏慢',
name: '待付款',
role: '资金计划',
duration: '¥86.3K',
status: '18 单',
tone: 'warning',
avatar: ''
avatar: ''
},
{
name: '刘思雨',
role: '费用审核员',
duration: '5.2 h',
status: '正常',
tone: 'success',
avatar: ''
name: '高额单据',
role: '费用集中度',
duration: '¥18.6K',
status: '本期最高',
tone: 'warning',
avatar: ''
}
]

View File

@@ -20,8 +20,11 @@ const FINANCE_DASHBOARD_FALLBACK = {
spendByCategory: null,
exceptionMix: null,
departmentRanking: null,
employeeRanking: null,
topClaims: null,
bottlenecks: null,
budgetSummary: null,
budgetMetrics: null,
hasRealData: false
}
@@ -66,8 +69,11 @@ function normalizeFinanceDashboardPayload(payload = {}) {
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
exceptionMix: payload.exception_mix || payload.exceptionMix || 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,
budgetSummary: payload.budget_summary || payload.budgetSummary || null
budgetSummary: payload.budget_summary || payload.budgetSummary || null,
budgetMetrics: payload.budget_metrics || payload.budgetMetrics || null
}
}

View File

@@ -30,6 +30,92 @@ function pickDetailValue(detail, request, keys = [], 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 = {}) {
const detail = resolveApplicationDetailPayload(request)
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
@@ -39,11 +125,7 @@ export function buildApplicationDetailFactItems(request = {}) {
label: '申请类型',
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
},
{
key: 'time',
label: '发生时间',
value: pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
},
...buildApplicationTimeRows(detail, request),
{
key: 'location',
label: '地点',
@@ -107,6 +189,12 @@ export function buildApplicationDetailFactItems(request = {}) {
export function buildRelatedApplicationFactItems(request = {}) {
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 = [
{
key: 'claim_no',
@@ -119,6 +207,16 @@ export function buildRelatedApplicationFactItems(request = {}) {
label: '申请内容',
value: related.content
},
{
key: 'trip_start_date',
label: '出发时间',
value: relatedStartDate
},
{
key: 'trip_end_date',
label: '返回时间',
value: relatedEndDate
},
{
key: 'days',
label: '申请天数',
@@ -135,9 +233,37 @@ export function buildRelatedApplicationFactItems(request = {}) {
value: related.location
},
{
key: 'time',
label: '申请时间',
value: related.time
key: 'transport_mode',
label: '出行方式',
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',
@@ -145,11 +271,6 @@ export function buildRelatedApplicationFactItems(request = {}) {
value: related.amountLabel,
highlight: true,
emphasis: true
},
{
key: 'transport_mode',
label: '出行方式',
value: related.transportMode
}
]

View File

@@ -127,7 +127,7 @@ export function buildMockApplicationTransportEstimate({
simulatedLatencyMs,
source: 'mock_ticket_price_query_v1',
confidence: 'mock',
basisText: `已查询 ${queryLabel} ${mode}参考票价,按${bandLabel}往返 ${amountDisplay}元估算(查询耗时 ${simulatedLatencyMs}ms`
basisText: `预估交通费用 ${amountDisplay}`
}
}

View File

@@ -99,6 +99,19 @@ export function resolveExpenseTypeCode(ontology) {
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) {
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 = {}) {
const expenseTypeCode = resolveExpenseTypeCode(ontology)
const expenseTypeCode = resolveApplicationExpenseTypeCode(ontology, prompt)
const amount = resolveApplicationAmount(ontology)
const documentTypeEntity = resolveEntity(ontology, 'document_type')
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')

View File

@@ -31,11 +31,11 @@ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
export function resolveApplicationTimeLabel(applicationType = '') {
const label = String(applicationType || '').trim()
if (/差旅|出差/.test(label)) return '行程时间'
if (/差旅|出差/.test(label)) return '出发时间'
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
return '申请时间'
}
@@ -47,10 +47,36 @@ function resolveApplicationFieldLabel(item, fields = {}) {
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) {
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 = []) {
for (const pattern of patterns) {
const match = text.match(pattern)
@@ -106,6 +132,7 @@ function resolvePreviewToday(options = {}) {
function resolveApplicationType(text) {
const compact = compactText(text)
if (looksLikeStructuredTravelApplication(text)) 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
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return `${estimate.basisText},报销阶段按真实票据复核`
return estimate.basisText
}
function ensureApplicationPolicyFields(fields = {}) {
@@ -437,9 +464,8 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
allowanceAmount: result?.allowance_amount
})
const transportEstimate = systemEstimate.transportEstimate
const queryLabel = transportEstimate?.queryDate || '出行日期待确认'
const transportText = transportEstimate
? `交通 ${systemEstimate.transportAmountDisplay}(按 ${queryLabel} 参考票价) + `
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
: ''
const totalAmount = systemEstimate.totalAmountDisplay
const amount = totalAmount ? `${totalAmount}` : fields.amount
@@ -499,7 +525,6 @@ export function refreshApplicationPreviewTransportEstimate(preview = {}) {
const hotelAmount = formatPolicyMoney(hotelAmountSource)
const allowanceAmount = formatPolicyMoney(allowanceAmountSource)
const hasPolicyAmounts = parseMoneyNumber(hotelAmountSource) > 0 || parseMoneyNumber(allowanceAmountSource) > 0
const queryLabel = transportEstimate.queryDate || '出行日期待确认'
const nextFields = {
...fields,
transportPolicy: buildTransportPolicyText(fields.transportMode, location, transportEstimate, fields.time),
@@ -513,7 +538,7 @@ export function refreshApplicationPreviewTransportEstimate(preview = {}) {
if (hasPolicyAmounts) {
const days = Number(policyResult.days) || parseApplicationDaysValue(fields.days) || 1
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.policyTotalAmount = totalAmount ? `${totalAmount}` : ''
}
@@ -639,17 +664,41 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
export function buildApplicationPreviewRows(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
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 value = String(rawValue || '').trim() || '待补充'
return {
return [{
...item,
label: resolveApplicationFieldLabel(item, fields),
value,
editable: item.editable !== false,
highlight: Boolean(item.highlight),
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
}
}]
})
}

View File

@@ -1,41 +1,69 @@
<template>
<main class="login-page">
<section class="login-visual" aria-label="智能费用管理运营能力">
<div class="visual-brand">
<header class="page-brand">
<LogoMark />
<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 class="visual-copy">
<p>智能费用管理</p>
<h1>让企业财务更高效更合规更可控</h1>
<span>以智能化流程驱动费用全生命周期管理助力企业降本增效稳健前行</span>
<div class="document-card">
<span>报销单</span>
<i></i><i></i><i></i>
<b class="doc-check"><i class="mdi mdi-check"></i></b>
</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">
<span class="visual-feature-icon">
<ElIcon><component :is="item.icon" /></ElIcon>
</span>
<span :class="item.tone"><i :class="item.icon"></i></span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.desc }}</p>
</div>
</article>
</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 class="login-panel" aria-label="登录表单">
<div class="login-card">
<section class="login-card" aria-label="登录表单">
<div class="card-brand">
<LogoMark />
<strong>{{ displayCompanyName }}</strong>
@@ -43,110 +71,80 @@
<header class="card-head">
<h2>欢迎登录</h2>
<p>智能费用管理平台</p>
<p>使用员工邮箱或管理员账号进入系统</p>
</header>
<form class="login-form" @submit.prevent="submitLogin">
<label class="form-field">
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<ElInput
v-model="username"
class="login-input"
:prefix-icon="User"
autocomplete="username"
clearable
placeholder="请输入账号"
/>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入员工邮箱 / 管理员账号" autocomplete="username" required />
</label>
<label class="form-field">
<label class="field">
<span class="sr-only">密码</span>
<ElInput
<i class="mdi mdi-lock-outline"></i>
<input
v-model="password"
class="login-input"
:prefix-icon="Lock"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入登录密码"
autocomplete="current-password"
placeholder="请输入密码"
show-password
type="password"
required
/>
<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 class="form-field">
<span class="sr-only">所属企业</span>
<ElSelect
v-model="tenant"
class="login-select"
popper-class="login-tenant-popper"
placeholder="请选择所属企业"
:suffix-icon="OfficeBuilding"
>
<ElOption
v-for="option in tenantOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<label class="field">
<span class="sr-only">企业或租户</span>
<i class="mdi mdi-office-building"></i>
<select v-model="tenant" class="tenant-select" aria-label="请选择企业或租户">
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
</select>
<span class="field-select-chevron" aria-hidden="true">
<i class="mdi mdi-chevron-down"></i>
</span>
</label>
<div class="form-meta">
<ElCheckbox v-model="remember" class="login-checkbox">记住账号</ElCheckbox>
<button type="button" class="link-button" @click="emit('recover-password')">忘记密码?</button>
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住账号</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
</div>
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
<ElButton
class="login-submit"
type="primary"
native-type="submit"
:loading="submitting"
:disabled="submitting"
>
登录
</ElButton>
<button class="submit-btn" type="submit" :disabled="submitting">
{{ submitting ? '登录中...' : '登录' }}
</button>
<ElButton
class="login-sso"
:icon="Grid"
:disabled="submitting"
@click="emit('sso-login')"
>
SSO 单点登录
</ElButton>
<div class="divider"><span></span></div>
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')">
<i class="mdi mdi-shield-outline"></i>
<span>SSO 单点登录</span>
</button>
</form>
<footer class="security-note">
登录即表示您已阅读并同意
<button type="button">用户协议</button>
<button type="button">隐私政策</button>
<i class="mdi mdi-lock-outline"></i>
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span>
</footer>
</div>
</section>
</main>
</template>
<script setup>
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'
const props = defineProps({
@@ -168,32 +166,7 @@ const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const displayCompanyName = computed(() => props.companyName || '易财费控')
const {
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
})
}
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
</script>
<style scoped src="../assets/styles/views/login-view.css"></style>

View File

@@ -26,7 +26,7 @@
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
<h3>每日报销金额 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeTrendRange"
class="card-select"
@@ -37,10 +37,23 @@
</div>
<TrendChart
mode="amount"
:labels="activeTrend.labels"
:applications="activeTrend.applications"
:approved="activeTrend.approved"
:avg-hours="activeTrend.avgHours"
:claim-count="activeTrend.claimCount"
:claim-amount="activeTrend.claimAmount"
/>
</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>
@@ -51,20 +64,12 @@
<DonutChart :items="spendLegend" :center-value="spendCenterValue" center-label="费用总额" />
<p class="panel-note">* 百分比按当前时间范围内的费用金额计算</p>
</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 class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行待处理金额<i class="mdi mdi-information-outline"></i></h3>
<h3>部门报销排行费用金额<i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
@@ -77,33 +82,58 @@
<BarChart :items="rankedDepartments" />
</article>
<article class="panel dashboard-card bottleneck-panel">
<article class="panel dashboard-card employee-rank-panel">
<div class="card-head">
<h3>审批瓶颈平均处理时长 <i class="mdi mdi-information-outline"></i></h3>
<h3>个人报销排行本月<i class="mdi mdi-information-outline"></i></h3>
</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
v-for="(item, index) in bottlenecks"
:key="item.name"
class="bottleneck-row"
v-for="item in topClaims"
:key="item.claimNo"
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` }"
>
<div class="reviewer">
<div class="reviewer-avatar">{{ item.avatar }}</div>
<span class="budget-metric-icon"><i :class="item.icon"></i></span>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
</div>
<div class="reviewer-stats">
<strong>{{ item.duration }}</strong>
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<em>{{ item.detail }}</em>
</div>
</div>
</div>
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</article>
<article class="panel dashboard-card budget-panel">
@@ -326,7 +356,7 @@ const {
activeRiskWindowDays,
activeTrend,
activeTrendRange,
bottlenecks,
budgetMetrics,
budgetSummary,
departmentRangeOptions,
digitalEmployeeCategoryRows,
@@ -338,16 +368,15 @@ const {
digitalEmployeeTaskRanking,
kpiMetrics,
rankedDepartments,
rankedEmployees,
riskDashboard,
riskDashboardError,
riskDashboardLoading,
riskDailyTrendRows,
riskLegend,
riskKpiMetrics,
riskLevelLegend,
riskSignalRanking,
riskSourceLegend,
riskTotal,
riskWindowOptions,
setRiskWindowDays,
spendCenterValue,
@@ -362,6 +391,7 @@ const {
systemUsageDurationRows,
systemUsageDurationSummary,
systemUserTokenUsage,
topClaims,
trendRanges
} = useOverviewView(props)

View File

@@ -90,7 +90,7 @@ export default {
const rows = demoDepartments
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,
rank: index + 1,
shortName: item.name,

View File

@@ -83,13 +83,38 @@ function normalizeApplicationDateText(value) {
}
function normalizeApplicationBusinessTime(claim) {
const start = normalizeApplicationDateText(claim?.start_date || claim?.startDate || claim?.begin_date || claim?.beginDate)
const end = normalizeApplicationDateText(claim?.end_date || claim?.endDate || claim?.finish_date || claim?.finishDate)
const detail = resolveApplicationDetailPayload(claim)
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) {
return `${start}${end}`
}
return normalizeApplicationDateText(
start
|| detail.application_business_time
|| detail.applicationBusinessTime
|| detail.business_time
|| detail.businessTime
|| detail.time_range
|| detail.timeRange
|| claim?.business_time
|| claim?.businessTime
|| 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) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
@@ -231,20 +271,51 @@ export function isUsableRequiredApplicationClaim(claim) {
}
export function normalizeRequiredApplicationCandidate(claim) {
const detail = resolveApplicationDetailPayload(claim)
const claimNo = normalizeText(claim?.claim_no || claim?.claimNo)
const location = normalizeText(claim?.location || claim?.business_location || claim?.businessLocation)
const amountText = formatAmount(claim?.amount || claim?.budget_amount || claim?.budgetAmount)
const location = normalizeText(
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)
return {
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
claim_no: claimNo,
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,
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
amount,
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_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
application_date: normalizeApplicationDate(claim)
@@ -296,6 +367,14 @@ export function buildRequiredApplicationActions(applications, actionType) {
application_amount: application.amount,
application_amount_label: application.amount_label,
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_label: application.status_label,
application_date: application.application_date

View File

@@ -140,6 +140,14 @@ function normalizeApplicationCandidates(applications) {
amount: normalizeText(item.amount || item.application_amount),
amount_label: normalizeText(item.amount_label || item.application_amount_label),
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_label: normalizeText(item.status_label || item.application_status_label),
application_date: normalizeText(item.application_date)
@@ -264,6 +272,14 @@ export function selectGuidedRequiredApplication(state, application = {}) {
application_amount: application.application_amount || application.amount || '',
application_amount_label: application.application_amount_label || application.amount_label || '',
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_date: application.application_date || ''
}),
@@ -412,6 +428,7 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
const applicationLocation = values.application_location || ''
const applicationAmount = values.application_amount || values.application_amount_label || ''
const applicationBusinessTime = values.application_business_time || ''
const applicationTransportMode = values.application_transport_mode || ''
const fieldLines = []
if (linkedApplication) {
const applicationParts = buildApplicationSummaryParts(values)
@@ -440,6 +457,7 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
business_location: values.location || applicationLocation || '',
time_range: values.time_range || applicationBusinessTime || '',
business_time: values.time_range || applicationBusinessTime || '',
transport_mode: values.transport_mode || applicationTransportMode || '',
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
application_claim_id: values.application_claim_id || '',
@@ -449,6 +467,14 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
application_amount: values.application_amount || '',
application_amount_label: values.application_amount_label || '',
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 || ''
}

View File

@@ -50,7 +50,7 @@ function buildTransportEstimatePendingPreview(preview = {}) {
...preview,
fields: {
...fields,
transportPolicy: '正在查询交通参考票价...',
transportPolicy: '正在预估交通费用...',
policyEstimate: '正在同步费用测算...',
transportEstimatedAmount: '查询中'
}

View File

@@ -288,8 +288,8 @@ export function useTravelReimbursementGuidedFlow({
const applicationId = normalizeText(current.values.application_claim_id)
const applicationReason = normalizeText(current.values.application_reason)
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 applicationTransportMode = normalizeText(current.values.application_transport_mode)
if (!originalMessage || !expenseTypeLabel || !applicationNo) {
return null
}
@@ -326,14 +326,23 @@ export function useTravelReimbursementGuidedFlow({
business_location: applicationLocation,
time_range: applicationBusinessTime,
business_time: applicationBusinessTime,
amount: applicationAmount,
transport_mode: applicationTransportMode,
amount: '',
application_claim_id: applicationId,
application_claim_no: applicationNo,
application_reason: applicationReason,
application_location: applicationLocation,
application_amount: current.values.application_amount || '',
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 || ''
}
}
}

View File

@@ -164,9 +164,10 @@ test('application preview renders ordered editable rows and submit text uses edi
const rows = buildApplicationPreviewRows(editedPreview)
assert.deepEqual(
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.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
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.amountDisplay, '1,100')
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 <= 779)
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 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.days, '4天')
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
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')?.label, '出发时间')
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.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', () => {
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静',
@@ -622,7 +650,7 @@ test('application duplicate confirmation flow marks submit step as blocked dupli
status: 'succeeded',
result: {
answer: [
'检测到同一申请人、同一申请类型、同一行程时间已存在申请单,系统没有重复创建。',
'检测到同一申请人、同一申请类型、同一出发时间已存在申请单,系统没有重复创建。',
'已有申请单号AP-20260602010101-ABCDEFGH',
'当前节点:直属领导审批'
].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.subsidyDailyCap, '120元/天')
assert.match(estimatedPreview.fields.transportPolicy, /参考票价/)
assert.match(estimatedPreview.fields.transportPolicy, /2026-05-25/)
assert.match(estimatedPreview.fields.transportPolicy, /查询耗时 \d+ms/)
assert.equal(estimatedPreview.fields.transportPolicy, '预估交通费用 1,100元')
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
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.transportEstimatedAmount, '2,330元')
assert.equal(message.applicationPreview.fields.amount, '4,490元')
assert.match(message.applicationPreview.fields.transportPolicy, /已查询 2026-05-25 飞机参考票价/)
assert.match(message.applicationPreview.fields.transportPolicy, /查询耗时 \d+ms/)
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
assert.ok(persistCount >= 2)
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')

View File

@@ -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.expenseTypeCode, 'travel')
assert.equal(fields.expenseTypeLabel, '差旅费')
assert.equal(fields.location, '上海')
assert.equal(fields.reason, '支撑国网服务器部署')
assert.deepEqual(

View File

@@ -2,6 +2,10 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
import {
buildApplicationDetailFactItems,
buildRelatedApplicationFactItems
} from '../src/utils/expenseApplicationDetail.js'
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
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)
})
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', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-budget',
@@ -679,11 +732,17 @@ test('paid reimbursement marks payment progress step as complete', () => {
application_type: '差旅费用申请',
application_content: '差旅费用申请 / 北京',
application_reason: '支撑国网仿生产环境部署',
application_days: '3 天',
application_days: '4 天',
application_location: '北京',
application_amount: '3000',
application_time: '2026-05-20T00:00:00.000Z',
application_transport_mode: '高铁'
application_time: '2026-05-20 至 2026-05-23',
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(request.relatedApplication.claimNo, 'APP-20260520-001')
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.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', () => {
@@ -739,7 +826,14 @@ test('reimbursement detail resolves linked application from guided entry context
application_reason: '支撑国网仿生产环境部署',
application_location: '北京',
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: {
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.reason, '支撑国网仿生产环境部署')
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.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', () => {

View File

@@ -232,7 +232,19 @@ test('guided reimbursement requires application selection for travel and enterta
amount: 1800,
occurred_at: '2026-05-20T08:00:00Z',
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',
@@ -285,6 +297,11 @@ test('guided reimbursement requires application selection for travel and enterta
assert.equal(state.stepKey, 'summary')
assert.equal(isGuidedReimbursementReadyForReview(state), true)
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)
assert.match(summaryText, /关联申请单AP-202605-001/)
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.amount, '')
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.match(submitOptions.rawText, /关联申请单AP-202605-001/)
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, /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.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.doesNotMatch(guidedFlowScript, /meta: \['缺少可关联申请单'\],[\s\S]{0,120}suggestedActions: buildGuidedExpenseTypeActions\(\)/)
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)