diff --git a/document/development/半年报销模拟数据/CONCEPT.md b/document/development/半年报销模拟数据/CONCEPT.md new file mode 100644 index 0000000..bc10d70 --- /dev/null +++ b/document/development/半年报销模拟数据/CONCEPT.md @@ -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 趋势和自定义日期范围。 +- 如果后续要支持页面一键生成,需要另行设计权限、审计和清理机制。 diff --git a/document/development/半年报销模拟数据/TODO.md b/document/development/半年报销模拟数据/TODO.md new file mode 100644 index 0000000..e8b2771 --- /dev/null +++ b/document/development/半年报销模拟数据/TODO.md @@ -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`。 diff --git a/document/development/财务看板口径重构与画像模拟/CONCEPT.md b/document/development/财务看板口径重构与画像模拟/CONCEPT.md new file mode 100644 index 0000000..7a35125 --- /dev/null +++ b/document/development/财务看板口径重构与画像模拟/CONCEPT.md @@ -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、构建和容器内测试证据。 diff --git a/document/development/财务看板口径重构与画像模拟/STATUS_AUDIT.md b/document/development/财务看板口径重构与画像模拟/STATUS_AUDIT.md new file mode 100644 index 0000000..336ee03 --- /dev/null +++ b/document/development/财务看板口径重构与画像模拟/STATUS_AUDIT.md @@ -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`。 +- 待开发:如确认要数字状态码,新增状态字典/状态码迁移方案。 diff --git a/document/development/财务看板口径重构与画像模拟/TODO.md b/document/development/财务看板口径重构与画像模拟/TODO.md new file mode 100644 index 0000000..a03b95e --- /dev/null +++ b/document/development/财务看板口径重构与画像模拟/TODO.md @@ -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`。 diff --git a/server/scripts/audit_expense_claim_statuses.py b/server/scripts/audit_expense_claim_statuses.py new file mode 100644 index 0000000..c98bdaa --- /dev/null +++ b/server/scripts/audit_expense_claim_statuses.py @@ -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 ""] += 1 + stage_counts[stage or ""] += 1 + status_stage_counts[f"{status or ''} | {stage or ''}"] += 1 + doc_type_counts[doc_type] += 1 + + if not is_known_claim_status(status): + unknown_statuses[status or ""] += 1 + if not is_known_approval_stage(stage): + unknown_stages[stage or ""] += 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 ''}/{stage or ''}" + 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() diff --git a/server/scripts/mock_half_year_expense_demo_attachments.py b/server/scripts/mock_half_year_expense_demo_attachments.py new file mode 100644 index 0000000..64ff13c --- /dev/null +++ b/server/scripts/mock_half_year_expense_demo_attachments.py @@ -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() diff --git a/server/scripts/repair_half_year_expense_demo_distribution.py b/server/scripts/repair_half_year_expense_demo_distribution.py new file mode 100644 index 0000000..af3baa5 --- /dev/null +++ b/server/scripts/repair_half_year_expense_demo_distribution.py @@ -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() diff --git a/server/scripts/seed_half_year_expense_demo.py b/server/scripts/seed_half_year_expense_demo.py new file mode 100644 index 0000000..3002116 --- /dev/null +++ b/server/scripts/seed_half_year_expense_demo.py @@ -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() diff --git a/server/src/app/schemas/finance_dashboard.py b/server/src/app/schemas/finance_dashboard.py index 77bc810..35134db 100644 --- a/server/src/app/schemas/finance_dashboard.py +++ b/server/src/app/schemas/finance_dashboard.py @@ -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) diff --git a/server/src/app/services/application_system_estimate.py b/server/src/app/services/application_system_estimate.py index 9a5b88f..e2b0a9c 100644 --- a/server/src/app/services/application_system_estimate.py +++ b/server/src/app/services/application_system_estimate.py @@ -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(), diff --git a/server/src/app/services/demo_company_simulation_catalog.py b/server/src/app/services/demo_company_simulation_catalog.py new file mode 100644 index 0000000..b81f061 --- /dev/null +++ b/server/src/app/services/demo_company_simulation_catalog.py @@ -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) diff --git a/server/src/app/services/demo_company_simulation_filters.py b/server/src/app/services/demo_company_simulation_filters.py new file mode 100644 index 0000000..b40aab7 --- /dev/null +++ b/server/src/app/services/demo_company_simulation_filters.py @@ -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)) diff --git a/server/src/app/services/demo_company_simulation_seed.py b/server/src/app/services/demo_company_simulation_seed.py new file mode 100644 index 0000000..d5550aa --- /dev/null +++ b/server/src/app/services/demo_company_simulation_seed.py @@ -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}", + ) diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 9e881ca..b49ccb9 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -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: diff --git a/server/src/app/services/expense_claim_application_handoff.py b/server/src/app/services/expense_claim_application_handoff.py index eecf709..6f90fe8 100644 --- a/server/src/app/services/expense_claim_application_handoff.py +++ b/server/src/app/services/expense_claim_application_handoff.py @@ -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( diff --git a/server/src/app/services/expense_claim_draft_flow.py b/server/src/app/services/expense_claim_draft_flow.py index b213fe8..03b474a 100644 --- a/server/src/app/services/expense_claim_draft_flow.py +++ b/server/src/app/services/expense_claim_draft_flow.py @@ -327,7 +327,11 @@ class ExpenseClaimDraftFlowMixin: ) self._sync_claim_from_items(claim) elif skip_primary_item: - self._sync_application_link_draft_without_items(claim) + 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( claim=claim, @@ -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) + return True @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, diff --git a/server/src/app/services/expense_claim_item_sync.py b/server/src/app/services/expense_claim_item_sync.py index 1e39050..9d6c559 100644 --- a/server/src/app/services/expense_claim_item_sync.py +++ b/server/src/app/services/expense_claim_item_sync.py @@ -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: diff --git a/server/src/app/services/expense_claim_ontology_resolvers.py b/server/src/app/services/expense_claim_ontology_resolvers.py index 7d5abea..5e69472 100644 --- a/server/src/app/services/expense_claim_ontology_resolvers.py +++ b/server/src/app/services/expense_claim_ontology_resolvers.py @@ -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], diff --git a/server/src/app/services/expense_claim_status_registry.py b/server/src/app/services/expense_claim_status_registry.py new file mode 100644 index 0000000..a4e47ed --- /dev/null +++ b/server/src/app/services/expense_claim_status_registry.py @@ -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 diff --git a/server/src/app/services/finance_dashboard.py b/server/src/app/services/finance_dashboard.py index 4eca971..5310978 100644 --- a/server/src/app/services/finance_dashboard.py +++ b/server/src/app/services/finance_dashboard.py @@ -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"}: - continue - for label in self._claim_risk_labels(claim): - buckets[label] += 1 + for claim in claims: + status = self._status(claim) + if status in {"draft", "deleted"}: + continue + 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( - { - "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), - } + return [ + { + "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() diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 25fdaf3..c2af6d8 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -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[^|\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[\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, diff --git a/server/src/app/services/user_agent_application_summary.py b/server/src/app/services/user_agent_application_summary.py new file mode 100644 index 0000000..811e06d --- /dev/null +++ b/server/src/app/services/user_agent_application_summary.py @@ -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) diff --git a/server/src/app/services/user_agent_review_slots.py b/server/src/app/services/user_agent_review_slots.py index 74edef2..e5c092b 100644 --- a/server/src/app/services/user_agent_review_slots.py +++ b/server/src/app/services/user_agent_review_slots.py @@ -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 diff --git a/server/tests/test_demo_company_simulation_seed.py b/server/tests/test_demo_company_simulation_seed.py new file mode 100644 index 0000000..bb0443d --- /dev/null +++ b/server/tests/test_demo_company_simulation_seed.py @@ -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 diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index ead2f55..007c763 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -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 diff --git a/server/tests/test_expense_claim_status_registry.py b/server/tests/test_expense_claim_status_registry.py new file mode 100644 index 0000000..ebcc6b2 --- /dev/null +++ b/server/tests/test_expense_claim_status_registry.py @@ -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 diff --git a/server/tests/test_finance_dashboard_service.py b/server/tests/test_finance_dashboard_service.py index 8502e80..5a03524 100644 --- a/server/tests/test_finance_dashboard_service.py +++ b/server/tests/test_finance_dashboard_service.py @@ -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 diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index 3b9d33e..031c76d 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -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" diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index a0ea2a0..cd74cdd 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -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 diff --git a/web/src/assets/styles/views/login-view.css b/web/src/assets/styles/views/login-view.css index 839f33d..6fec840 100644 --- a/web/src/assets/styles/views/login-view.css +++ b/web/src/assets/styles/views/login-view.css @@ -2,316 +2,495 @@ position: relative; min-height: var(--desktop-stage-height, 100dvh); display: grid; - grid-template-columns: minmax(760px, 1fr) minmax(420px, 540px); + grid-template-columns: minmax(620px, .96fr) minmax(520px, .84fr); + justify-content: center; align-items: center; - gap: clamp(28px, 3.6vw, 58px); - padding: 40px clamp(42px, 4vw, 58px) 30px; + gap: clamp(32px, 4.8vw, 76px); + padding: 48px clamp(40px, 5vw, 86px); overflow: hidden; background: - linear-gradient(112deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.09), transparent 38%), - linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%); + linear-gradient(120deg, rgba(var(--theme-primary-rgb, 58, 124, 165), .10), transparent 34%), + linear-gradient(105deg, #f8fafc 0%, #f5faff 44%, #f8fafc 100%); } .login-page::before { content: ""; position: absolute; inset: 0; + z-index: 0; background: - linear-gradient(90deg, rgba(15, 23, 42, 0.032) 1px, transparent 1px), - linear-gradient(0deg, rgba(15, 23, 42, 0.028) 1px, transparent 1px); - background-size: 72px 72px; - mask-image: linear-gradient(105deg, rgba(0, 0, 0, 0.56), rgba(0, 0, 0, 0.14)); + linear-gradient(90deg, rgba(15,23,42,.045) 1px, transparent 1px), + linear-gradient(0deg, rgba(15,23,42,.04) 1px, transparent 1px), + radial-gradient(circle at 28% 72%, rgba(var(--theme-primary-rgb, 58, 124, 165), .10), transparent 28%), + radial-gradient(circle at 75% 22%, rgba(37,99,235,.06), transparent 30%); + background-size: 72px 72px, 72px 72px, auto, auto; + mask-image: linear-gradient(100deg, rgba(0,0,0,.7), rgba(0,0,0,.32) 48%, rgba(0,0,0,.16)); pointer-events: none; } -.login-visual, -.login-panel { - position: relative; - z-index: 1; +.login-page::after { + content: ""; + position: absolute; + left: -9vw; + top: 13vh; + z-index: 0; + width: min(820px, 58vw); + height: min(560px, 64vh); + border: 1px solid rgba(148,163,184,.22); + border-radius: 8px; + background: + linear-gradient(90deg, transparent 0 28%, rgba(15,23,42,.055) 28% calc(28% + 1px), transparent calc(28% + 1px)), + repeating-linear-gradient(0deg, transparent 0 35px, rgba(15,23,42,.05) 36px), + linear-gradient(135deg, rgba(255,255,255,.74), rgba(var(--theme-primary-rgb, 58, 124, 165), .10)); + box-shadow: 0 34px 80px rgba(15,23,42,.08); + transform: rotate(-7deg); + pointer-events: none; } -.login-visual { - min-height: min(900px, calc(var(--desktop-stage-height, 100dvh) - 70px)); - display: grid; - grid-template-columns: minmax(360px, 0.96fr) minmax(420px, 1.04fr); - grid-template-rows: auto auto minmax(230px, 1fr) auto auto; - column-gap: clamp(16px, 2vw, 34px); - align-items: start; - animation: loginVisualIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both; -} - -.visual-brand, -.visual-copy, -.visual-feature-list, -.visual-main-asset, -.visual-chart-asset, -.visual-footer { - position: relative; -} - -.visual-brand { - grid-column: 1 / 3; +.page-brand { + position: absolute; + top: 38px; + left: clamp(42px, 6vw, 86px); + z-index: 2; display: inline-flex; - width: fit-content; align-items: center; gap: 10px; - color: #0f172a; - font-size: 18px; - font-weight: 800; - z-index: 3; + color: #111827; + font-size: 22px; + font-weight: 900; } :deep(.logo-mark) { - width: 26px; - height: 26px; + width: 34px; + height: 34px; display: inline-grid; place-items: center; color: var(--theme-primary-active); } :deep(.logo-mark svg) { - width: 26px; - height: 26px; + width: 34px; + height: 34px; fill: currentColor; } -.visual-copy { - grid-column: 1 / 3; - grid-row: 2 / 3; - width: min(660px, 70%); - margin-top: 86px; - z-index: 3; -} - -.visual-copy p, -.visual-copy h1, -.visual-copy span { - margin: 0; -} - -.visual-copy p { - color: #0f2f56; - font-size: 24px; - line-height: 1.25; - font-weight: 900; -} - -.visual-copy h1 { - width: 100%; - margin-top: 12px; - color: #0f2f56; - font-size: clamp(29px, 2.2vw, 35px); - line-height: 1.28; - font-weight: 900; - letter-spacing: 0; -} - -.visual-copy span { - display: block; - width: min(420px, 100%); - margin-top: 18px; - color: #475569; - font-size: 15px; - line-height: 1.85; -} - -.visual-feature-list { - grid-column: 1 / 2; - grid-row: 3 / 4; - display: grid; - gap: 24px; - margin-top: 46px; - z-index: 3; -} - -.visual-feature-list article { - display: grid; - grid-template-columns: 54px minmax(0, 1fr); - gap: 18px; - align-items: center; -} - -.visual-feature-icon { - width: 50px; - height: 50px; - display: grid; - place-items: center; - border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16); - border-radius: 4px; - background: rgba(255, 255, 255, 0.62); - color: var(--theme-primary-active); - box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); -} - -.visual-feature-icon .el-icon { - font-size: 25px; -} - -.visual-feature-list strong { - display: block; - color: #0f2f56; - font-size: 15px; - line-height: 1.45; - font-weight: 900; -} - -.visual-feature-list p { - margin: 5px 0 0; - color: #64748b; - font-size: 12px; - line-height: 1.65; -} - -.visual-main-asset { - grid-column: 2 / 3; - grid-row: 2 / 5; - align-self: start; - width: min(570px, 118%); - justify-self: end; - margin-top: 62px; - object-fit: contain; - filter: saturate(0.9) contrast(1.02); +.hero { + position: relative; z-index: 1; + align-self: stretch; + display: grid; + align-content: center; + justify-items: start; + padding-top: 40px; + transform: translateX(34px); } -.visual-chart-asset { - grid-column: 1 / 3; - grid-row: 4 / 5; - align-self: end; - justify-self: start; - width: min(590px, 64%); - margin: 28px 0 30px; - object-fit: contain; - filter: saturate(0.9) contrast(1.02); - z-index: 2; +.eyebrow-text { + color: var(--theme-primary-active); + font-size: 14px; + font-weight: 900; + letter-spacing: .08em; + text-transform: uppercase; } -.login-visual::after { +.hero h1 { + margin-top: 16px; + color: #0f172a; + font-size: clamp(38px, 3.8vw, 54px); + line-height: 1.12; + font-weight: 950; +} + +.hero-lead { + margin-top: 14px; + color: #111827; + font-size: clamp(23px, 2.15vw, 31px); + font-weight: 800; +} + +.hero-sub { + margin-top: 14px; + color: #64748b; + font-size: 16px; + font-weight: 600; +} + +.hero-stage { + position: relative; + width: min(760px, 100%); + height: 350px; + margin-top: 22px; + margin-left: 0; +} + +.hero-stage::before { content: ""; position: absolute; - right: 0; - top: 42px; - width: min(560px, 55%); - height: 78%; - background: linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.20), rgba(var(--theme-secondary-rgb, 79, 111, 159), 0.06)); - mix-blend-mode: color; - mask-image: radial-gradient(ellipse at 62% 50%, #000 0%, rgba(0, 0, 0, 0.76) 48%, transparent 78%); - opacity: 0.36; - z-index: 0; - pointer-events: none; + left: 285px; + bottom: 38px; + width: 230px; + height: 62px; + border-radius: 50%; + background: linear-gradient(90deg, rgba(var(--theme-primary-rgb, 58, 124, 165), .14), rgba(37,99,235,.08)); + filter: blur(4px); } -.visual-footer { - grid-column: 1 / 3; - grid-row: 5 / 6; - align-self: end; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 14px; +.flow-line { + position: absolute; + z-index: 0; + display: block; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), .22); + border-left: 0; + border-bottom: 0; + border-radius: 0 8px 0 0; +} + +.flow-line::after { + content: ""; + position: absolute; + right: -3px; + top: -4px; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--theme-primary); + box-shadow: 0 0 0 5px rgba(var(--theme-primary-rgb, 58, 124, 165), .12); +} + +.flow-a { + left: 190px; + top: 76px; + width: 170px; + height: 72px; +} + +.flow-b { + left: 190px; + bottom: 96px; + width: 142px; + height: 82px; + transform: scaleY(-1); +} + +.flow-c { + right: 182px; + top: 96px; + width: 132px; + height: 70px; + transform: scaleX(-1); +} + +.metric-card, +.document-card, +.round-badge { + position: absolute; + border: 1px solid rgba(215, 224, 234, .86); + background: rgba(255,255,255,.78); + box-shadow: 0 18px 36px rgba(65, 88, 110, .10); + backdrop-filter: blur(16px); +} + +.metric-card { + z-index: 2; + width: 166px; + min-height: 110px; + display: grid; + gap: 7px; + padding: 17px 18px; + border-radius: 8px; +} + +.metric-card span { + color: #334155; + font-size: 13px; + font-weight: 800; +} + +.metric-card strong { + color: #0f172a; + font-size: 25px; + line-height: 1; + font-weight: 900; +} + +.metric-card small { color: #64748b; font-size: 12px; - z-index: 3; + font-weight: 700; } -.visual-footer i { - width: 1px; - height: 14px; - background: #cbd5e1; +.up { color: var(--success); } +.danger { color: #ef4444; } + +.amount { left: 20px; top: 20px; } +.risk { left: 42px; bottom: 24px; } +.audit { right: 22px; top: 24px; } +.sla { right: 40px; bottom: 20px; } + +.mini-bars { + height: 30px; + display: flex; + align-items: end; + gap: 6px; + margin-top: 2px; } -.login-panel { +.mini-bars i { + width: 14px; + border-radius: 4px 4px 0 0; + background: linear-gradient(180deg, #93c5fd, #dbeafe); +} +.mini-bars i:nth-child(1) { height: 11px; } +.mini-bars i:nth-child(2) { height: 18px; } +.mini-bars i:nth-child(3) { height: 24px; } +.mini-bars i:nth-child(4) { height: 32px; } + +.document-card { + z-index: 1; + left: 286px; + top: 44px; + width: 220px; + height: 214px; + padding: 28px 28px; + border-radius: 8px; + transform: rotate(2deg); +} + +.document-card span { + color: #1e293b; + font-size: 18px; + font-weight: 900; +} + +.document-card > i { + display: block; + height: 10px; + margin-top: 22px; + border-radius: 999px; + background: #e4ebf5; +} +.document-card > i:nth-of-type(2) { width: 78%; margin-top: 16px; } +.document-card > i:nth-of-type(3) { width: 54%; margin-top: 16px; } + +.doc-check { + position: absolute; + right: -16px; + bottom: -12px; + width: 54px; + height: 54px; display: grid; + place-items: center; + border-radius: 999px; + background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); + color: #fff; + font-size: 27px; + box-shadow: 0 14px 28px rgba(var(--theme-primary-rgb, 58, 124, 165), .22); +} + +.shield-art { + position: absolute; + z-index: 3; + left: 316px; + bottom: 0; + width: 155px; + height: 155px; + object-fit: contain; + filter: drop-shadow(0 22px 24px rgba(125, 91, 54, .16)); +} + +.round-badge { + z-index: 4; + width: 58px; + height: 58px; + display: grid; + place-items: center; + border-radius: 999px; + color: #3b82f6; + font-size: 24px; + font-weight: 950; +} + +.round-badge.ai { + left: 258px; + top: 30px; + width: 52px; + height: 52px; + color: #3b82f6; + font-size: 21px; + box-shadow: 0 14px 30px rgba(59,130,246,.14); +} + +.feature-strip { + width: min(760px, 100%); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + margin-top: 18px; + margin-left: 0; +} + +.feature-strip article { + min-height: 78px; + display: grid; + grid-template-columns: 42px minmax(0, 1fr); align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid rgba(215, 224, 234, .82); + border-radius: 6px; + background: rgba(255,255,255,.76); + box-shadow: 0 12px 30px rgba(65, 88, 110, .08); + backdrop-filter: blur(16px); +} + +.feature-strip article > span { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 11px; + font-size: 21px; +} + +.feature-strip .primary { background: var(--theme-primary-soft); color: var(--theme-primary-active); } +.feature-strip .red { background: #fee2e2; color: #ef4444; } +.feature-strip .blue { background: #dbeafe; color: #3b82f6; } + +.feature-strip strong { + color: #0f172a; + font-size: 15px; + font-weight: 900; +} + +.feature-strip p { + display: block; + margin-top: 3px; + color: #64748b; + font-size: 11.5px; + line-height: 1.45; } .login-card { + position: relative; + z-index: 1; width: 100%; - min-height: 748px; - display: grid; - align-content: start; + max-width: 560px; justify-self: center; - padding: 56px 56px 38px; - border: 1px solid rgba(203, 213, 225, 0.88); - border-radius: 4px; - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 24px 68px rgba(15, 23, 42, 0.10); - backdrop-filter: blur(16px); - animation: loginCardIn 420ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both; + display: grid; + padding: 58px 60px 44px; + border: 1px solid rgba(215, 224, 234, .96); + border-radius: 8px; + background: rgba(255,255,255,.86); + box-shadow: 0 24px 64px rgba(65, 88, 110, .16); + backdrop-filter: blur(18px); } .card-brand { display: inline-flex; align-items: center; - gap: 10px; + justify-content: center; + gap: 12px; color: #0f172a; - font-size: 15px; - font-weight: 800; -} - -.card-brand :deep(.logo-mark), -.card-brand :deep(.logo-mark svg) { - width: 22px; - height: 22px; + font-size: 22px; + font-weight: 950; } .card-head { - margin-top: 34px; + margin-top: 24px; + text-align: center; } .card-head h2 { - margin: 0; color: #0f172a; - font-size: 32px; - line-height: 1.18; - font-weight: 900; - letter-spacing: 0; + font-size: 34px; + line-height: 1.15; + font-weight: 950; } .card-head p { - margin: 10px 0 0; - color: #475569; + margin-top: 12px; + color: #64748b; font-size: 16px; - line-height: 1.5; } .login-form { display: grid; - gap: 18px; - margin-top: 32px; + gap: 16px; + margin-top: 30px; } -.form-field { - display: grid; +.field { + position: relative; + display: flex; + align-items: center; + min-height: 52px; } -.login-input, -.login-select { +.field > .mdi { + position: absolute; + left: 16px; + color: #64748b; + font-size: 19px; +} + +.field input, +.field select { width: 100%; -} - -:deep(.login-input .el-input__wrapper), -:deep(.login-select .el-select__wrapper) { - min-height: 50px; - border-radius: 4px; - background: rgba(255, 255, 255, 0.96); -} - -:deep(.login-input .el-input__inner), -:deep(.login-select .el-select__selected-item) { + height: 52px; + padding: 0 50px 0 48px; + border: 1px solid #d7e0ea; + border-radius: 8px; + background: rgba(255,255,255,.86); color: #0f172a; - font-size: 14px; - font-weight: 500; + font-size: 15px; + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; } -:deep(.login-input .el-input__inner::placeholder) { +.field select { + appearance: none; + cursor: pointer; +} + +.field input::placeholder { color: #94a3b8; } -:deep(.login-input .el-input__prefix), -:deep(.login-input .el-input__suffix), -:deep(.login-select .el-select__suffix) { - color: #94a3b8; +.field input:focus, +.field select:focus { + border-color: var(--theme-primary); + background: #fff; + box-shadow: 0 0 0 3px var(--theme-focus-ring, rgba(58, 124, 165, .14)); + outline: none; +} + +.field-select-chevron { + position: absolute; + right: 12px; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 8px; + color: #64748b; + pointer-events: none; +} + +.field-icon-btn { + position: absolute; + right: 12px; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: #64748b; +} + +.field-icon-btn:hover { + background: #f1f5f9; + color: var(--theme-primary-active); } .form-meta { @@ -319,76 +498,119 @@ align-items: center; justify-content: space-between; gap: 16px; - min-height: 28px; -} - -:deep(.login-checkbox .el-checkbox__label) { - color: #334155; - font-size: 13px; - font-weight: 600; -} - -.link-button { - min-height: 28px; - padding: 0; - border: 0; - background: transparent; - color: var(--theme-primary-active); - font-size: 13px; - font-weight: 700; - cursor: pointer; -} - -.link-button:hover { - color: var(--theme-primary-hover); + margin-top: 2px; } .login-error { - margin: 0; - padding: 10px 12px; - border: 1px solid rgba(var(--danger-rgb, 220, 38, 38), 0.22); - border-radius: 4px; - background: var(--danger-soft, #fef2f2); - color: var(--danger-active, #991b1b); + padding: 12px 14px; + border: 1px solid rgba(239, 68, 68, .18); + border-radius: 8px; + background: #fef2f2; + color: #b91c1c; font-size: 13px; line-height: 1.55; } -.login-submit, -.login-sso { - width: 100%; - min-height: 50px; - border-radius: 4px; - font-weight: 800; +.remember { + display: inline-flex; + align-items: center; + gap: 8px; + color: #334155; + font-size: 14px; } -.login-submit { +.remember input { + width: 16px; + height: 16px; + accent-color: var(--theme-primary); +} + +.link-btn { + border: 0; + background: transparent; + color: #2563eb; + font-size: 14px; + font-weight: 700; +} + +.submit-btn, +.sso-btn { + height: 52px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + border-radius: 8px; + font-size: 17px; + font-weight: 900; +} + +.submit-btn { margin-top: 4px; - box-shadow: 0 14px 28px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.20); + border: 0; + background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); + color: #fff; + box-shadow: 0 16px 30px rgba(var(--theme-primary-rgb, 58, 124, 165), .20); } -.login-sso { - margin-left: 0; +.submit-btn:hover { + background: linear-gradient(135deg, var(--theme-primary-hover), var(--theme-primary-active)); +} + +.submit-btn:disabled, +.sso-btn:disabled { + opacity: .6; + cursor: not-allowed; + box-shadow: none; +} + +.divider { + position: relative; + display: grid; + place-items: center; + height: 28px; + color: #94a3b8; + font-size: 13px; +} + +.divider::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + background: #e2e8f0; +} + +.divider span { + position: relative; + padding: 0 16px; + background: rgba(255,255,255,.9); +} + +.sso-btn { + border: 1px solid var(--theme-primary); + background: rgba(255,255,255,.78); color: var(--theme-primary-active); } +.sso-btn:hover { + background: var(--theme-primary-soft); +} + .security-note { - justify-self: center; - margin-top: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 34px; color: #64748b; - font-size: 12px; - line-height: 1.7; - text-align: center; + font-size: 13px; } -.security-note button { - padding: 0; - border: 0; - background: transparent; - color: var(--theme-primary-active); - font: inherit; - font-weight: 700; - cursor: pointer; +.security-note .mdi { + color: #94a3b8; } .sr-only { @@ -403,182 +625,46 @@ border: 0; } -@keyframes loginVisualIn { - from { - opacity: 0; - transform: translateX(-18px); - } - - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes loginCardIn { - from { - opacity: 0; - transform: scale3d(0.985, 0.985, 1) translateY(12px); - } - - to { - opacity: 1; - transform: scale3d(1, 1, 1) translateY(0); - } -} - -@media (min-width: 1321px) { +@media (max-width: 1280px) { .login-page { - --login-edge: clamp(42px, 3.6vw, 56px); - --login-card-width: 535px; - --login-card-left: min(calc(100vw - var(--login-card-width) - var(--login-edge)), 1002px); - --login-visual-top: 56px; - --login-visual-width: calc(var(--login-card-left) - var(--login-edge) - 24px); - display: block; - padding: 0; + grid-template-columns: minmax(520px, 1fr) minmax(480px, 540px); + gap: 44px; + padding-inline: 48px; } - .login-visual { - position: absolute; - left: var(--login-edge); - top: var(--login-visual-top); - width: var(--login-visual-width); - height: calc(var(--desktop-stage-height, 100dvh) - 86px); - min-height: 0; - display: block; + .hero-stage { + transform: scale(.88); + transform-origin: left center; + margin-bottom: -32px; } - .login-panel { - position: absolute; - left: var(--login-card-left); - top: clamp(96px, 11.3vh, 112px); - width: var(--login-card-width); - display: block; + .feature-strip { + width: 520px; + gap: 14px; + margin-left: 0; + } + + .login-card { max-width: 500px; } +} + +@media (max-height: 840px) and (min-width: 981px) { + .hero { + padding-top: 18px; + } + + .hero-stage { + margin-top: 16px; + transform: scale(.9); + transform-origin: left center; + margin-bottom: -22px; } .login-card { - width: 100%; - height: min(748px, calc(var(--desktop-stage-height, 100dvh) - 80px)); - min-height: 0; - } - - .visual-brand { - position: absolute; - left: 0; - top: 0; - } - - .visual-copy { - position: absolute; - left: 0; - top: 120px; - width: min(660px, 72%); - margin-top: 0; - } - - .visual-feature-list { - position: absolute; - left: 0; - top: 312px; - width: 380px; - margin-top: 0; - } - - .visual-main-asset { - position: absolute; - left: 392px; - top: 86px; - width: min(585px, calc(var(--login-visual-width) - 360px)); - margin-top: 0; - } - - .visual-chart-asset { - position: absolute; - left: 0; - top: 619px; - width: min(585px, 66%); - margin: 0; - } - - .login-visual::after { - right: -4px; - top: 84px; - width: min(620px, 58%); - height: 70%; - } - - .visual-footer { - position: absolute; - left: 0; - right: 0; - bottom: 14px; - } -} - -@media (max-width: 1320px) { - .login-page { - grid-template-columns: minmax(640px, 1fr) minmax(410px, 500px); - gap: 28px; - padding-inline: 38px; - } - - .login-visual { - grid-template-columns: minmax(320px, 0.92fr) minmax(380px, 1.08fr); - } - - .visual-copy { - margin-top: 72px; - } - - .visual-main-asset { - width: min(540px, 116%); - } - - .visual-chart-asset { - width: min(560px, 68%); - } - - .login-card { - min-height: 600px; - padding: 44px 44px 30px; - } -} - -@media (max-height: 820px) and (min-width: 981px) { - .login-page { - padding-block: 28px 22px; - } - - .login-visual { - min-height: 620px; - } - - .visual-copy { - margin-top: 58px; - } - - .visual-feature-list { - gap: 18px; - margin-top: 34px; - } - - .visual-main-asset { - width: min(500px, 108%); - margin-top: 46px; - } - - .visual-chart-asset { - width: min(540px, 66%); - margin-block: 18px 18px; - } - - .login-card { - min-height: 560px; padding-block: 38px 28px; } .card-head { - margin-top: 24px; + margin-top: 18px; } .login-form { @@ -587,7 +673,7 @@ } .security-note { - margin-top: 28px; + margin-top: 24px; } } @@ -595,169 +681,43 @@ .login-page { min-height: var(--desktop-stage-height, 100dvh); grid-template-columns: 1fr; - align-content: start; - gap: 18px; - padding: 18px 22px 24px; - overflow-x: hidden; - overflow-y: auto; + padding: 92px 28px 28px; + overflow: auto; } - .login-visual { - min-height: 0; - grid-template-columns: 1fr; - grid-template-rows: auto auto auto auto auto auto; - row-gap: 18px; + .page-brand { + top: 24px; + left: 24px; } - .visual-brand, - .visual-copy, - .visual-feature-list, - .visual-main-asset, - .visual-chart-asset, - .visual-footer { - grid-column: 1; - } - - .visual-brand { - grid-row: 1; - } - - .visual-copy { - grid-row: 2; - width: 100%; - margin-top: 12px; - } - - .visual-main-asset { - grid-row: 3; - width: min(520px, 100%); - margin-top: 0; - justify-self: center; - } - - .visual-feature-list { - grid-row: 4; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - margin-top: 0; - } - - .visual-feature-list article { - grid-template-columns: 34px minmax(0, 1fr); - gap: 9px; - padding: 10px; - border: 1px solid rgba(203, 213, 225, 0.62); - border-radius: 4px; - background: rgba(255, 255, 255, 0.66); - } - - .visual-feature-icon { - width: 32px; - height: 32px; - } - - .visual-feature-icon .el-icon { - font-size: 18px; - } - - .visual-feature-list p { + .hero { display: none; - } - - .visual-chart-asset { - grid-row: 5; - width: min(520px, 100%); - margin: 0; - justify-self: center; - } - - .visual-footer { - grid-row: 6; - } - - .login-visual::after { - top: auto; - bottom: 86px; - width: min(520px, 100%); - height: 42%; + transform: none; } .login-card { max-width: 520px; - min-height: 0; - padding: 30px 24px 26px; - } - - .card-brand { - display: none; - } - - .card-head { - margin-top: 0; - } - - .card-head h2 { - font-size: 28px; + padding: 40px 28px 30px; } } -@media (max-width: 620px) { +@media (max-width: 520px) { .login-page { - padding-inline: 16px; - } - - .visual-copy h1 { - font-size: 27px; - } - - .visual-feature-list { - grid-template-columns: 1fr; - } - - .visual-feature-list p { - display: block; + padding-inline: 22px; } .login-card { - padding: 24px 18px 20px; - } - - .login-form { - gap: 13px; - margin-top: 22px; - } - - :deep(.login-input .el-input__wrapper), - :deep(.login-select .el-select__wrapper), - .login-submit, - .login-sso { - min-height: 44px; - } -} - -@media (max-width: 420px) { - .visual-copy p { - font-size: 21px; - } - - .visual-copy h1 { - font-size: 24px; + padding: 32px 22px 24px; + border-radius: 8px; } .card-head h2 { - font-size: 25px; + font-size: 30px; } .form-meta { align-items: flex-start; flex-direction: column; - gap: 8px; - } -} - -@media (prefers-reduced-motion: reduce) { - .login-visual, - .login-card { - animation: none; + gap: 10px; } } diff --git a/web/src/assets/styles/views/overview-view.css b/web/src/assets/styles/views/overview-view.css index 3f9c5c5..edee97c 100644 --- a/web/src/assets/styles/views/overview-view.css +++ b/web/src/assets/styles/views/overview-view.css @@ -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; } diff --git a/web/src/components/charts/TrendChart.vue b/web/src/components/charts/TrendChart.vue index 9a36a62..6f76bcf 100644 --- a/web/src/components/charts/TrendChart.vue +++ b/web/src/components/charts/TrendChart.vue @@ -1,9 +1,7 @@