feat(expenses): add authoritative pre-review workflow
This commit is contained in:
@@ -210,6 +210,10 @@
|
|||||||
#### 风险与预审
|
#### 风险与预审
|
||||||
|
|
||||||
- 统一输出事实、规则、证据、风险、建议和可执行修复动作。
|
- 统一输出事实、规则、证据、风险、建议和可执行修复动作。
|
||||||
|
- 报销提交必须先完成服务端预审握手。预审结果至少包含稳定 `review_id`、输入指纹、规则集指纹、流水线版本、`ready / needs_fix / ready_with_review` 决策、结构化发现和修复动作;客户端不得仅凭旧的 `passed` 标记提交。
|
||||||
|
- 输入字段、明细、票据、风险事实或规则集发生变化时,旧预审自动失效并在提交事务内重算;同一输入和规则集重复预审复用相同 `review_id`,避免生成重复事件。
|
||||||
|
- 只有 high/critical 且 `fixable_by_submitter` 的未解决风险阻断提交;预算治理、领导判断和财务复核风险随单进入对应审批角色,不把所有高风险简单放行或全部拦截。
|
||||||
|
- `needs_fix` 必须在预算占用和 `claim_submitted` 之前阻断,保持草稿状态并返回 HTTP 409 结构化整改信息;可提交结果的预审事件与提交事件共享 correlation,并通过 causation 形成可追溯链路。
|
||||||
- 使用已确认正/负样本校准误报与漏检;规则或 Prompt 发布前运行 golden case。
|
- 使用已确认正/负样本校准误报与漏检;规则或 Prompt 发布前运行 golden case。
|
||||||
- 新策略先进入 shadow,再 Canary,最后按动作开放自动化。
|
- 新策略先进入 shadow,再 Canary,最后按动作开放自动化。
|
||||||
|
|
||||||
@@ -250,6 +254,7 @@
|
|||||||
- `expense_case_created`
|
- `expense_case_created`
|
||||||
- `historical_claim_imported`:迁移前旧单的当前快照;只证明已纳入统一费用事件,不重建或伪造迁移前审批历史。
|
- `historical_claim_imported`:迁移前旧单的当前快照;只证明已纳入统一费用事件,不重建或伪造迁移前审批历史。
|
||||||
- `application_generated` / `application_submitted` / `application_approved`
|
- `application_generated` / `application_submitted` / `application_approved`
|
||||||
|
- `application_pre_review_completed` / `claim_pre_review_completed`
|
||||||
- `receipt_received` / `receipt_verified` / `ocr_corrected`
|
- `receipt_received` / `receipt_verified` / `ocr_corrected`
|
||||||
- `field_suggested` / `field_accepted` / `field_edited` / `field_rejected`
|
- `field_suggested` / `field_accepted` / `field_edited` / `field_rejected`
|
||||||
- `attachment_associated` / `draft_saved` / `claim_submitted`
|
- `attachment_associated` / `draft_saved` / `claim_submitted`
|
||||||
@@ -529,6 +534,13 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
|
|||||||
|
|
||||||
## 本轮实现记录
|
## 本轮实现记录
|
||||||
|
|
||||||
|
- 2026-07-16(P0 提交前预审握手):新增稳定预审决策契约、输入/规则/动态 findings 指纹和结构化整改动作;申请与报销提交前均由服务端重新计算完整风险上下文,可整改重大风险在预算占用前返回 409,握手期间风险变化返回 `PRE_REVIEW_CHANGED`,预算治理和人工判断风险继续进入领导与 P8。
|
||||||
|
- 2026-07-16(P0 费用事件续接):新增 `application_pre_review_completed` / `claim_pre_review_completed` 时间线语义,预审与提交共享 correlation/causation;票据归集完成后刷新预审,申请批准生成空报销草稿时 Expense Case 保持 `approved_to_spend`,首张票据归集后再进入 `claiming`。
|
||||||
|
- 2026-07-16(租户与路由修复):AI 草稿、申请预览、Steward 和差旅测算透传真实租户;Claim 核心访问和关联草稿后台任务按 CaseLink 与服务端租户隔离;仅未整改的人工判断/预算治理高风险申请进入同部门 P8,已解决风险不再重复升级。
|
||||||
|
- 2026-07-16(结构化风险回填):409 findings 保留原始 source、business stage、risk domain 与 actionability,前端即使详情刷新失败也能生成可见整改卡,不再被 `ai_pre_review` 汇总过滤规则丢弃。
|
||||||
|
- 2026-07-16(工程拆分):提取申请关联、职级标准调整、预审端点、风险清单指纹与租户查询范围职责,所有本轮触及的核心服务、端点和前端提交模块均回到 800 行硬上限以内。
|
||||||
|
- 2026-07-16(验证):容器内费用服务与审批路由 126 项、费用事件/接口/租户/票据关联 63 项、报销接口全量 20 项和前端预审/时间线 24 项通过;Vite 生产构建、Python compileall、关键未定义符号检查与 `git diff --check` 通过。
|
||||||
|
|
||||||
- 2026-07-13:完成产品能力、端到端费用旅程、AI 学习飞轮、KPI 和商业模式分析,形成总功能概念文档。
|
- 2026-07-13:完成产品能力、端到端费用旅程、AI 学习飞轮、KPI 和商业模式分析,形成总功能概念文档。
|
||||||
- 2026-07-13(规划阶段):只沉淀功能规划,没有修改业务代码、数据库结构或现有接口。
|
- 2026-07-13(规划阶段):只沉淀功能规划,没有修改业务代码、数据库结构或现有接口。
|
||||||
- 2026-07-13(P0 基础切片):新增 `ExpenseCase`、`ExpenseCaseLink`、`BusinessEvent` 模型与 `ExpenseCaseService`,提供 `/api/v1/expense-cases/by-claim/{claim_id}` 时间线查询接口。
|
- 2026-07-13(P0 基础切片):新增 `ExpenseCase`、`ExpenseCaseLink`、`BusinessEvent` 模型与 `ExpenseCaseService`,提供 `/api/v1/expense-cases/by-claim/{claim_id}` 时间线查询接口。
|
||||||
|
|||||||
@@ -73,9 +73,16 @@
|
|||||||
- [x] [CONCEPT: 业务事件与 AI 决策] 新增服务端预览决策表、认证签发接口和正式 Decision 关联迁移。
|
- [x] [CONCEPT: 业务事件与 AI 决策] 新增服务端预览决策表、认证签发接口和正式 Decision 关联迁移。
|
||||||
证据:`20260714_0004_ai_application_preview_decisions.py`、`expense_application_previews.py`、`reimbursements.py`;动作消费与 Claim、Case、Business Event 和学习三表同事务,跨登录会话重放被拒绝;独立版本密钥生成的 HMAC 指纹不保存低熵字段明文,并编码字段存在性。只有从未成功签发的旧调用可无 ID 降级,当前会话已有有效决策时省略 ID 会返回 409。
|
证据:`20260714_0004_ai_application_preview_decisions.py`、`expense_application_previews.py`、`reimbursements.py`;动作消费与 Claim、Case、Business Event 和学习三表同事务,跨登录会话重放被拒绝;独立版本密钥生成的 HMAC 指纹不保存低熵字段明文,并编码字段存在性。只有从未成功签发的旧调用可无 ID 降级,当前会话已有有效决策时省略 ID 会返回 409。
|
||||||
- [ ] [CONCEPT: 数据与契约] 为申请、票据、草稿、提交、退回、审批、付款和归档接入统一 correlation ID。
|
- [ ] [CONCEPT: 数据与契约] 为申请、票据、草稿、提交、退回、审批、付款和归档接入统一 correlation ID。
|
||||||
|
当前进度:报销显式预审与提交已共享 correlation/causation,AI 草稿和票据归集已透传租户与稳定幂等键;申请、审批、付款和外部连接器仍需继续统一。
|
||||||
- [ ] [CONCEPT: 业务事件与 AI 决策] 建立事务 Outbox:申请、提交、退回、审批、支付和入账状态与事件同事务提交,消费端按事件 ID 幂等处理。
|
- [ ] [CONCEPT: 业务事件与 AI 决策] 建立事务 Outbox:申请、提交、退回、审批、支付和入账状态与事件同事务提交,消费端按事件 ID 幂等处理。
|
||||||
- [x] [CONCEPT: 业务事件与 AI 决策] 完成首批草稿、提交、退回、审批、申请转报销、付款和申请归档事件旁写,具备 correlation、causation、幂等键及事务回滚。
|
- [x] [CONCEPT: 业务事件与 AI 决策] 完成首批草稿、提交、退回、审批、申请转报销、付款和申请归档事件旁写,具备 correlation、causation、幂等键及事务回滚。
|
||||||
证据:`expense_claim_draft_flow.py`、`expense_claims.py`、`expense_claim_approval_flow.py`、`test_expense_case_service.py`;容器测试覆盖同 Case 关联、重复事件去重及 Outbox 失败整体回滚。
|
证据:`expense_claim_draft_flow.py`、`expense_claims.py`、`expense_claim_approval_flow.py`、`test_expense_case_service.py`;容器测试覆盖同 Case 关联、重复事件去重及 Outbox 失败整体回滚。
|
||||||
|
- [x] [CONCEPT: 风险与预审] 建立申请与报销提交前服务端预审握手,使用稳定 review ID、输入/规则/动态结果指纹、三态决策和结构化整改动作阻止旧预审复用。
|
||||||
|
证据:`expense_claim_pre_review_decision.py`、`expense_claim_pre_review.py`、`expense_claims.py`、`reimbursement_pre_review.py`;提交前始终重算完整风险上下文,`needs_fix` 在预算占用前保持草稿并返回结构化 409,握手期间风险变化返回 `PRE_REVIEW_CHANGED`,相同输入、规则和 findings 重试只保留一条预审事件。申请与报销使用同一契约:提交人可整改风险必须先处理,人工判断或预算治理风险才进入领导与 P8。
|
||||||
|
- [x] [CONCEPT: 统一费用事件] 补齐票据归集 → 预审 → 提交切片的阶段与事件衔接,并修复非默认租户在 AI 草稿、预览和 Steward 链路回落到 default 的问题。
|
||||||
|
证据:`expense_receipt_association.py`、`expense_claim_approval_flow.py`、`expense_claim_draft_flow.py`、`expense_claim_review_preview.py`、`steward_action_executor.py`;申请批准生成空报销草稿后 Case 保持 `approved_to_spend`,票据关联后进入 `claiming`,跨租户 Case/Event 不能串线。
|
||||||
|
- [x] [CONCEPT: 权限与安全] 将 Claim 核心查询和关联报销后台任务纳入服务端租户范围,拒绝同用户名跨租户读取、修改、审批或查询任务。
|
||||||
|
证据:`expense_claim_tenant_scope.py`、`expense_claim_access_policy.py`、`linked_reimbursement_draft_jobs.py`;默认租户仅为无 CaseLink 的历史单保留兼容,后台任务强制覆盖客户端 tenant,关联申请按 CaseLink 与租户共同校验。
|
||||||
- [x] [CONCEPT: 业务事件与 AI 决策] 收口 AI 新建申请直接提交入口,统一复用申请提交事务,并消除预算 metadata 检查对外层事务的隐式提交。
|
- [x] [CONCEPT: 业务事件与 AI 决策] 收口 AI 新建申请直接提交入口,统一复用申请提交事务,并消除预算 metadata 检查对外层事务的隐式提交。
|
||||||
证据:`user_agent_application.py`、`budget.py`、`test_reimbursement_endpoints.py`、`test_user_agent_service.py`;容器测试覆盖有效预算预占、`application_submitted` 事件、提交风险标记、事件失败整体回滚及保存草稿无提交副作用。
|
证据:`user_agent_application.py`、`budget.py`、`test_reimbursement_endpoints.py`、`test_user_agent_service.py`;容器测试覆盖有效预算预占、`application_submitted` 事件、提交风险标记、事件失败整体回滚及保存草稿无提交副作用。
|
||||||
- [x] [CONCEPT: 业务事件与 AI 决策] 为 AI 工作台和小财管家申请草稿补齐新建/更新事件、租户透传、事务回滚和动作幂等。
|
- [x] [CONCEPT: 业务事件与 AI 决策] 为 AI 工作台和小财管家申请草稿补齐新建/更新事件、租户透传、事务回滚和动作幂等。
|
||||||
@@ -118,6 +125,8 @@
|
|||||||
证据:附件任务 composable 将待确认视为安全终态,不发送伪成功通知;自动完成提供“查看草稿”,申请候选提供“查看候选申请”,不伪造报销草稿。
|
证据:附件任务 composable 将待确认视为安全终态,不发送伪成功通知;自动完成提供“查看草稿”,申请候选提供“查看候选申请”,不伪造报销草稿。
|
||||||
- [x] [CONCEPT: 零录入附件关联任务契约] 会话刷新后恢复新任务结果、候选和异常状态,旧任务结果仍可正常展示。
|
- [x] [CONCEPT: 零录入附件关联任务契约] 会话刷新后恢复新任务结果、候选和异常状态,旧任务结果仍可正常展示。
|
||||||
证据:`workbenchAiMessageModel.js` 持久化新协议,规范化层兼容旧字段;容器内前端票据关联、协议、卡片、幂等重放、风险复核和会话恢复组合回归 29 项通过。
|
证据:`workbenchAiMessageModel.js` 持久化新协议,规范化层兼容旧字段;容器内前端票据关联、协议、卡片、幂等重放、风险复核和会话恢复组合回归 29 项通过。
|
||||||
|
- [x] [CONCEPT: 风险与预审] 报销详情提交前先执行真实预审,`needs_fix` 直接展示结构化风险并停止提交,其他结果携带 review ID 与输入指纹完成握手。
|
||||||
|
证据:`reimbursements.js`、`api.js`、`useTravelRequestDetailRiskSubmit.js`;错误响应保留 status/code/detail/review,风险可即时回填并触发详情刷新,前端预审、409、请求头、时间线组合测试 24 项和 Vite 生产构建通过。
|
||||||
- [ ] [CONCEPT: 风险与预审] 风险卡提供一键补件、修正、解释和重新预审入口。
|
- [ ] [CONCEPT: 风险与预审] 风险卡提供一键补件、修正、解释和重新预审入口。
|
||||||
- [ ] [CONCEPT: 统一费用事件] 退回后直接定位问题字段,支持断点续办,不要求重新发起对话。
|
- [ ] [CONCEPT: 统一费用事件] 退回后直接定位问题字段,支持断点续办,不要求重新发起对话。
|
||||||
- [ ] [CONCEPT: 移动端] 接通真实登录恢复、路由守卫、报销列表、详情、草稿、上传和审批 API。
|
- [ ] [CONCEPT: 移动端] 接通真实登录恢复、路由守卫、报销列表、详情、草稿、上传和审批 API。
|
||||||
@@ -194,6 +203,7 @@
|
|||||||
- [x] [CONCEPT: 测试方案] 为 AI 新建申请直接提交补充统一事务回归,覆盖提交成功、事件失败回滚和仅保存草稿三条边界。
|
- [x] [CONCEPT: 测试方案] 为 AI 新建申请直接提交补充统一事务回归,覆盖提交成功、事件失败回滚和仅保存草稿三条边界。
|
||||||
证据:容器内直接提交定向测试 3 项、申请提交回归 5 项、预算与 Expense Case 回归 13 项通过;相关 Python 文件 `ruff --select F,I` 通过。
|
证据:容器内直接提交定向测试 3 项、申请提交回归 5 项、预算与 Expense Case 回归 13 项通过;相关 Python 文件 `ruff --select F,I` 通过。
|
||||||
- [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。
|
- [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。
|
||||||
|
当前进度:已补充预审决策确定性、嵌套无序集合、动态 findings 变化、申请/报销统一阻断、P8 全链路、真实预算前阻断、事件幂等/回滚、同用户名租户隔离、后台任务 tenant 伪造和 Case `approved_to_spend → claiming` 阶段测试;学习、自动化和节省仍待后续阶段。
|
||||||
- [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。
|
- [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。
|
||||||
证据:`test_auth_session_endpoints.py`、`test_auth_service.py`、`test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。
|
证据:`test_auth_session_endpoints.py`、`test_auth_service.py`、`test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。
|
||||||
- [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。
|
- [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。
|
||||||
@@ -204,6 +214,7 @@
|
|||||||
证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;tmpfs PostgreSQL 17 中 4 项通过,覆盖空库升级、重复升级、关键约束/索引、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。
|
证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;tmpfs PostgreSQL 17 中 4 项通过,覆盖空库升级、重复升级、关键约束/索引、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。
|
||||||
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
|
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
|
||||||
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
|
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
|
||||||
|
当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通;付款回执、ERP 入账和对账仍未接入。
|
||||||
- [x] [CONCEPT: 测试方案] 跑通首个个人出行方式切片的 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
|
- [x] [CONCEPT: 测试方案] 跑通首个个人出行方式切片的 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
|
||||||
证据:容器内记忆、预览决策、迁移与所有权组合回归 56 项通过、1 项条件跳过;一次性 PostgreSQL 迁移循环 1 项通过;前端申请快速预览、个人记忆、Steward 与会话恢复组合 83 项通过,Vite 生产构建通过,Python Ruff F/I 与 `git diff --check` 通过。
|
证据:容器内记忆、预览决策、迁移与所有权组合回归 56 项通过、1 项条件跳过;一次性 PostgreSQL 迁移循环 1 项通过;前端申请快速预览、个人记忆、Steward 与会话恢复组合 83 项通过,Vite 生产构建通过,Python Ruff F/I 与 `git diff --check` 通过。
|
||||||
- [x] [CONCEPT: 测试方案] 跑通首个行为采集切片:AI 申请预填 → 用户接受/显式修改 → 草稿或提交结果同事务落账。
|
- [x] [CONCEPT: 测试方案] 跑通首个行为采集切片:AI 申请预填 → 用户接受/显式修改 → 草稿或提交结果同事务落账。
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## 修复记录
|
||||||
|
|
||||||
|
- 2026-07-16 11:03 CST:执行 `git fetch --all --prune`、`git status -sb`、`git log HEAD..@{u}` 和 `git log @{u}..HEAD`。上游没有新增提交;本地相对 `origin/main` ahead 12,依次包含 `ae3f02c3` 零录入票据关联、`54754b55` 个人申请记忆、`211f85d9` 认证申请工作流、`5b246307` 预览决策签发、`a662cfe6` 申请反馈账本、`5ed34c2b` 历史费用 Case 回填、`11275e4b` 迁移所有权安全、`1347366b` 安全时间线与草稿事件、`22669a90` 统一费用时间线、`a616b30c` AI 申请提交事务、`653eda05` Bearer 会话和 `661990b2` 事务费用事件。
|
||||||
|
- 2026-07-16 11:03 CST:修复报销提交只检查旧 `ai_pre_review` 标记、前端不执行真实预审、规则或单据变化后仍可复用过期结果的问题。新增稳定 review ID、输入/规则指纹、流水线版本、`ready / needs_fix / ready_with_review` 决策、结构化发现与整改动作;高危且可由提交人修复的风险在预算占用前保持草稿并返回结构化 409,预算治理和人工复核风险继续进入审批。
|
||||||
|
- 2026-07-16 11:03 CST:修复预审和提交事件关联断裂、票据归集后预审未刷新、申请批准生成空报销草稿过早把 Expense Case 推入 `claiming` 的问题。预审与提交事件共享 correlation/causation,同输入和规则重试只保留一条预审事件;Case 在申请批准后保持 `approved_to_spend`,票据关联后再进入 `claiming`。
|
||||||
|
- 2026-07-16 11:03 CST:修复 AI 报销草稿、申请预览、Steward 和差旅测算链路未透传非默认租户、事件回落到 `default` 的问题;新增租户隔离与 Case 阶段回归,阻止跨租户 Case/Event 串线。
|
||||||
|
- 2026-07-16 11:03 CST:修复高风险费用申请未进入同部门 P8 预算审批、缺失 P8 未报错、P8 直属领导合并审批缺少审计标记及预算预占提前转移的问题;高风险恢复动态预算路由,中等普通预算预警与低风险申请仍保持快速流转。
|
||||||
|
- 2026-07-16 11:03 CST:修复职责拆分后发票去重助手未接回 `ExpenseClaimService`、英文 reimbursement/travel application 意图被财务门禁拒绝,以及 OCR 成功返回空文档时普通图片被降为“待识别中风险”的问题;复用统一票据键提取器,补齐英文财务信号,并把无有效 OCR 信号附件进入高风险校验。
|
||||||
|
- 2026-07-16 11:03 CST:容器内验证费用服务与审批路由 126 项、费用事件/接口/租户/票据关联 63 项、报销接口全量 20 项和前端预审/时间线 24 项通过;Vite 生产构建、Python compileall、关键未定义符号检查及 `git diff --check` 通过。影响范围为报销预审与提交、费用事件、申请预算路由、租户隔离、票据去重、英文财务意图和空 OCR 附件风险分级,不修改用户维护的财务规则工作簿。
|
||||||
|
- 2026-07-16 11:39 CST:再次执行 `git fetch --all --prune`、`git status -sb`、`git log HEAD..@{u}` 和 `git log @{u}..HEAD`。上游仍无新增提交;本地相对 `origin/main` ahead 12,提交摘要与 11:03 检查一致,当前未提交改动继续排除 7 个用户维护的财务规则工作簿和历史未跟踪开发文档。
|
||||||
|
- 2026-07-16 11:39 CST:根据独立审查修复旧 `ready` 预审未覆盖动态风险上下文的问题。提交前申请与报销均重算完整风险,review ID 新增动态 findings 指纹;风险在握手期间变化时返回 `PRE_REVIEW_CHANGED`,新增重复发票/历史风险变化和嵌套无序集合回归,实际提交事件始终关联本次有效预审事件。
|
||||||
|
- 2026-07-16 11:39 CST:修复结构化 409 findings 被前端 `ai_pre_review` 汇总过滤规则丢弃、首票归集后 Case 未进入 `claiming`、申请可整改风险可绕过前端直接 API 提交,以及已解决高风险仍被路由至 P8 的问题。findings 现在保留来源、业务阶段、风险域和 actionability;申请与报销统一在预算前阻断提交人可整改风险,仅人工判断和预算治理风险进入领导/P8。
|
||||||
|
- 2026-07-16 11:39 CST:完成 Claim 核心访问、关联报销后台任务、草稿 ID/单号/最近候选查询和历史风险统计的租户隔离。同用户名、同员工 ID 在不同租户下不能读取、修改、关联或污染预审/P8;默认租户仅对无 CaseLink 历史单保留兼容。新增租户范围 helper 和跨租户对抗测试。
|
||||||
|
- 2026-07-16 11:39 CST:修复规则工作簿损坏或保存中间态导致报销主链抛出 `BadZipFile` / `InvalidFileException` 的问题;单个不可读工作簿现在安全跳过,默认规则和其他已加载规则继续可用。修复只增加运行时容错与临时文件单测,未写入、回滚或替换任何用户 XLSX。
|
||||||
|
- 2026-07-16 11:39 CST:按工程硬约束拆出申请关联、预审端点、职级标准调整、规则清单指纹和租户范围职责;本轮触及的核心服务、端点和前端提交模块均不超过 800 行。容器最终验证核心费用/审批/预审/规则容错 133 项、接口/Case/租户/任务/票据 74 项、前端 25 项通过;Vite 生产构建、全量 Python compileall、变更文件未定义符号检查和 `git diff --check` 通过。
|
||||||
116
server/src/app/api/v1/endpoints/reimbursement_pre_review.py
Normal file
116
server/src/app/api/v1/endpoints/reimbursement_pre_review.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.schemas.reimbursement import (
|
||||||
|
ExpenseClaimActionResponse,
|
||||||
|
ExpenseClaimPreReviewRead,
|
||||||
|
ExpenseClaimRead,
|
||||||
|
ExpenseClaimSubmitPayload,
|
||||||
|
)
|
||||||
|
from app.services.document_numbering import is_application_claim_no
|
||||||
|
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||||
|
from app.services.expense_claim_pre_review_decision import (
|
||||||
|
find_pre_review_flag,
|
||||||
|
pre_review_public_payload,
|
||||||
|
)
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
|
||||||
|
RequestIdHeader = Annotated[
|
||||||
|
str | None,
|
||||||
|
Header(
|
||||||
|
alias="X-Request-ID",
|
||||||
|
description="客户端生成的请求 ID;相同 ID 的预审重试复用同一费用事件。",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def pre_review_expense_claim_or_http_error(
|
||||||
|
service: ExpenseClaimService,
|
||||||
|
claim_id: str,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
request_id: str | None,
|
||||||
|
) -> ExpenseClaimRead:
|
||||||
|
try:
|
||||||
|
claim = service.pre_review_claim(
|
||||||
|
claim_id,
|
||||||
|
current_user,
|
||||||
|
correlation_id=request_id,
|
||||||
|
idempotency_key=request_id,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
return _claim_response_or_not_found(claim)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_expense_claim_or_http_error(
|
||||||
|
service: ExpenseClaimService,
|
||||||
|
claim_id: str,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
payload: ExpenseClaimSubmitPayload | None,
|
||||||
|
) -> ExpenseClaimRead:
|
||||||
|
try:
|
||||||
|
claim = service.submit_claim(
|
||||||
|
claim_id,
|
||||||
|
current_user,
|
||||||
|
pre_review_id=str(getattr(payload, "pre_review_id", "") or ""),
|
||||||
|
pre_review_input_fingerprint=str(
|
||||||
|
getattr(payload, "input_fingerprint", "") or ""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ExpenseClaimPreReviewBlockedError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"code": error.code,
|
||||||
|
"message": str(error),
|
||||||
|
"review": error.review,
|
||||||
|
},
|
||||||
|
) from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
return _claim_response_or_not_found(claim)
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_response_or_not_found(claim: ExpenseClaim | None) -> ExpenseClaimRead:
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Claim not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = ExpenseClaimRead.model_validate(claim)
|
||||||
|
payload = pre_review_public_payload(find_pre_review_flag(claim))
|
||||||
|
pre_review = (
|
||||||
|
ExpenseClaimPreReviewRead.model_validate(payload)
|
||||||
|
if payload is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return response.model_copy(update={"pre_review": pre_review})
|
||||||
|
|
||||||
|
|
||||||
|
def expense_claim_deletion_response(claim: ExpenseClaim) -> ExpenseClaimActionResponse:
|
||||||
|
claim_no = str(claim.claim_no or "").strip()
|
||||||
|
expense_type = str(claim.expense_type or "").strip().lower()
|
||||||
|
document_label = (
|
||||||
|
"申请单"
|
||||||
|
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
|
||||||
|
else "报销单"
|
||||||
|
)
|
||||||
|
return ExpenseClaimActionResponse(
|
||||||
|
message=f"{claim.claim_no} {document_label}已删除。",
|
||||||
|
claim_id=claim.id,
|
||||||
|
status="deleted",
|
||||||
|
)
|
||||||
@@ -21,6 +21,7 @@ from app.schemas.reimbursement import (
|
|||||||
ExpenseClaimRead,
|
ExpenseClaimRead,
|
||||||
ExpenseClaimReturnPayload,
|
ExpenseClaimReturnPayload,
|
||||||
ExpenseClaimStandardAdjustmentPayload,
|
ExpenseClaimStandardAdjustmentPayload,
|
||||||
|
ExpenseClaimSubmitPayload,
|
||||||
ExpenseClaimUpdate,
|
ExpenseClaimUpdate,
|
||||||
ReimbursementCreate,
|
ReimbursementCreate,
|
||||||
ReimbursementRead,
|
ReimbursementRead,
|
||||||
@@ -28,11 +29,17 @@ from app.schemas.reimbursement import (
|
|||||||
TravelReimbursementCalculatorResponse,
|
TravelReimbursementCalculatorResponse,
|
||||||
)
|
)
|
||||||
from app.services.budget import BudgetService
|
from app.services.budget import BudgetService
|
||||||
from app.services.document_numbering import is_application_claim_no
|
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.reimbursement import ReimbursementService
|
from app.services.reimbursement import ReimbursementService
|
||||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
|
|
||||||
|
from .reimbursement_pre_review import (
|
||||||
|
RequestIdHeader,
|
||||||
|
expense_claim_deletion_response,
|
||||||
|
pre_review_expense_claim_or_http_error,
|
||||||
|
submit_expense_claim_or_http_error,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||||
@@ -600,16 +607,18 @@ def delete_expense_claim_item_attachment(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
|
def pre_review_expense_claim(
|
||||||
service = ExpenseClaimService(db)
|
claim_id: str,
|
||||||
try:
|
db: DbSession,
|
||||||
claim = service.pre_review_claim(claim_id, current_user)
|
current_user: CurrentUser,
|
||||||
except ValueError as error:
|
request_id: RequestIdHeader = None,
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
) -> ExpenseClaimRead:
|
||||||
|
return pre_review_expense_claim_or_http_error(
|
||||||
if claim is None:
|
ExpenseClaimService(db),
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
claim_id,
|
||||||
return claim
|
current_user,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -628,16 +637,18 @@ def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: Current
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
|
def submit_expense_claim(
|
||||||
service = ExpenseClaimService(db)
|
claim_id: str,
|
||||||
try:
|
db: DbSession,
|
||||||
claim = service.submit_claim(claim_id, current_user)
|
current_user: CurrentUser,
|
||||||
except ValueError as error:
|
payload: ExpenseClaimSubmitPayload | None = None,
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
) -> ExpenseClaimRead:
|
||||||
|
return submit_expense_claim_or_http_error(
|
||||||
if claim is None:
|
ExpenseClaimService(db),
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
claim_id,
|
||||||
return claim
|
current_user,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -764,18 +775,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
|||||||
if claim is None:
|
if claim is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
|
||||||
claim_no = str(claim.claim_no or "").strip()
|
return expense_claim_deletion_response(claim)
|
||||||
expense_type = str(claim.expense_type or "").strip().lower()
|
|
||||||
document_label = (
|
|
||||||
"申请单"
|
|
||||||
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
|
|
||||||
else "报销单"
|
|
||||||
)
|
|
||||||
return ExpenseClaimActionResponse(
|
|
||||||
message=f"{claim.claim_no} {document_label}已删除。",
|
|
||||||
claim_id=claim.id,
|
|
||||||
status="deleted",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -29,8 +29,17 @@ class BusinessEventPayloadRead(BaseModel):
|
|||||||
next_approval_stage: str = ""
|
next_approval_stage: str = ""
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
opinion: str = ""
|
opinion: str = ""
|
||||||
|
message: str = ""
|
||||||
application_claim_no: str = ""
|
application_claim_no: str = ""
|
||||||
reimbursement_claim_no: str = ""
|
reimbursement_claim_no: str = ""
|
||||||
|
claim_no: str = ""
|
||||||
|
file_name: str = ""
|
||||||
|
document_type: str = ""
|
||||||
|
scene_code: str = ""
|
||||||
|
review_status: str = ""
|
||||||
|
passed: bool | None = None
|
||||||
|
blocking_risk_count: int = 0
|
||||||
|
business_stage: str = ""
|
||||||
archived_applications: list[ArchivedApplicationRead] = Field(default_factory=list)
|
archived_applications: list[ArchivedApplicationRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
@@ -137,6 +137,49 @@ class ExpenseClaimStandardAdjustmentPayload(BaseModel):
|
|||||||
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
|
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimPreReviewRemediationRead(BaseModel):
|
||||||
|
action: str
|
||||||
|
target_item_ids: list[str] = Field(default_factory=list)
|
||||||
|
required_fields: list[str] = Field(default_factory=list)
|
||||||
|
alternative_action: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimPreReviewFindingRead(BaseModel):
|
||||||
|
risk_id: str
|
||||||
|
rule_code: str = ""
|
||||||
|
rule_version: str = ""
|
||||||
|
severity: str
|
||||||
|
disposition: Literal["fix", "review"]
|
||||||
|
resolution_status: Literal["unresolved", "resolved"]
|
||||||
|
actionability: str = ""
|
||||||
|
source: str = "pre_review_finding"
|
||||||
|
business_stage: str = ""
|
||||||
|
risk_domain: str = ""
|
||||||
|
visibility_scope: str = ""
|
||||||
|
item_ids: list[str] = Field(default_factory=list)
|
||||||
|
message: str
|
||||||
|
remediation: ExpenseClaimPreReviewRemediationRead
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimPreReviewRead(BaseModel):
|
||||||
|
review_id: str
|
||||||
|
input_fingerprint: str
|
||||||
|
rule_set_fingerprint: str
|
||||||
|
review_context_fingerprint: str = ""
|
||||||
|
pipeline_version: str
|
||||||
|
reviewed_at: datetime
|
||||||
|
decision: Literal["ready", "needs_fix", "ready_with_review"]
|
||||||
|
passed: bool
|
||||||
|
blocking_count: int = 0
|
||||||
|
message: str
|
||||||
|
findings: list[ExpenseClaimPreReviewFindingRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimSubmitPayload(BaseModel):
|
||||||
|
pre_review_id: str | None = Field(default=None, max_length=36)
|
||||||
|
input_fingerprint: str | None = Field(default=None, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimRead(BaseModel):
|
class ExpenseClaimRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -172,6 +215,7 @@ class ExpenseClaimRead(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
items: list[ExpenseClaimItemRead] = Field(default_factory=list)
|
items: list[ExpenseClaimItemRead] = Field(default_factory=list)
|
||||||
|
pre_review: ExpenseClaimPreReviewRead | None = None
|
||||||
|
|
||||||
@field_validator("risk_flags_json", mode="before")
|
@field_validator("risk_flags_json", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -242,6 +242,14 @@ class ExpenseCaseService:
|
|||||||
self.db.flush()
|
self.db.flush()
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mark_claiming_started(expense_case: ExpenseCase) -> None:
|
||||||
|
"""首个报销资源进入 Case 后推进阶段,但不回退已进入审批的 Case。"""
|
||||||
|
if str(expense_case.current_stage or "").strip() == "approved_to_spend":
|
||||||
|
expense_case.current_stage = "claiming"
|
||||||
|
if str(expense_case.status or "").strip() not in {"closed", "cancelled"}:
|
||||||
|
expense_case.status = "active"
|
||||||
|
|
||||||
def record_claim_event(
|
def record_claim_event(
|
||||||
self,
|
self,
|
||||||
claim: ExpenseClaim,
|
claim: ExpenseClaim,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.models.financial_record import ExpenseClaim
|
|||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
from app.services.document_numbering import is_application_claim_no
|
from app.services.document_numbering import is_application_claim_no
|
||||||
|
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||||
from app.services.expense_claim_workflow_constants import (
|
from app.services.expense_claim_workflow_constants import (
|
||||||
APPLICATION_ARCHIVE_STAGE,
|
APPLICATION_ARCHIVE_STAGE,
|
||||||
ARCHIVE_ACCOUNTING_STAGE,
|
ARCHIVE_ACCOUNTING_STAGE,
|
||||||
@@ -22,7 +23,6 @@ from app.services.expense_claim_workflow_constants import (
|
|||||||
PAYMENT_PENDING_STATUS,
|
PAYMENT_PENDING_STATUS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
||||||
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"}
|
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"}
|
||||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||||
@@ -39,7 +39,7 @@ ARCHIVED_REIMBURSEMENT_STAGES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimAccessPolicy:
|
class ExpenseClaimAccessPolicy(ExpenseClaimTenantScopeMixin):
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
@@ -697,6 +697,7 @@ class ExpenseClaimAccessPolicy:
|
|||||||
return [and_(*pending_budget_approval_parts)]
|
return [and_(*pending_budget_approval_parts)]
|
||||||
|
|
||||||
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||||
|
stmt = self.apply_tenant_scope(stmt, current_user)
|
||||||
role_codes = self.normalize_role_codes(current_user)
|
role_codes = self.normalize_role_codes(current_user)
|
||||||
if current_user.is_admin:
|
if current_user.is_admin:
|
||||||
return stmt.where(ExpenseClaim.status == "submitted")
|
return stmt.where(ExpenseClaim.status == "submitted")
|
||||||
@@ -721,6 +722,7 @@ class ExpenseClaimAccessPolicy:
|
|||||||
*,
|
*,
|
||||||
include_approval_scope: bool = False,
|
include_approval_scope: bool = False,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
stmt = self.apply_tenant_scope(stmt, current_user)
|
||||||
if current_user.is_admin:
|
if current_user.is_admin:
|
||||||
if include_approval_scope:
|
if include_approval_scope:
|
||||||
return stmt
|
return stmt
|
||||||
@@ -768,6 +770,7 @@ class ExpenseClaimAccessPolicy:
|
|||||||
return stmt.where(or_(*conditions))
|
return stmt.where(or_(*conditions))
|
||||||
|
|
||||||
def apply_archived_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
def apply_archived_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||||
|
stmt = self.apply_tenant_scope(stmt, current_user)
|
||||||
archived_condition = self.build_archived_claim_condition()
|
archived_condition = self.build_archived_claim_condition()
|
||||||
if not self.has_archive_center_access(current_user):
|
if not self.has_archive_center_access(current_user):
|
||||||
owned_conditions = self.build_personal_claim_conditions(current_user)
|
owned_conditions = self.build_personal_claim_conditions(current_user)
|
||||||
|
|||||||
543
server/src/app/services/expense_claim_application_link.py
Normal file
543
server/src/app/services/expense_claim_application_link.py
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.services.expense_claim_constants import (
|
||||||
|
DOCUMENT_FACT_ITEM_TYPES,
|
||||||
|
SYSTEM_GENERATED_ITEM_TYPES,
|
||||||
|
)
|
||||||
|
|
||||||
|
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
|
||||||
|
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {
|
||||||
|
"cancelled",
|
||||||
|
"canceled",
|
||||||
|
"deleted",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimApplicationLinkMixin:
|
||||||
|
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
|
||||||
|
claim.amount = Decimal("0.00")
|
||||||
|
claim.invoice_count = 0
|
||||||
|
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,
|
||||||
|
*,
|
||||||
|
claim: ExpenseClaim | None,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
document_specs: list[dict[str, Any]],
|
||||||
|
attachment_count: int,
|
||||||
|
amount: Decimal | None,
|
||||||
|
) -> bool:
|
||||||
|
if document_specs or attachment_count > 0:
|
||||||
|
return False
|
||||||
|
if self._build_application_link_flag(context_json) is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_application_amount_candidates(
|
||||||
|
cls,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
) -> set[Decimal]:
|
||||||
|
review_values = cls._normalize_context_object(
|
||||||
|
context_json.get("review_form_values")
|
||||||
|
)
|
||||||
|
scene_selection = cls._normalize_context_object(
|
||||||
|
context_json.get("expense_scene_selection")
|
||||||
|
)
|
||||||
|
candidates: set[Decimal] = set()
|
||||||
|
for source in (review_values, scene_selection, context_json):
|
||||||
|
for key in (
|
||||||
|
"application_amount",
|
||||||
|
"application_amount_label",
|
||||||
|
"applicationAmount",
|
||||||
|
"applicationAmountLabel",
|
||||||
|
):
|
||||||
|
parsed = cls._parse_context_money_amount(source.get(key))
|
||||||
|
if parsed is not None:
|
||||||
|
candidates.add(parsed)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_context_money_amount(value: Any) -> Decimal | None:
|
||||||
|
raw_value = str(value or "").strip()
|
||||||
|
if not raw_value:
|
||||||
|
return None
|
||||||
|
compact = re.sub(r"[^\d.\-]", "", raw_value.replace(",", ""))
|
||||||
|
if not compact or compact in {"-", ".", "-."}:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(compact).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_application_link_flag(
|
||||||
|
risk_flags: list[Any],
|
||||||
|
*,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
) -> list[Any]:
|
||||||
|
link_flag = ExpenseClaimApplicationLinkMixin._build_application_link_flag(
|
||||||
|
context_json
|
||||||
|
)
|
||||||
|
if link_flag is None:
|
||||||
|
return list(risk_flags or [])
|
||||||
|
|
||||||
|
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
||||||
|
for flag in list(risk_flags or []):
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
continue
|
||||||
|
existing_no = str(
|
||||||
|
flag.get("application_claim_no")
|
||||||
|
or flag.get("applicationClaimNo")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if existing_no and existing_no == application_claim_no:
|
||||||
|
return list(risk_flags or [])
|
||||||
|
return [*list(risk_flags or []), link_flag]
|
||||||
|
|
||||||
|
def _build_application_link_block_result(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
target_claim: ExpenseClaim | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
link_flag = self._build_application_link_flag(context_json)
|
||||||
|
if link_flag is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
application_claim = self._find_application_claim_for_link(link_flag)
|
||||||
|
application_claim_no = str(
|
||||||
|
link_flag.get("application_claim_no") or ""
|
||||||
|
).strip()
|
||||||
|
display_no = application_claim_no or "未编号申请单"
|
||||||
|
if application_claim is None or not self._is_expense_application_claim(
|
||||||
|
application_claim
|
||||||
|
):
|
||||||
|
return self._build_application_link_rejected_result(
|
||||||
|
f"未找到可关联的申请单 {display_no}。请先选择已审批通过的申请单。",
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_status = str(application_claim.status or "").strip().lower()
|
||||||
|
if normalized_status not in APPROVED_APPLICATION_LINK_STATUSES:
|
||||||
|
return self._build_application_link_rejected_result(
|
||||||
|
f"申请单 {application_claim.claim_no} 当前不是已审批通过状态,不能用于快速报销关联。",
|
||||||
|
application_claim=application_claim,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_reimbursement = self._find_existing_reimbursement_for_application_link(
|
||||||
|
application_claim=application_claim,
|
||||||
|
link_flag=link_flag,
|
||||||
|
target_claim=target_claim,
|
||||||
|
)
|
||||||
|
if existing_reimbursement is not None:
|
||||||
|
return self._build_application_link_rejected_result(
|
||||||
|
(
|
||||||
|
f"申请单 {application_claim.claim_no} 已经关联报销单 "
|
||||||
|
f"{existing_reimbursement.claim_no}。"
|
||||||
|
"请进入该草稿或单据继续补充,不能重复生成。"
|
||||||
|
),
|
||||||
|
application_claim=application_claim,
|
||||||
|
existing_claim=existing_reimbursement,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_application_claim_for_link(
|
||||||
|
self,
|
||||||
|
link_flag: dict[str, Any],
|
||||||
|
) -> ExpenseClaim | None:
|
||||||
|
application_claim_id = str(
|
||||||
|
link_flag.get("application_claim_id") or ""
|
||||||
|
).strip()
|
||||||
|
application_claim_no = str(
|
||||||
|
link_flag.get("application_claim_no") or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if application_claim_id:
|
||||||
|
claim = self.db.get(ExpenseClaim, application_claim_id)
|
||||||
|
if claim is not None and self._is_expense_application_claim(claim):
|
||||||
|
return claim
|
||||||
|
|
||||||
|
if application_claim_no:
|
||||||
|
return self.db.scalar(
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.where(ExpenseClaim.claim_no == application_claim_no)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_existing_reimbursement_for_application_link(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
application_claim: ExpenseClaim,
|
||||||
|
link_flag: dict[str, Any],
|
||||||
|
target_claim: ExpenseClaim | None,
|
||||||
|
) -> ExpenseClaim | None:
|
||||||
|
generated_draft = self._find_generated_reimbursement_from_application(
|
||||||
|
application_claim=application_claim,
|
||||||
|
target_claim=target_claim,
|
||||||
|
)
|
||||||
|
if generated_draft is not None:
|
||||||
|
return generated_draft
|
||||||
|
|
||||||
|
linked_ids, linked_nos = self._collect_application_link_reference_values(
|
||||||
|
link_flag
|
||||||
|
)
|
||||||
|
linked_ids.add(str(application_claim.id or "").strip())
|
||||||
|
linked_nos.add(str(application_claim.claim_no or "").strip().upper())
|
||||||
|
linked_ids.discard("")
|
||||||
|
linked_nos.discard("")
|
||||||
|
|
||||||
|
for claim in list(self.db.scalars(select(ExpenseClaim)).all()):
|
||||||
|
if self._is_same_target_claim(claim, target_claim):
|
||||||
|
continue
|
||||||
|
if self._is_expense_application_claim(claim):
|
||||||
|
continue
|
||||||
|
if self._is_inactive_application_link_reimbursement(claim):
|
||||||
|
continue
|
||||||
|
if self._claim_references_application(
|
||||||
|
claim,
|
||||||
|
linked_ids=linked_ids,
|
||||||
|
linked_nos=linked_nos,
|
||||||
|
):
|
||||||
|
return claim
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_generated_reimbursement_from_application(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
application_claim: ExpenseClaim,
|
||||||
|
target_claim: ExpenseClaim | None,
|
||||||
|
) -> ExpenseClaim | None:
|
||||||
|
for flag in list(application_claim.risk_flags_json or []):
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
continue
|
||||||
|
generated_draft_id = str(
|
||||||
|
flag.get("generated_draft_claim_id")
|
||||||
|
or flag.get("generatedDraftClaimId")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
generated_draft_no = str(
|
||||||
|
flag.get("generated_draft_claim_no")
|
||||||
|
or flag.get("generatedDraftClaimNo")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
claim = (
|
||||||
|
self.db.get(ExpenseClaim, generated_draft_id)
|
||||||
|
if generated_draft_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if claim is None and generated_draft_no:
|
||||||
|
claim = self.db.scalar(
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.where(ExpenseClaim.claim_no == generated_draft_no)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if claim is None:
|
||||||
|
continue
|
||||||
|
if self._is_same_target_claim(claim, target_claim):
|
||||||
|
continue
|
||||||
|
if self._is_expense_application_claim(claim):
|
||||||
|
continue
|
||||||
|
if self._is_inactive_application_link_reimbursement(claim):
|
||||||
|
continue
|
||||||
|
return claim
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_same_target_claim(
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
target_claim: ExpenseClaim | None,
|
||||||
|
) -> bool:
|
||||||
|
return bool(target_claim is not None and claim.id == target_claim.id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_inactive_application_link_reimbursement(claim: ExpenseClaim) -> bool:
|
||||||
|
status = str(claim.status or "").strip().lower()
|
||||||
|
return status in INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _claim_references_application(
|
||||||
|
cls,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
*,
|
||||||
|
linked_ids: set[str],
|
||||||
|
linked_nos: set[str],
|
||||||
|
) -> bool:
|
||||||
|
for flag in list(claim.risk_flags_json or []):
|
||||||
|
flag_ids, flag_nos = cls._collect_application_link_reference_values(flag)
|
||||||
|
if flag_ids.intersection(linked_ids) or flag_nos.intersection(linked_nos):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _collect_application_link_reference_values(
|
||||||
|
cls,
|
||||||
|
payload: Any,
|
||||||
|
) -> tuple[set[str], set[str]]:
|
||||||
|
ids: set[str] = set()
|
||||||
|
claim_nos: set[str] = set()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return ids, claim_nos
|
||||||
|
|
||||||
|
cls._add_application_link_reference(ids, claim_nos, payload)
|
||||||
|
for key in (
|
||||||
|
"application_detail",
|
||||||
|
"applicationDetail",
|
||||||
|
"review_form_values",
|
||||||
|
"reviewFormValues",
|
||||||
|
"expense_scene_selection",
|
||||||
|
"expenseSceneSelection",
|
||||||
|
):
|
||||||
|
nested_ids, nested_nos = cls._collect_application_link_reference_values(
|
||||||
|
payload.get(key)
|
||||||
|
)
|
||||||
|
ids.update(nested_ids)
|
||||||
|
claim_nos.update(nested_nos)
|
||||||
|
ids.discard("")
|
||||||
|
claim_nos.discard("")
|
||||||
|
return ids, claim_nos
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_application_link_reference(
|
||||||
|
ids: set[str],
|
||||||
|
claim_nos: set[str],
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
for key in ("application_claim_id", "applicationClaimId"):
|
||||||
|
ids.add(str(payload.get(key) or "").strip())
|
||||||
|
for key in ("application_claim_no", "applicationClaimNo"):
|
||||||
|
claim_nos.add(str(payload.get(key) or "").strip().upper())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_application_link_rejected_result(
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
application_claim: ExpenseClaim | None = None,
|
||||||
|
existing_claim: ExpenseClaim | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"message": message,
|
||||||
|
"draft_only": False,
|
||||||
|
"status": "blocked",
|
||||||
|
"application_link_blocked": True,
|
||||||
|
"submission_blocked": True,
|
||||||
|
"submission_blocked_reasons": [message],
|
||||||
|
"missing_fields": [message],
|
||||||
|
"risk_flags": ["application_link_blocked"],
|
||||||
|
}
|
||||||
|
if application_claim is not None:
|
||||||
|
result["application_claim_id"] = application_claim.id
|
||||||
|
result["application_claim_no"] = application_claim.claim_no
|
||||||
|
result["application_status"] = application_claim.status
|
||||||
|
if existing_claim is not None:
|
||||||
|
result["existing_claim_id"] = existing_claim.id
|
||||||
|
result["existing_claim_no"] = existing_claim.claim_no
|
||||||
|
result["existing_claim_status"] = existing_claim.status
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_application_link_flag(
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
review_values = ExpenseClaimApplicationLinkMixin._normalize_context_object(
|
||||||
|
context_json.get("review_form_values")
|
||||||
|
)
|
||||||
|
scene_selection = ExpenseClaimApplicationLinkMixin._normalize_context_object(
|
||||||
|
context_json.get("expense_scene_selection")
|
||||||
|
)
|
||||||
|
|
||||||
|
def pick(*keys: str) -> str:
|
||||||
|
for source in (review_values, scene_selection, context_json):
|
||||||
|
for key in keys:
|
||||||
|
value = str(source.get(key) or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
application_claim_no = pick("application_claim_no", "applicationClaimNo")
|
||||||
|
if not application_claim_no:
|
||||||
|
return None
|
||||||
|
|
||||||
|
application_claim_id = pick("application_claim_id", "applicationClaimId")
|
||||||
|
application_amount = pick("application_amount", "applicationAmount")
|
||||||
|
application_amount_label = pick(
|
||||||
|
"application_amount_label", "applicationAmountLabel"
|
||||||
|
)
|
||||||
|
application_reason = pick(
|
||||||
|
"application_reason", "applicationReason", "reason"
|
||||||
|
)
|
||||||
|
application_location = pick(
|
||||||
|
"application_location", "applicationLocation", "location"
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": "application_link",
|
||||||
|
"event_type": "expense_reimbursement_application_linked",
|
||||||
|
"severity": "info",
|
||||||
|
"label": "关联申请单",
|
||||||
|
"message": f"报销草稿已关联申请单 {application_claim_no}。",
|
||||||
|
"application_claim_id": application_claim_id,
|
||||||
|
"application_claim_no": application_claim_no,
|
||||||
|
"application_amount_label": application_amount_label,
|
||||||
|
"application_status": application_status,
|
||||||
|
"application_status_label": application_status_label,
|
||||||
|
"application_detail": {
|
||||||
|
"application_reason": application_reason,
|
||||||
|
"application_location": application_location,
|
||||||
|
"application_amount": application_amount,
|
||||||
|
"application_amount_label": application_amount_label,
|
||||||
|
"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,
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_context_object(value: Any) -> dict[str, Any]:
|
||||||
|
return dict(value) if isinstance(value, dict) else {}
|
||||||
@@ -296,6 +296,7 @@ class ExpenseClaimApprovalFlowMixin:
|
|||||||
},
|
},
|
||||||
expense_case=expense_case,
|
expense_case=expense_case,
|
||||||
relation_type="generated_reimbursement",
|
relation_type="generated_reimbursement",
|
||||||
|
update_case_state=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.services.expense_claim_risk_stage import (
|
|||||||
risk_flag_business_stage,
|
risk_flag_business_stage,
|
||||||
with_risk_business_stage,
|
with_risk_business_stage,
|
||||||
)
|
)
|
||||||
|
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimApprovalRoutingMixin:
|
class ExpenseClaimApprovalRoutingMixin:
|
||||||
@@ -73,6 +74,14 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
claim.risk_flags_json,
|
claim.risk_flags_json,
|
||||||
business_stage=business_stage,
|
business_stage=business_stage,
|
||||||
)
|
)
|
||||||
|
application_risk_reasons = (
|
||||||
|
self._collect_application_route_risk_reasons(
|
||||||
|
claim.risk_flags_json,
|
||||||
|
business_stage=business_stage,
|
||||||
|
)
|
||||||
|
if is_application_claim
|
||||||
|
else []
|
||||||
|
)
|
||||||
historical_risk_count = self._count_recent_substantive_risky_claims(claim)
|
historical_risk_count = self._count_recent_substantive_risky_claims(claim)
|
||||||
historical_risk_reasons = (
|
historical_risk_reasons = (
|
||||||
[f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"]
|
[f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"]
|
||||||
@@ -80,7 +89,7 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
reasons = self._dedupe_reasons(
|
reasons = self._dedupe_reasons(
|
||||||
budget_reasons
|
[*budget_reasons, *application_risk_reasons]
|
||||||
if is_application_claim
|
if is_application_claim
|
||||||
else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
|
else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
|
||||||
)
|
)
|
||||||
@@ -95,9 +104,11 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
|
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
|
||||||
if is_application_claim:
|
if is_application_claim:
|
||||||
message = (
|
message = (
|
||||||
"系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。"
|
"系统根据预算占用与高风险复核结果判断,"
|
||||||
|
"该申请单需要预算管理者二次确认。"
|
||||||
if requires_budget_review
|
if requires_budget_review
|
||||||
else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。"
|
else "系统根据预算占用与风险复核结果判断,"
|
||||||
|
"该申请单可跳过预算管理者复核。"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message = (
|
message = (
|
||||||
@@ -184,6 +195,37 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
reasons.append(f"{label}:{message}" if message else label)
|
reasons.append(f"{label}:{message}" if message else label)
|
||||||
return self._dedupe_reasons(reasons)
|
return self._dedupe_reasons(reasons)
|
||||||
|
|
||||||
|
def _collect_application_route_risk_reasons(
|
||||||
|
self,
|
||||||
|
risk_flags: list[Any] | None,
|
||||||
|
*,
|
||||||
|
business_stage: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""申请单仅将高危关注项升级给预算管理者。
|
||||||
|
|
||||||
|
普通预算预警由 90% 占用线决定是否升级,避免中低风险导致
|
||||||
|
所有申请都增加一道审批。
|
||||||
|
"""
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
for flag in list(risk_flags or []):
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
continue
|
||||||
|
flag_stage = risk_flag_business_stage(flag)
|
||||||
|
if flag_stage and flag_stage != business_stage:
|
||||||
|
continue
|
||||||
|
severity = str(flag.get("severity") or "").strip().lower()
|
||||||
|
event_type = str(flag.get("event_type") or "").strip().lower()
|
||||||
|
is_high_risk = severity in {"high", "critical", "danger"}
|
||||||
|
if not is_high_risk and event_type not in self._ROUTE_RISK_EVENT_TYPES:
|
||||||
|
continue
|
||||||
|
if not self._is_substantive_route_risk_flag(flag):
|
||||||
|
continue
|
||||||
|
label = str(flag.get("label") or event_type or "风险标记").strip()
|
||||||
|
message = str(flag.get("message") or "").strip()
|
||||||
|
reasons.append(f"{label}:{message}" if message else label)
|
||||||
|
return self._dedupe_reasons(reasons)
|
||||||
|
|
||||||
def _count_recent_substantive_risky_claims(self, claim: ExpenseClaim) -> int:
|
def _count_recent_substantive_risky_claims(self, claim: ExpenseClaim) -> int:
|
||||||
filters = []
|
filters = []
|
||||||
if claim.employee_id:
|
if claim.employee_id:
|
||||||
@@ -199,6 +241,14 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
.where(or_(*filters))
|
.where(or_(*filters))
|
||||||
.where(ExpenseClaim.id != claim.id)
|
.where(ExpenseClaim.id != claim.id)
|
||||||
.where(ExpenseClaim.occurred_at >= since)
|
.where(ExpenseClaim.occurred_at >= since)
|
||||||
|
.where(
|
||||||
|
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(
|
||||||
|
ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
|
||||||
|
self.db,
|
||||||
|
claim.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return sum(
|
return sum(
|
||||||
1
|
1
|
||||||
@@ -211,6 +261,13 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _is_substantive_route_risk_flag(self, flag: dict[str, Any]) -> bool:
|
def _is_substantive_route_risk_flag(self, flag: dict[str, Any]) -> bool:
|
||||||
|
resolution_status = str(
|
||||||
|
flag.get("resolution_status") or flag.get("resolutionStatus") or ""
|
||||||
|
).strip().lower()
|
||||||
|
if resolution_status in {"resolved", "accepted", "waived"} or bool(
|
||||||
|
flag.get("resolved")
|
||||||
|
):
|
||||||
|
return False
|
||||||
source = str(flag.get("source") or "").strip().lower()
|
source = str(flag.get("source") or "").strip().lower()
|
||||||
if source in self._ROUTE_IGNORED_SOURCES:
|
if source in self._ROUTE_IGNORED_SOURCES:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -322,7 +322,9 @@ class ExpenseClaimAttachmentOperationsMixin:
|
|||||||
source_score = cls._attachment_ocr_signal_score(source_receipt_document)
|
source_score = cls._attachment_ocr_signal_score(source_receipt_document)
|
||||||
upload_score = cls._attachment_ocr_signal_score(upload_ocr_document)
|
upload_score = cls._attachment_ocr_signal_score(upload_ocr_document)
|
||||||
if source_score <= 0:
|
if source_score <= 0:
|
||||||
return upload_ocr_document if upload_score > 0 else None
|
# OCR 已返回文档但没有任何有效信号时,也要进入高风险校验,
|
||||||
|
# 不能回落成“待识别”的中风险状态放过普通图片或空白附件。
|
||||||
|
return upload_ocr_document
|
||||||
if upload_score <= 0:
|
if upload_score <= 0:
|
||||||
return source_receipt_document
|
return source_receipt_document
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ from app.services.expense_claim_constants import (
|
|||||||
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
||||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||||
)
|
)
|
||||||
|
from app.services.expense_claim_platform_context_tools import (
|
||||||
|
collect_invoice_keys_from_document_info,
|
||||||
|
)
|
||||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||||
from app.services.expense_amounts import (
|
from app.services.expense_amounts import (
|
||||||
extract_amount_candidates,
|
extract_amount_candidates,
|
||||||
@@ -470,6 +473,10 @@ class ExpenseClaimDocumentItemBuilderMixin:
|
|||||||
document_info["fields"] = document_info.get("document_fields")
|
document_info["fields"] = document_info.get("document_fields")
|
||||||
return self._collect_invoice_keys_from_document_info(document_info)
|
return self._collect_invoice_keys_from_document_info(document_info)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_invoice_keys_from_document_info(document_info: dict[str, Any]) -> list[str]:
|
||||||
|
return collect_invoice_keys_from_document_info(document_info)
|
||||||
|
|
||||||
def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
|
def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
|
||||||
document_type = str(document.get("document_type") or "").strip()
|
document_type = str(document.get("document_type") or "").strip()
|
||||||
mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)
|
mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)
|
||||||
|
|||||||
@@ -1,546 +1,30 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from datetime import UTC, date, datetime
|
||||||
import re
|
from decimal import Decimal
|
||||||
import shutil
|
|
||||||
import uuid
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import UTC, date, datetime, timedelta
|
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session, selectinload
|
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
|
||||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
|
||||||
from app.models.agent_asset import AgentAsset
|
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
from app.schemas.ontology import OntologyParseResult
|
||||||
from app.schemas.reimbursement import (
|
from app.services.expense_claim_application_link import (
|
||||||
ExpenseClaimItemCreate,
|
APPROVED_APPLICATION_LINK_STATUSES as APPROVED_APPLICATION_LINK_STATUSES,
|
||||||
ExpenseClaimItemUpdate,
|
|
||||||
ExpenseClaimUpdate,
|
|
||||||
TravelReimbursementCalculatorRequest,
|
|
||||||
)
|
)
|
||||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
from app.services.expense_claim_application_link import ExpenseClaimApplicationLinkMixin
|
||||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
|
||||||
from app.services.agent_foundation import AgentFoundationService
|
|
||||||
from app.services.audit import AuditLogService
|
|
||||||
from app.services.document_intelligence import build_document_insight
|
|
||||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
|
||||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
|
||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
|
||||||
from app.services.expense_claim_constants import (
|
from app.services.expense_claim_constants import (
|
||||||
EXPENSE_TYPE_LABELS,
|
|
||||||
MAX_DRAFT_CLAIMS_PER_USER,
|
|
||||||
EDITABLE_CLAIM_STATUSES,
|
|
||||||
SYSTEM_GENERATED_ITEM_TYPES,
|
|
||||||
TRAVEL_DETAIL_ITEM_TYPES,
|
|
||||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
|
||||||
DOCUMENT_TYPE_ITEM_TYPE_MAP,
|
|
||||||
DOCUMENT_TYPE_SCENE_MAP,
|
|
||||||
DOCUMENT_FACT_ITEM_TYPES,
|
|
||||||
ROUTE_DESCRIPTION_ITEM_TYPES,
|
|
||||||
DOCUMENT_TRIP_DATE_LABELS,
|
|
||||||
DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS,
|
|
||||||
DOCUMENT_TRIP_DATE_KEYS,
|
|
||||||
DOCUMENT_GENERIC_DATE_KEYS,
|
|
||||||
DOCUMENT_INVOICE_DATE_KEYS,
|
|
||||||
DOCUMENT_TRIP_DATE_LABEL_TOKENS,
|
|
||||||
DOCUMENT_GENERIC_DATE_LABEL_TOKENS,
|
|
||||||
DOCUMENT_INVOICE_DATE_LABEL_TOKENS,
|
|
||||||
DOCUMENT_ROUTE_FORMAT_PATTERN,
|
|
||||||
DOCUMENT_ROUTE_TEXT_PATTERN,
|
|
||||||
DOCUMENT_ROUTE_ORIGIN_LABELS,
|
|
||||||
DOCUMENT_ROUTE_DESTINATION_LABELS,
|
|
||||||
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES,
|
|
||||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
|
||||||
EXPENSE_SCENE_KEYWORDS,
|
|
||||||
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES,
|
|
||||||
DOCUMENT_SCENE_LABELS,
|
|
||||||
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
|
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
|
||||||
PERSISTENT_EXPENSE_REVIEW_ACTIONS,
|
EDITABLE_CLAIM_STATUSES,
|
||||||
RETURN_REASON_OPTIONS,
|
|
||||||
MAX_CLAIM_NO_RETRY_ATTEMPTS,
|
MAX_CLAIM_NO_RETRY_ATTEMPTS,
|
||||||
DOCUMENT_DATE_PATTERN,
|
MAX_DRAFT_CLAIMS_PER_USER,
|
||||||
SYSTEM_GENERATED_REASON_PREFIXES,
|
SYSTEM_GENERATED_ITEM_TYPES,
|
||||||
LEADING_REASON_TIME_PATTERNS,
|
|
||||||
AI_REVIEW_LOOKBACK_DAYS,
|
|
||||||
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
|
|
||||||
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
|
|
||||||
TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES,
|
|
||||||
TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES,
|
|
||||||
TRAVEL_POLICY_CITY_TIERS,
|
|
||||||
TRAVEL_POLICY_CITY_MATCH_ORDER,
|
|
||||||
TRAVEL_POLICY_BAND_LABELS,
|
|
||||||
TRAVEL_POLICY_HOTEL_LIMITS,
|
|
||||||
TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS,
|
|
||||||
TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS,
|
|
||||||
TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS,
|
|
||||||
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
|
|
||||||
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
|
||||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
|
||||||
)
|
)
|
||||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||||
from app.services.expense_amounts import (
|
|
||||||
extract_amount_candidates,
|
|
||||||
format_decimal_amount,
|
|
||||||
is_amount_match_date_fragment,
|
|
||||||
is_date_like_amount_candidate,
|
|
||||||
is_probable_year_amount,
|
|
||||||
parse_document_amount_value,
|
|
||||||
parse_plain_document_amount_value,
|
|
||||||
resolve_document_field_amount,
|
|
||||||
resolve_document_item_amount,
|
|
||||||
resolve_document_text_amount,
|
|
||||||
)
|
|
||||||
from app.services.expense_rule_runtime import (
|
|
||||||
DEFAULT_SCENE_RULE_ASSET_CODE,
|
|
||||||
ExpenseRuleRuntimeService,
|
|
||||||
RuntimeTravelPolicy,
|
|
||||||
build_default_expense_rule_catalog,
|
|
||||||
resolve_document_type_label,
|
|
||||||
)
|
|
||||||
from app.services.ocr import OcrService
|
|
||||||
|
|
||||||
|
|
||||||
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
|
class ExpenseClaimDraftAttachmentAssociationMixin(ExpenseClaimTenantScopeMixin):
|
||||||
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {"cancelled", "canceled", "deleted"}
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimApplicationLinkMixin:
|
|
||||||
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
|
|
||||||
claim.amount = Decimal("0.00")
|
|
||||||
claim.invoice_count = 0
|
|
||||||
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,
|
|
||||||
*,
|
|
||||||
claim: ExpenseClaim | None,
|
|
||||||
context_json: dict[str, Any],
|
|
||||||
document_specs: list[dict[str, Any]],
|
|
||||||
attachment_count: int,
|
|
||||||
amount: Decimal | None,
|
|
||||||
) -> bool:
|
|
||||||
if document_specs or attachment_count > 0:
|
|
||||||
return False
|
|
||||||
if self._build_application_link_flag(context_json) is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
|
|
||||||
review_values = cls._normalize_context_object(context_json.get("review_form_values"))
|
|
||||||
scene_selection = cls._normalize_context_object(context_json.get("expense_scene_selection"))
|
|
||||||
candidates: set[Decimal] = set()
|
|
||||||
for source in (review_values, scene_selection, context_json):
|
|
||||||
for key in ("application_amount", "application_amount_label", "applicationAmount", "applicationAmountLabel"):
|
|
||||||
parsed = cls._parse_context_money_amount(source.get(key))
|
|
||||||
if parsed is not None:
|
|
||||||
candidates.add(parsed)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_context_money_amount(value: Any) -> Decimal | None:
|
|
||||||
raw_value = str(value or "").strip()
|
|
||||||
if not raw_value:
|
|
||||||
return None
|
|
||||||
compact = re.sub(r"[^\d.\-]", "", raw_value.replace(",", ""))
|
|
||||||
if not compact or compact in {"-", ".", "-."}:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return Decimal(compact).quantize(Decimal("0.01"))
|
|
||||||
except (InvalidOperation, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _merge_application_link_flag(
|
|
||||||
risk_flags: list[Any],
|
|
||||||
*,
|
|
||||||
context_json: dict[str, Any],
|
|
||||||
) -> list[Any]:
|
|
||||||
link_flag = ExpenseClaimDraftFlowMixin._build_application_link_flag(context_json)
|
|
||||||
if link_flag is None:
|
|
||||||
return list(risk_flags or [])
|
|
||||||
|
|
||||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
|
||||||
for flag in list(risk_flags or []):
|
|
||||||
if not isinstance(flag, dict):
|
|
||||||
continue
|
|
||||||
existing_no = str(
|
|
||||||
flag.get("application_claim_no")
|
|
||||||
or flag.get("applicationClaimNo")
|
|
||||||
or ""
|
|
||||||
).strip()
|
|
||||||
if existing_no and existing_no == application_claim_no:
|
|
||||||
return list(risk_flags or [])
|
|
||||||
return [*list(risk_flags or []), link_flag]
|
|
||||||
|
|
||||||
def _build_application_link_block_result(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
context_json: dict[str, Any],
|
|
||||||
target_claim: ExpenseClaim | None,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
link_flag = self._build_application_link_flag(context_json)
|
|
||||||
if link_flag is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
application_claim = self._find_application_claim_for_link(link_flag)
|
|
||||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
|
||||||
display_no = application_claim_no or "未编号申请单"
|
|
||||||
if application_claim is None or not self._is_expense_application_claim(application_claim):
|
|
||||||
return self._build_application_link_rejected_result(
|
|
||||||
f"未找到可关联的申请单 {display_no}。请先选择已审批通过的申请单。",
|
|
||||||
)
|
|
||||||
|
|
||||||
normalized_status = str(application_claim.status or "").strip().lower()
|
|
||||||
if normalized_status not in APPROVED_APPLICATION_LINK_STATUSES:
|
|
||||||
return self._build_application_link_rejected_result(
|
|
||||||
f"申请单 {application_claim.claim_no} 当前不是已审批通过状态,不能用于快速报销关联。",
|
|
||||||
application_claim=application_claim,
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_reimbursement = self._find_existing_reimbursement_for_application_link(
|
|
||||||
application_claim=application_claim,
|
|
||||||
link_flag=link_flag,
|
|
||||||
target_claim=target_claim,
|
|
||||||
)
|
|
||||||
if existing_reimbursement is not None:
|
|
||||||
return self._build_application_link_rejected_result(
|
|
||||||
(
|
|
||||||
f"申请单 {application_claim.claim_no} 已经关联报销单 {existing_reimbursement.claim_no}。"
|
|
||||||
"请进入该草稿或单据继续补充,不能重复生成。"
|
|
||||||
),
|
|
||||||
application_claim=application_claim,
|
|
||||||
existing_claim=existing_reimbursement,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _find_application_claim_for_link(self, link_flag: dict[str, Any]) -> ExpenseClaim | None:
|
|
||||||
application_claim_id = str(link_flag.get("application_claim_id") or "").strip()
|
|
||||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
|
||||||
|
|
||||||
if application_claim_id:
|
|
||||||
claim = self.db.get(ExpenseClaim, application_claim_id)
|
|
||||||
if claim is not None and self._is_expense_application_claim(claim):
|
|
||||||
return claim
|
|
||||||
|
|
||||||
if application_claim_no:
|
|
||||||
return self.db.scalar(
|
|
||||||
select(ExpenseClaim)
|
|
||||||
.where(ExpenseClaim.claim_no == application_claim_no)
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _find_existing_reimbursement_for_application_link(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
application_claim: ExpenseClaim,
|
|
||||||
link_flag: dict[str, Any],
|
|
||||||
target_claim: ExpenseClaim | None,
|
|
||||||
) -> ExpenseClaim | None:
|
|
||||||
generated_draft = self._find_generated_reimbursement_from_application(
|
|
||||||
application_claim=application_claim,
|
|
||||||
target_claim=target_claim,
|
|
||||||
)
|
|
||||||
if generated_draft is not None:
|
|
||||||
return generated_draft
|
|
||||||
|
|
||||||
linked_ids, linked_nos = self._collect_application_link_reference_values(link_flag)
|
|
||||||
linked_ids.add(str(application_claim.id or "").strip())
|
|
||||||
linked_nos.add(str(application_claim.claim_no or "").strip().upper())
|
|
||||||
linked_ids.discard("")
|
|
||||||
linked_nos.discard("")
|
|
||||||
|
|
||||||
for claim in list(self.db.scalars(select(ExpenseClaim)).all()):
|
|
||||||
if self._is_same_target_claim(claim, target_claim):
|
|
||||||
continue
|
|
||||||
if self._is_expense_application_claim(claim):
|
|
||||||
continue
|
|
||||||
if self._is_inactive_application_link_reimbursement(claim):
|
|
||||||
continue
|
|
||||||
if self._claim_references_application(claim, linked_ids=linked_ids, linked_nos=linked_nos):
|
|
||||||
return claim
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _find_generated_reimbursement_from_application(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
application_claim: ExpenseClaim,
|
|
||||||
target_claim: ExpenseClaim | None,
|
|
||||||
) -> ExpenseClaim | None:
|
|
||||||
for flag in list(application_claim.risk_flags_json or []):
|
|
||||||
if not isinstance(flag, dict):
|
|
||||||
continue
|
|
||||||
generated_draft_id = str(
|
|
||||||
flag.get("generated_draft_claim_id")
|
|
||||||
or flag.get("generatedDraftClaimId")
|
|
||||||
or ""
|
|
||||||
).strip()
|
|
||||||
generated_draft_no = str(
|
|
||||||
flag.get("generated_draft_claim_no")
|
|
||||||
or flag.get("generatedDraftClaimNo")
|
|
||||||
or ""
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
claim = self.db.get(ExpenseClaim, generated_draft_id) if generated_draft_id else None
|
|
||||||
if claim is None and generated_draft_no:
|
|
||||||
claim = self.db.scalar(
|
|
||||||
select(ExpenseClaim)
|
|
||||||
.where(ExpenseClaim.claim_no == generated_draft_no)
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
if claim is None:
|
|
||||||
continue
|
|
||||||
if self._is_same_target_claim(claim, target_claim):
|
|
||||||
continue
|
|
||||||
if self._is_expense_application_claim(claim):
|
|
||||||
continue
|
|
||||||
if self._is_inactive_application_link_reimbursement(claim):
|
|
||||||
continue
|
|
||||||
return claim
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_same_target_claim(claim: ExpenseClaim, target_claim: ExpenseClaim | None) -> bool:
|
|
||||||
return bool(target_claim is not None and claim.id == target_claim.id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_inactive_application_link_reimbursement(claim: ExpenseClaim) -> bool:
|
|
||||||
status = str(claim.status or "").strip().lower()
|
|
||||||
return status in INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _claim_references_application(
|
|
||||||
cls,
|
|
||||||
claim: ExpenseClaim,
|
|
||||||
*,
|
|
||||||
linked_ids: set[str],
|
|
||||||
linked_nos: set[str],
|
|
||||||
) -> bool:
|
|
||||||
for flag in list(claim.risk_flags_json or []):
|
|
||||||
flag_ids, flag_nos = cls._collect_application_link_reference_values(flag)
|
|
||||||
if flag_ids.intersection(linked_ids) or flag_nos.intersection(linked_nos):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _collect_application_link_reference_values(cls, payload: Any) -> tuple[set[str], set[str]]:
|
|
||||||
ids: set[str] = set()
|
|
||||||
claim_nos: set[str] = set()
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return ids, claim_nos
|
|
||||||
|
|
||||||
cls._add_application_link_reference(ids, claim_nos, payload)
|
|
||||||
for key in (
|
|
||||||
"application_detail",
|
|
||||||
"applicationDetail",
|
|
||||||
"review_form_values",
|
|
||||||
"reviewFormValues",
|
|
||||||
"expense_scene_selection",
|
|
||||||
"expenseSceneSelection",
|
|
||||||
):
|
|
||||||
nested_ids, nested_nos = cls._collect_application_link_reference_values(payload.get(key))
|
|
||||||
ids.update(nested_ids)
|
|
||||||
claim_nos.update(nested_nos)
|
|
||||||
ids.discard("")
|
|
||||||
claim_nos.discard("")
|
|
||||||
return ids, claim_nos
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _add_application_link_reference(
|
|
||||||
ids: set[str],
|
|
||||||
claim_nos: set[str],
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
for key in ("application_claim_id", "applicationClaimId"):
|
|
||||||
ids.add(str(payload.get(key) or "").strip())
|
|
||||||
for key in ("application_claim_no", "applicationClaimNo"):
|
|
||||||
claim_nos.add(str(payload.get(key) or "").strip().upper())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_application_link_rejected_result(
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
application_claim: ExpenseClaim | None = None,
|
|
||||||
existing_claim: ExpenseClaim | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
result: dict[str, Any] = {
|
|
||||||
"message": message,
|
|
||||||
"draft_only": False,
|
|
||||||
"status": "blocked",
|
|
||||||
"application_link_blocked": True,
|
|
||||||
"submission_blocked": True,
|
|
||||||
"submission_blocked_reasons": [message],
|
|
||||||
"missing_fields": [message],
|
|
||||||
"risk_flags": ["application_link_blocked"],
|
|
||||||
}
|
|
||||||
if application_claim is not None:
|
|
||||||
result["application_claim_id"] = application_claim.id
|
|
||||||
result["application_claim_no"] = application_claim.claim_no
|
|
||||||
result["application_status"] = application_claim.status
|
|
||||||
if existing_claim is not None:
|
|
||||||
result["existing_claim_id"] = existing_claim.id
|
|
||||||
result["existing_claim_no"] = existing_claim.claim_no
|
|
||||||
result["existing_claim_status"] = existing_claim.status
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
|
||||||
context_json.get("review_form_values")
|
|
||||||
)
|
|
||||||
scene_selection = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
|
||||||
context_json.get("expense_scene_selection")
|
|
||||||
)
|
|
||||||
|
|
||||||
def pick(*keys: str) -> str:
|
|
||||||
for source in (review_values, scene_selection, context_json):
|
|
||||||
for key in keys:
|
|
||||||
value = str(source.get(key) or "").strip()
|
|
||||||
if value:
|
|
||||||
return value
|
|
||||||
return ""
|
|
||||||
|
|
||||||
application_claim_no = pick("application_claim_no", "applicationClaimNo")
|
|
||||||
if not application_claim_no:
|
|
||||||
return None
|
|
||||||
|
|
||||||
application_claim_id = pick("application_claim_id", "applicationClaimId")
|
|
||||||
application_amount = pick("application_amount", "applicationAmount")
|
|
||||||
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
|
|
||||||
application_reason = pick("application_reason", "applicationReason", "reason")
|
|
||||||
application_location = pick("application_location", "applicationLocation", "location")
|
|
||||||
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")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"source": "application_link",
|
|
||||||
"event_type": "expense_reimbursement_application_linked",
|
|
||||||
"severity": "info",
|
|
||||||
"label": "关联申请单",
|
|
||||||
"message": f"报销草稿已关联申请单 {application_claim_no}。",
|
|
||||||
"application_claim_id": application_claim_id,
|
|
||||||
"application_claim_no": application_claim_no,
|
|
||||||
"application_amount_label": application_amount_label,
|
|
||||||
"application_status": application_status,
|
|
||||||
"application_status_label": application_status_label,
|
|
||||||
"application_detail": {
|
|
||||||
"application_reason": application_reason,
|
|
||||||
"application_location": application_location,
|
|
||||||
"application_amount": application_amount,
|
|
||||||
"application_amount_label": application_amount_label,
|
|
||||||
"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,
|
|
||||||
"created_at": datetime.now(UTC).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_context_object(value: Any) -> dict[str, Any]:
|
|
||||||
return dict(value) if isinstance(value, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimDraftAttachmentAssociationMixin:
|
|
||||||
def _find_target_claim(
|
def _find_target_claim(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -556,7 +40,16 @@ class ExpenseClaimDraftAttachmentAssociationMixin:
|
|||||||
|
|
||||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||||
if draft_claim_id:
|
if draft_claim_id:
|
||||||
claim = self.db.get(ExpenseClaim, draft_claim_id)
|
claim = self.db.scalar(
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.where(ExpenseClaim.id == draft_claim_id)
|
||||||
|
.where(
|
||||||
|
self.build_claim_tenant_condition(
|
||||||
|
self.normalize_context_tenant_id(context_json)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
if claim is not None and self._is_editable_claim_status(claim.status):
|
if claim is not None and self._is_editable_claim_status(claim.status):
|
||||||
return claim
|
return claim
|
||||||
return None
|
return None
|
||||||
@@ -573,6 +66,11 @@ class ExpenseClaimDraftAttachmentAssociationMixin:
|
|||||||
select(ExpenseClaim)
|
select(ExpenseClaim)
|
||||||
.where(ExpenseClaim.claim_no.in_(claim_codes))
|
.where(ExpenseClaim.claim_no.in_(claim_codes))
|
||||||
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
|
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
|
||||||
|
.where(
|
||||||
|
self.build_claim_tenant_condition(
|
||||||
|
self.normalize_context_tenant_id(context_json)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
return self.db.scalar(stmt)
|
return self.db.scalar(stmt)
|
||||||
@@ -587,7 +85,16 @@ class ExpenseClaimDraftAttachmentAssociationMixin:
|
|||||||
) -> ExpenseClaim | None:
|
) -> ExpenseClaim | None:
|
||||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||||
if draft_claim_id:
|
if draft_claim_id:
|
||||||
claim = self.db.get(ExpenseClaim, draft_claim_id)
|
claim = self.db.scalar(
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.where(ExpenseClaim.id == draft_claim_id)
|
||||||
|
.where(
|
||||||
|
self.build_claim_tenant_condition(
|
||||||
|
self.normalize_context_tenant_id(context_json)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
if claim is not None and self._is_editable_claim_status(claim.status):
|
if claim is not None and self._is_editable_claim_status(claim.status):
|
||||||
return claim
|
return claim
|
||||||
|
|
||||||
@@ -612,6 +119,11 @@ class ExpenseClaimDraftAttachmentAssociationMixin:
|
|||||||
select(ExpenseClaim)
|
select(ExpenseClaim)
|
||||||
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
|
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
|
||||||
.where(or_(*owner_filters))
|
.where(or_(*owner_filters))
|
||||||
|
.where(
|
||||||
|
self.build_claim_tenant_condition(
|
||||||
|
self.normalize_context_tenant_id(context_json)
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.created_at.desc())
|
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -1024,6 +536,12 @@ class ExpenseClaimDraftFlowMixin(ExpenseClaimApplicationLinkMixin, ExpenseClaimD
|
|||||||
claim,
|
claim,
|
||||||
event_type=("claim_draft_created" if is_new_claim else "claim_draft_updated"),
|
event_type=("claim_draft_created" if is_new_claim else "claim_draft_updated"),
|
||||||
actor_id=user_id or claim.employee_name or "system",
|
actor_id=user_id or claim.employee_name or "system",
|
||||||
|
tenant_id=str(
|
||||||
|
context_json.get("tenant_id")
|
||||||
|
or context_json.get("tenantId")
|
||||||
|
or "default"
|
||||||
|
).strip()
|
||||||
|
or "default",
|
||||||
correlation_id=run_id,
|
correlation_id=run_id,
|
||||||
idempotency_key=run_id,
|
idempotency_key=run_id,
|
||||||
previous_status=str((before_json or {}).get("status") or ""),
|
previous_status=str((before_json or {}).get("status") or ""),
|
||||||
|
|||||||
@@ -5,3 +5,21 @@ class ExpenseClaimSubmissionBlockedError(ValueError):
|
|||||||
def __init__(self, issues: list[str]) -> None:
|
def __init__(self, issues: list[str]) -> None:
|
||||||
self.issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
|
self.issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
|
||||||
super().__init__("提交前请先补全信息:" + ";".join(self.issues))
|
super().__init__("提交前请先补全信息:" + ";".join(self.issues))
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimPreReviewBlockedError(ValueError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
review: dict,
|
||||||
|
*,
|
||||||
|
code: str = "PRE_REVIEW_NEEDS_FIX",
|
||||||
|
) -> None:
|
||||||
|
self.review = dict(review or {})
|
||||||
|
self.code = str(code or "PRE_REVIEW_NEEDS_FIX").strip()
|
||||||
|
fallback = (
|
||||||
|
"提交前风险环境已变化,请确认最新预审结果后重新提交。"
|
||||||
|
if self.code == "PRE_REVIEW_CHANGED"
|
||||||
|
else "预审发现需先整改的风险。"
|
||||||
|
)
|
||||||
|
message = str(self.review.get("message") or fallback).strip()
|
||||||
|
super().__init__(message)
|
||||||
|
|||||||
@@ -20,16 +20,14 @@ from app.services.expense_claim_platform_context_tools import (
|
|||||||
extract_known_cities_from_text,
|
extract_known_cities_from_text,
|
||||||
resolve_first_document_field_value,
|
resolve_first_document_field_value,
|
||||||
)
|
)
|
||||||
from app.services.expense_rule_runtime import (
|
|
||||||
RuntimeTravelPolicy,
|
|
||||||
)
|
|
||||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
|
||||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
|
||||||
from app.services.expense_claim_platform_route_risk import resolve_multi_city_related_item_ids
|
|
||||||
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
|
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
|
||||||
|
from app.services.expense_claim_platform_route_risk import resolve_multi_city_related_item_ids
|
||||||
from app.services.expense_claim_platform_text_risk import (
|
from app.services.expense_claim_platform_text_risk import (
|
||||||
collect_vague_goods_description_evidence,
|
collect_vague_goods_description_evidence,
|
||||||
)
|
)
|
||||||
|
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||||
|
from app.services.expense_claim_rule_fingerprint import build_risk_manifest_fingerprint
|
||||||
|
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||||
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
|
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
|
||||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||||
@@ -38,21 +36,25 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
|||||||
class ExpenseClaimPlatformRiskMixin:
|
class ExpenseClaimPlatformRiskMixin:
|
||||||
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
||||||
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
|
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
|
||||||
|
|
||||||
def evaluate_platform_risk_rules(
|
def evaluate_platform_risk_rules(
|
||||||
self,
|
self,
|
||||||
claim: ExpenseClaim,
|
claim: ExpenseClaim,
|
||||||
*,
|
*,
|
||||||
rule_codes: list[str] | None = None,
|
rule_codes: list[str] | None = None,
|
||||||
business_stage: str | None = None,
|
business_stage: str | None = None,
|
||||||
) -> dict[str, list[Any]]:
|
) -> dict[str, Any]:
|
||||||
normalized_stage = self._normalize_platform_risk_business_stage(business_stage)
|
normalized_stage = self._normalize_platform_risk_business_stage(business_stage)
|
||||||
manifests = self._load_platform_risk_rule_manifests(
|
manifests = self._load_platform_risk_rule_manifests(
|
||||||
rule_codes=rule_codes,
|
rule_codes=rule_codes,
|
||||||
business_stage=normalized_stage,
|
business_stage=normalized_stage,
|
||||||
)
|
)
|
||||||
|
rule_set_fingerprint = build_risk_manifest_fingerprint(manifests)
|
||||||
if not manifests:
|
if not manifests:
|
||||||
return {"flags": [], "blocking_reasons": []}
|
return {
|
||||||
|
"flags": [],
|
||||||
|
"blocking_reasons": [],
|
||||||
|
"rule_set_fingerprint": rule_set_fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
contexts = self._build_claim_attachment_contexts(claim)
|
contexts = self._build_claim_attachment_contexts(claim)
|
||||||
contexts.append(
|
contexts.append(
|
||||||
@@ -71,7 +73,6 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
if not self._risk_manifest_applies_to_claim(manifest, claim=claim, contexts=contexts):
|
if not self._risk_manifest_applies_to_claim(manifest, claim=claim, contexts=contexts):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
flag = self._evaluate_platform_risk_manifest(
|
flag = self._evaluate_platform_risk_manifest(
|
||||||
manifest,
|
manifest,
|
||||||
claim=claim,
|
claim=claim,
|
||||||
@@ -94,7 +95,22 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
blocking_reasons.append(str(flag.get("message") or flag.get("label") or "").strip())
|
blocking_reasons.append(str(flag.get("message") or flag.get("label") or "").strip())
|
||||||
|
|
||||||
deduplicated_reasons = list(dict.fromkeys(reason for reason in blocking_reasons if reason))
|
deduplicated_reasons = list(dict.fromkeys(reason for reason in blocking_reasons if reason))
|
||||||
return {"flags": flags, "blocking_reasons": deduplicated_reasons}
|
return {
|
||||||
|
"flags": flags,
|
||||||
|
"blocking_reasons": deduplicated_reasons,
|
||||||
|
"rule_set_fingerprint": rule_set_fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
def platform_risk_rule_set_fingerprint(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
business_stage: str,
|
||||||
|
) -> str:
|
||||||
|
manifests = self._load_platform_risk_rule_manifests(
|
||||||
|
rule_codes=None,
|
||||||
|
business_stage=self._normalize_platform_risk_business_stage(business_stage),
|
||||||
|
)
|
||||||
|
return build_risk_manifest_fingerprint(manifests)
|
||||||
|
|
||||||
def _load_platform_risk_rule_manifests(
|
def _load_platform_risk_rule_manifests(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ from typing import Any
|
|||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||||
|
from app.services.expense_claim_pre_review_decision import build_pre_review_decision
|
||||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||||
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
|
from app.services.expense_claim_risk_stage import (
|
||||||
|
risk_business_stage_for_claim,
|
||||||
|
with_risk_business_stage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimPreReviewMixin:
|
class ExpenseClaimPreReviewMixin:
|
||||||
@@ -15,6 +19,9 @@ class ExpenseClaimPreReviewMixin:
|
|||||||
self,
|
self,
|
||||||
claim_id: str,
|
claim_id: str,
|
||||||
current_user: CurrentUserContext,
|
current_user: CurrentUserContext,
|
||||||
|
*,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
) -> ExpenseClaim | None:
|
) -> ExpenseClaim | None:
|
||||||
claim = self.get_claim(claim_id, current_user)
|
claim = self.get_claim(claim_id, current_user)
|
||||||
if claim is None:
|
if claim is None:
|
||||||
@@ -34,48 +41,32 @@ class ExpenseClaimPreReviewMixin:
|
|||||||
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
||||||
|
|
||||||
before_json = self._serialize_claim(claim)
|
before_json = self._serialize_claim(claim)
|
||||||
reviewed_at = datetime.now(UTC)
|
pre_review_flag = self.refresh_claim_pre_review_state(
|
||||||
if is_application_claim:
|
|
||||||
preserved_flags = [
|
|
||||||
flag
|
|
||||||
for flag in list(claim.risk_flags_json or [])
|
|
||||||
if not (
|
|
||||||
isinstance(flag, dict)
|
|
||||||
and str(flag.get("source") or "").strip() == "submission_review"
|
|
||||||
and str(flag.get("hit_source") or "").strip() == "rule_center"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
application_review = self.evaluate_platform_risk_rules(
|
|
||||||
claim,
|
claim,
|
||||||
business_stage="expense_application",
|
|
||||||
)
|
|
||||||
review_flags = dedupe_claim_risk_flags(
|
|
||||||
[*preserved_flags, *list(application_review.get("flags") or [])]
|
|
||||||
)
|
|
||||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
|
||||||
passed = blocking_count <= 0
|
|
||||||
else:
|
|
||||||
review_result = self._run_ai_submission_review(claim)
|
|
||||||
review_flags = list(review_result.get("risk_flags") or [])
|
|
||||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
|
||||||
passed = blocking_count <= 0
|
|
||||||
|
|
||||||
claim.risk_flags_json = self._replace_ai_pre_review_flag(
|
|
||||||
review_flags,
|
|
||||||
self._build_ai_pre_review_flag(
|
|
||||||
passed=passed,
|
|
||||||
blocking_count=blocking_count,
|
|
||||||
reviewed_at=reviewed_at,
|
|
||||||
business_stage=risk_business_stage_for_claim(
|
|
||||||
is_application_claim=is_application_claim,
|
is_application_claim=is_application_claim,
|
||||||
),
|
reviewed_at=datetime.now(UTC),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
if pre_review_flag is None:
|
||||||
|
raise RuntimeError("无法生成费用预审结果。")
|
||||||
claim.approval_stage = "待提交" if not is_application_claim else claim.approval_stage
|
claim.approval_stage = "待提交" if not is_application_claim else claim.approval_stage
|
||||||
claim.submitted_at = None
|
claim.submitted_at = None
|
||||||
|
|
||||||
|
normalized_correlation_id = self._expense_cases.normalize_correlation_id(
|
||||||
|
correlation_id or idempotency_key or str(pre_review_flag.get("review_id") or "")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._record_pre_review_event(
|
||||||
|
claim,
|
||||||
|
pre_review_flag=pre_review_flag,
|
||||||
|
current_user=current_user,
|
||||||
|
is_application_claim=is_application_claim,
|
||||||
|
correlation_id=normalized_correlation_id,
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(claim)
|
self.db.refresh(claim)
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
self.audit_service.log_action(
|
self.audit_service.log_action(
|
||||||
actor=current_user.name or current_user.username,
|
actor=current_user.name or current_user.username,
|
||||||
@@ -87,43 +78,31 @@ class ExpenseClaimPreReviewMixin:
|
|||||||
)
|
)
|
||||||
return claim
|
return claim
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _count_ai_pre_review_blocking_risks(risk_flags: list[Any]) -> int:
|
|
||||||
return sum(
|
|
||||||
1
|
|
||||||
for flag in risk_flags
|
|
||||||
if (
|
|
||||||
isinstance(flag, dict)
|
|
||||||
and str(flag.get("source") or "").strip() != "ai_pre_review"
|
|
||||||
and str(flag.get("severity") or "").strip().lower() == "high"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_ai_pre_review_flag(
|
def _build_ai_pre_review_flag(
|
||||||
*,
|
*,
|
||||||
passed: bool,
|
decision_payload: dict[str, Any],
|
||||||
blocking_count: int,
|
|
||||||
reviewed_at: datetime,
|
|
||||||
business_stage: str,
|
business_stage: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if passed:
|
decision = str(decision_payload.get("decision") or "ready_with_review")
|
||||||
message = "自动检测通过,费用明细和附件可提交审批。"
|
passed = decision != "needs_fix"
|
||||||
else:
|
blocking_count = int(decision_payload.get("blocking_count") or 0)
|
||||||
message = f"自动检测发现 {blocking_count} 条重大风险,请逐条填写原因后再提交审批。"
|
|
||||||
|
|
||||||
return with_risk_business_stage(
|
return with_risk_business_stage(
|
||||||
{
|
{
|
||||||
"source": "ai_pre_review",
|
"source": "ai_pre_review",
|
||||||
"event_type": "expense_claim_ai_pre_review",
|
"event_type": "expense_claim_ai_pre_review",
|
||||||
"severity": "info" if passed else "high",
|
"severity": "info" if passed else "high",
|
||||||
"label": "自动检测通过" if passed else "自动检测未通过",
|
"label": "自动检测通过" if decision == "ready" else (
|
||||||
"message": message,
|
"自动检测待复核" if passed else "自动检测未通过"
|
||||||
|
),
|
||||||
|
"message": str(decision_payload.get("message") or ""),
|
||||||
"status": "passed" if passed else "failed",
|
"status": "passed" if passed else "failed",
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
"blocking_risk_count": blocking_count,
|
"blocking_risk_count": blocking_count,
|
||||||
|
**decision_payload,
|
||||||
"next_action": "next_step" if passed else "risk_explanation_required",
|
"next_action": "next_step" if passed else "risk_explanation_required",
|
||||||
"created_at": reviewed_at.isoformat(),
|
"created_at": str(decision_payload.get("reviewed_at") or ""),
|
||||||
},
|
},
|
||||||
business_stage,
|
business_stage,
|
||||||
)
|
)
|
||||||
@@ -143,15 +122,29 @@ class ExpenseClaimPreReviewMixin:
|
|||||||
]
|
]
|
||||||
return [*preserved_flags, next_flag]
|
return [*preserved_flags, next_flag]
|
||||||
|
|
||||||
|
def refresh_claim_pre_review_state(
|
||||||
|
self,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
*,
|
||||||
|
is_application_claim: bool | None = None,
|
||||||
|
reviewed_at: datetime | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""业务变更事务内刷新预审快照,不提交、不单独写事件。"""
|
||||||
|
return self._refresh_claim_pre_review_flags(
|
||||||
|
claim,
|
||||||
|
is_application_claim=is_application_claim,
|
||||||
|
reviewed_at=reviewed_at,
|
||||||
|
)
|
||||||
|
|
||||||
def _refresh_claim_pre_review_flags(
|
def _refresh_claim_pre_review_flags(
|
||||||
self,
|
self,
|
||||||
claim: ExpenseClaim,
|
claim: ExpenseClaim,
|
||||||
*,
|
*,
|
||||||
is_application_claim: bool | None = None,
|
is_application_claim: bool | None = None,
|
||||||
reviewed_at: datetime | None = None,
|
reviewed_at: datetime | None = None,
|
||||||
) -> bool:
|
) -> dict[str, Any] | None:
|
||||||
if claim is None:
|
if claim is None:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
if is_application_claim is None:
|
if is_application_claim is None:
|
||||||
is_application_claim = self._is_expense_application_claim(claim)
|
is_application_claim = self._is_expense_application_claim(claim)
|
||||||
@@ -174,31 +167,79 @@ class ExpenseClaimPreReviewMixin:
|
|||||||
review_flags = dedupe_claim_risk_flags(
|
review_flags = dedupe_claim_risk_flags(
|
||||||
[*preserved_flags, *list(application_review.get("flags") or [])]
|
[*preserved_flags, *list(application_review.get("flags") or [])]
|
||||||
)
|
)
|
||||||
|
platform_rule_set_fingerprint = str(
|
||||||
|
application_review.get("rule_set_fingerprint") or ""
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
review_result = self._run_ai_submission_review(claim)
|
review_result = self._run_ai_submission_review(claim)
|
||||||
review_flags = list(review_result.get("risk_flags") or [])
|
review_flags = list(review_result.get("risk_flags") or [])
|
||||||
|
platform_rule_set_fingerprint = str(
|
||||||
|
review_result.get("rule_set_fingerprint") or ""
|
||||||
|
)
|
||||||
|
|
||||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
|
||||||
claim.risk_flags_json = self._replace_ai_pre_review_flag(
|
|
||||||
review_flags,
|
|
||||||
self._build_ai_pre_review_flag(
|
|
||||||
passed=blocking_count <= 0,
|
|
||||||
blocking_count=blocking_count,
|
|
||||||
reviewed_at=reviewed_at,
|
|
||||||
business_stage = risk_business_stage_for_claim(
|
business_stage = risk_business_stage_for_claim(
|
||||||
is_application_claim=is_application_claim,
|
is_application_claim=is_application_claim,
|
||||||
),
|
)
|
||||||
),
|
decision_payload = build_pre_review_decision(
|
||||||
|
claim,
|
||||||
|
risk_flags=review_flags,
|
||||||
|
business_stage=business_stage,
|
||||||
|
platform_rule_set_fingerprint=platform_rule_set_fingerprint,
|
||||||
|
reviewed_at=reviewed_at,
|
||||||
|
)
|
||||||
|
pre_review_flag = self._build_ai_pre_review_flag(
|
||||||
|
decision_payload=decision_payload,
|
||||||
|
business_stage=business_stage,
|
||||||
|
)
|
||||||
|
claim.risk_flags_json = self._replace_ai_pre_review_flag(
|
||||||
|
review_flags,
|
||||||
|
pre_review_flag,
|
||||||
)
|
)
|
||||||
if not is_application_claim:
|
if not is_application_claim:
|
||||||
claim.approval_stage = "\u5f85\u63d0\u4ea4"
|
claim.approval_stage = "\u5f85\u63d0\u4ea4"
|
||||||
claim.submitted_at = None
|
claim.submitted_at = None
|
||||||
return True
|
return pre_review_flag
|
||||||
|
|
||||||
@staticmethod
|
def _record_pre_review_event(
|
||||||
def _has_ai_pre_review_flag(claim: ExpenseClaim) -> bool:
|
self,
|
||||||
return any(
|
claim: ExpenseClaim,
|
||||||
isinstance(flag, dict)
|
*,
|
||||||
and str(flag.get("source") or "").strip() == "ai_pre_review"
|
pre_review_flag: dict[str, Any],
|
||||||
for flag in list(claim.risk_flags_json or [])
|
current_user: CurrentUserContext,
|
||||||
|
is_application_claim: bool,
|
||||||
|
correlation_id: str,
|
||||||
|
):
|
||||||
|
review_id = str(pre_review_flag.get("review_id") or "").strip()
|
||||||
|
if not review_id:
|
||||||
|
raise ValueError("预审结果缺少 review_id。")
|
||||||
|
return self._expense_cases.record_claim_event(
|
||||||
|
claim,
|
||||||
|
event_type=(
|
||||||
|
"application_pre_review_completed"
|
||||||
|
if is_application_claim
|
||||||
|
else "claim_pre_review_completed"
|
||||||
|
),
|
||||||
|
actor_id=current_user.username or current_user.name,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
idempotency_key=f"pre-review:{review_id}",
|
||||||
|
update_case_state=False,
|
||||||
|
extra_payload={
|
||||||
|
"review_id": review_id,
|
||||||
|
"input_fingerprint": str(pre_review_flag.get("input_fingerprint") or ""),
|
||||||
|
"rule_set_fingerprint": str(
|
||||||
|
pre_review_flag.get("rule_set_fingerprint") or ""
|
||||||
|
),
|
||||||
|
"review_context_fingerprint": str(
|
||||||
|
pre_review_flag.get("review_context_fingerprint") or ""
|
||||||
|
),
|
||||||
|
"decision": str(pre_review_flag.get("decision") or ""),
|
||||||
|
"review_status": str(pre_review_flag.get("status") or ""),
|
||||||
|
"passed": bool(pre_review_flag.get("passed")),
|
||||||
|
"blocking_risk_count": int(
|
||||||
|
pre_review_flag.get("blocking_risk_count") or 0
|
||||||
|
),
|
||||||
|
"business_stage": str(pre_review_flag.get("business_stage") or ""),
|
||||||
|
"message": str(pre_review_flag.get("message") or ""),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
394
server/src/app/services/expense_claim_pre_review_decision.py
Normal file
394
server/src/app/services/expense_claim_pre_review_decision.py
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||||
|
|
||||||
|
PRE_REVIEW_PIPELINE_VERSION = "2026-07-16.1"
|
||||||
|
PRE_REVIEW_DECISIONS = {"ready", "needs_fix", "ready_with_review"}
|
||||||
|
_DERIVED_RISK_SOURCES = {
|
||||||
|
"ai_pre_review",
|
||||||
|
"application_submission",
|
||||||
|
"approval",
|
||||||
|
"approval_log",
|
||||||
|
"approval_routing",
|
||||||
|
"budget_approval",
|
||||||
|
"expense_claim_approval",
|
||||||
|
"expense_claim_finance_approval",
|
||||||
|
"finance_approval",
|
||||||
|
"manual_approval",
|
||||||
|
"payment",
|
||||||
|
"submission_review",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_pre_review_input_fingerprint(claim: ExpenseClaim) -> str:
|
||||||
|
payload = {
|
||||||
|
"claim": {
|
||||||
|
"id": _text(claim.id),
|
||||||
|
"employee_id": _text(claim.employee_id),
|
||||||
|
"employee_name": _text(claim.employee_name),
|
||||||
|
"department_id": _text(claim.department_id),
|
||||||
|
"department_name": _text(claim.department_name),
|
||||||
|
"project_code": _text(claim.project_code),
|
||||||
|
"expense_type": _text(claim.expense_type),
|
||||||
|
"reason": _text(claim.reason),
|
||||||
|
"location": _text(claim.location),
|
||||||
|
"amount": _money(claim.amount),
|
||||||
|
"currency": _text(claim.currency),
|
||||||
|
"occurred_at": _date_time(claim.occurred_at),
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": _text(item.id),
|
||||||
|
"item_date": _date_time(item.item_date),
|
||||||
|
"item_type": _text(item.item_type),
|
||||||
|
"item_reason": _text(item.item_reason),
|
||||||
|
"item_location": _text(item.item_location),
|
||||||
|
"item_note": _text(item.item_note),
|
||||||
|
"item_amount": _money(item.item_amount),
|
||||||
|
"invoice_id": _text(item.invoice_id),
|
||||||
|
}
|
||||||
|
for item in sorted(list(claim.items or []), key=lambda entry: _text(entry.id))
|
||||||
|
],
|
||||||
|
"risk_inputs": sorted(
|
||||||
|
[
|
||||||
|
_strip_volatile_fields(flag)
|
||||||
|
for flag in list(claim.risk_flags_json or [])
|
||||||
|
if isinstance(flag, dict)
|
||||||
|
and _text(flag.get("source")).lower() not in _DERIVED_RISK_SOURCES
|
||||||
|
],
|
||||||
|
key=_canonical_json,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return _fingerprint(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def build_pre_review_rule_set_fingerprint(platform_rule_set_fingerprint: str) -> str:
|
||||||
|
return _fingerprint(
|
||||||
|
{
|
||||||
|
"pipeline_version": PRE_REVIEW_PIPELINE_VERSION,
|
||||||
|
"platform_rule_set_fingerprint": _text(platform_rule_set_fingerprint),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_pre_review_decision(
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
*,
|
||||||
|
risk_flags: list[Any],
|
||||||
|
business_stage: str,
|
||||||
|
platform_rule_set_fingerprint: str,
|
||||||
|
reviewed_at: datetime,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
input_fingerprint = build_pre_review_input_fingerprint(claim)
|
||||||
|
rule_set_fingerprint = build_pre_review_rule_set_fingerprint(
|
||||||
|
platform_rule_set_fingerprint
|
||||||
|
)
|
||||||
|
findings = _build_findings(risk_flags, business_stage=business_stage)
|
||||||
|
review_context_fingerprint = _fingerprint(findings)
|
||||||
|
blocking_findings = [
|
||||||
|
finding
|
||||||
|
for finding in findings
|
||||||
|
if finding["severity"] in {"critical", "high"}
|
||||||
|
and finding["disposition"] == "fix"
|
||||||
|
and finding["resolution_status"] == "unresolved"
|
||||||
|
]
|
||||||
|
if blocking_findings:
|
||||||
|
decision = "needs_fix"
|
||||||
|
message = (
|
||||||
|
f"自动检测发现 {len(blocking_findings)} 条需先整改的重大风险,"
|
||||||
|
"请按建议处理后重新预审。"
|
||||||
|
)
|
||||||
|
elif findings:
|
||||||
|
decision = "ready_with_review"
|
||||||
|
message = "自动检测已完成,当前风险可随单进入审批并由对应角色复核。"
|
||||||
|
else:
|
||||||
|
decision = "ready"
|
||||||
|
message = "自动检测通过,费用明细和附件可提交审批。"
|
||||||
|
|
||||||
|
review_id = str(
|
||||||
|
uuid.uuid5(
|
||||||
|
uuid.NAMESPACE_URL,
|
||||||
|
":".join(
|
||||||
|
[
|
||||||
|
"expense-claim-pre-review",
|
||||||
|
_text(claim.id),
|
||||||
|
input_fingerprint,
|
||||||
|
rule_set_fingerprint,
|
||||||
|
review_context_fingerprint,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"review_id": review_id,
|
||||||
|
"input_fingerprint": input_fingerprint,
|
||||||
|
"rule_set_fingerprint": rule_set_fingerprint,
|
||||||
|
"review_context_fingerprint": review_context_fingerprint,
|
||||||
|
"pipeline_version": PRE_REVIEW_PIPELINE_VERSION,
|
||||||
|
"reviewed_at": reviewed_at.isoformat(),
|
||||||
|
"decision": decision,
|
||||||
|
"passed": decision != "needs_fix",
|
||||||
|
"blocking_count": len(blocking_findings),
|
||||||
|
"findings": findings,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_pre_review_flag(claim: ExpenseClaim) -> dict[str, Any] | None:
|
||||||
|
for flag in reversed(list(claim.risk_flags_json or [])):
|
||||||
|
if isinstance(flag, dict) and _text(flag.get("source")) == "ai_pre_review":
|
||||||
|
return flag
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_pre_review_current(
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
flag: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
platform_rule_set_fingerprint: str,
|
||||||
|
) -> bool:
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
_text(flag.get("review_id"))
|
||||||
|
and _text(flag.get("input_fingerprint"))
|
||||||
|
== build_pre_review_input_fingerprint(claim)
|
||||||
|
and _text(flag.get("rule_set_fingerprint"))
|
||||||
|
== build_pre_review_rule_set_fingerprint(platform_rule_set_fingerprint)
|
||||||
|
and _text(flag.get("decision")) in PRE_REVIEW_DECISIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pre_review_identity_matches(
|
||||||
|
flag: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
review_id: str,
|
||||||
|
input_fingerprint: str,
|
||||||
|
) -> bool:
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
return False
|
||||||
|
normalized_review_id = _text(review_id)
|
||||||
|
normalized_input_fingerprint = _text(input_fingerprint)
|
||||||
|
if normalized_review_id and _text(flag.get("review_id")) != normalized_review_id:
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
normalized_input_fingerprint
|
||||||
|
and _text(flag.get("input_fingerprint")) != normalized_input_fingerprint
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def pre_review_public_payload(flag: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(flag, dict) or not _text(flag.get("review_id")):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"review_id": _text(flag.get("review_id")),
|
||||||
|
"input_fingerprint": _text(flag.get("input_fingerprint")),
|
||||||
|
"rule_set_fingerprint": _text(flag.get("rule_set_fingerprint")),
|
||||||
|
"review_context_fingerprint": _text(
|
||||||
|
flag.get("review_context_fingerprint")
|
||||||
|
),
|
||||||
|
"pipeline_version": _text(flag.get("pipeline_version")),
|
||||||
|
"reviewed_at": _text(flag.get("reviewed_at") or flag.get("created_at")),
|
||||||
|
"decision": _text(flag.get("decision")) or "ready_with_review",
|
||||||
|
"passed": bool(flag.get("passed")),
|
||||||
|
"blocking_count": int(
|
||||||
|
flag.get("blocking_count") or flag.get("blocking_risk_count") or 0
|
||||||
|
),
|
||||||
|
"message": _text(flag.get("message")),
|
||||||
|
"findings": [
|
||||||
|
dict(item)
|
||||||
|
for item in list(flag.get("findings") or [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_findings(
|
||||||
|
risk_flags: list[Any],
|
||||||
|
*,
|
||||||
|
business_stage: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
findings: list[dict[str, Any]] = []
|
||||||
|
for flag in list(risk_flags or []):
|
||||||
|
if not isinstance(flag, dict) or _text(flag.get("source")) == "ai_pre_review":
|
||||||
|
continue
|
||||||
|
enriched = enrich_risk_flag_semantics(flag, business_stage=business_stage)
|
||||||
|
severity = _text(
|
||||||
|
enriched.get("severity") or enriched.get("tone") or enriched.get("level")
|
||||||
|
).lower()
|
||||||
|
if severity not in {"medium", "high", "critical"}:
|
||||||
|
continue
|
||||||
|
actionability = _text(enriched.get("actionability")).lower()
|
||||||
|
if actionability == "system_trace":
|
||||||
|
continue
|
||||||
|
item_ids = _item_ids(enriched)
|
||||||
|
resolution_status = _resolution_status(enriched)
|
||||||
|
disposition = "fix" if actionability == "fixable_by_submitter" else "review"
|
||||||
|
message = _text(
|
||||||
|
enriched.get("message")
|
||||||
|
or enriched.get("summary")
|
||||||
|
or enriched.get("reason")
|
||||||
|
or enriched.get("label")
|
||||||
|
)
|
||||||
|
risk_key = {
|
||||||
|
"source": _text(enriched.get("source")),
|
||||||
|
"rule_code": _text(enriched.get("rule_code")),
|
||||||
|
"severity": severity,
|
||||||
|
"item_ids": item_ids,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
findings.append(
|
||||||
|
{
|
||||||
|
"risk_id": _text(enriched.get("risk_id"))
|
||||||
|
or f"risk:{_fingerprint(risk_key).removeprefix('sha256:')[:24]}",
|
||||||
|
"rule_code": _text(enriched.get("rule_code")),
|
||||||
|
"rule_version": _text(enriched.get("rule_version")),
|
||||||
|
"severity": severity,
|
||||||
|
"disposition": disposition,
|
||||||
|
"resolution_status": resolution_status,
|
||||||
|
"actionability": actionability,
|
||||||
|
"source": _text(enriched.get("source")) or "pre_review_finding",
|
||||||
|
"business_stage": _text(enriched.get("business_stage"))
|
||||||
|
or business_stage,
|
||||||
|
"risk_domain": _text(
|
||||||
|
enriched.get("risk_domain") or enriched.get("riskDomain")
|
||||||
|
),
|
||||||
|
"visibility_scope": _text(
|
||||||
|
enriched.get("visibility_scope")
|
||||||
|
or enriched.get("visibilityScope")
|
||||||
|
),
|
||||||
|
"item_ids": item_ids,
|
||||||
|
"message": message,
|
||||||
|
"remediation": _build_remediation(
|
||||||
|
disposition=disposition,
|
||||||
|
item_ids=item_ids,
|
||||||
|
flag=enriched,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
findings,
|
||||||
|
key=lambda finding: (
|
||||||
|
{"critical": 0, "high": 1, "medium": 2}.get(finding["severity"], 9),
|
||||||
|
finding["risk_id"],
|
||||||
|
),
|
||||||
|
)[:50]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_remediation(
|
||||||
|
*,
|
||||||
|
disposition: str,
|
||||||
|
item_ids: list[str],
|
||||||
|
flag: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if disposition != "fix":
|
||||||
|
return {
|
||||||
|
"action": "manual_review",
|
||||||
|
"target_item_ids": item_ids,
|
||||||
|
"required_fields": [],
|
||||||
|
}
|
||||||
|
corpus = " ".join(
|
||||||
|
_text(flag.get(key))
|
||||||
|
for key in ("label", "message", "summary", "rule_code")
|
||||||
|
)
|
||||||
|
remediation = {
|
||||||
|
"action": "edit_item_note" if item_ids else "edit_claim",
|
||||||
|
"target_item_ids": item_ids,
|
||||||
|
"required_fields": ["item_note"] if item_ids else [],
|
||||||
|
}
|
||||||
|
if any(token in corpus for token in ("超标", "标准", "住宿", "金额")):
|
||||||
|
remediation["alternative_action"] = "accept_standard_limit"
|
||||||
|
return remediation
|
||||||
|
|
||||||
|
|
||||||
|
def _resolution_status(flag: dict[str, Any]) -> str:
|
||||||
|
explicit = _text(flag.get("resolution_status") or flag.get("resolutionStatus")).lower()
|
||||||
|
if explicit in {"resolved", "accepted", "waived"}:
|
||||||
|
return "resolved"
|
||||||
|
if explicit in {"unresolved", "open", "pending"}:
|
||||||
|
return "unresolved"
|
||||||
|
return "resolved" if bool(flag.get("resolved")) else "unresolved"
|
||||||
|
|
||||||
|
|
||||||
|
def _item_ids(flag: dict[str, Any]) -> list[str]:
|
||||||
|
raw_values = [
|
||||||
|
flag.get("item_id"),
|
||||||
|
flag.get("itemId"),
|
||||||
|
*_as_list(flag.get("item_ids")),
|
||||||
|
*_as_list(flag.get("itemIds")),
|
||||||
|
]
|
||||||
|
return sorted(
|
||||||
|
dict.fromkeys(_text(value) for value in raw_values if _text(value))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list[Any]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (tuple, set)):
|
||||||
|
return list(value)
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_volatile_fields(value: Any) -> Any:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return sorted(
|
||||||
|
[_strip_volatile_fields(item) for item in value],
|
||||||
|
key=_canonical_json,
|
||||||
|
)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
volatile_keys = {
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"review_id",
|
||||||
|
"input_fingerprint",
|
||||||
|
"rule_set_fingerprint",
|
||||||
|
"reviewed_at",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
str(key): _strip_volatile_fields(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
if str(key) not in volatile_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(value: Any) -> str:
|
||||||
|
canonical = _canonical_json(value)
|
||||||
|
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: Any) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _date_time(value: Any) -> str:
|
||||||
|
if hasattr(value, "isoformat"):
|
||||||
|
return str(value.isoformat())
|
||||||
|
return _text(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _money(value: Any) -> str:
|
||||||
|
return f"{Decimal(value or Decimal('0.00')).quantize(Decimal('0.01')):.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
@@ -154,6 +154,12 @@ class ExpenseClaimReviewPreviewMixin:
|
|||||||
if str(item).strip()
|
if str(item).strip()
|
||||||
],
|
],
|
||||||
is_admin=bool(context_json.get("is_admin")),
|
is_admin=bool(context_json.get("is_admin")),
|
||||||
|
tenant_id=str(
|
||||||
|
context_json.get("tenant_id")
|
||||||
|
or context_json.get("tenantId")
|
||||||
|
or "default"
|
||||||
|
).strip()
|
||||||
|
or "default",
|
||||||
department_name=str(context_json.get("department_name") or context_json.get("department") or "").strip(),
|
department_name=str(context_json.get("department_name") or context_json.get("department") or "").strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -344,6 +350,12 @@ class ExpenseClaimReviewPreviewMixin:
|
|||||||
name=str(context_json.get("name") or user_id or "anonymous").strip() or "anonymous",
|
name=str(context_json.get("name") or user_id or "anonymous").strip() or "anonymous",
|
||||||
role_codes=[],
|
role_codes=[],
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
|
tenant_id=str(
|
||||||
|
context_json.get("tenant_id")
|
||||||
|
or context_json.get("tenantId")
|
||||||
|
or "default"
|
||||||
|
).strip()
|
||||||
|
or "default",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMix
|
|||||||
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
|
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
|
||||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||||
|
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||||
from app.services.risk_observations import RiskObservationService
|
from app.services.risk_observations import RiskObservationService
|
||||||
|
|
||||||
logger = get_logger("app.services.expense_claim_risk_review")
|
logger = get_logger("app.services.expense_claim_risk_review")
|
||||||
@@ -110,6 +111,9 @@ class ExpenseClaimRiskReviewMixin(
|
|||||||
"status": "submitted",
|
"status": "submitted",
|
||||||
"approval_stage": "直属领导审批",
|
"approval_stage": "直属领导审批",
|
||||||
"risk_flags": final_risk_flags,
|
"risk_flags": final_risk_flags,
|
||||||
|
"rule_set_fingerprint": str(
|
||||||
|
platform_risk_review.get("rule_set_fingerprint") or ""
|
||||||
|
),
|
||||||
"message": (
|
"message": (
|
||||||
f"报销单 {claim.claim_no} 已完成自动检测,"
|
f"报销单 {claim.claim_no} 已完成自动检测,"
|
||||||
f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
|
f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
|
||||||
@@ -144,9 +148,15 @@ class ExpenseClaimRiskReviewMixin(
|
|||||||
.where(or_(*filters))
|
.where(or_(*filters))
|
||||||
.where(ExpenseClaim.id != claim.id)
|
.where(ExpenseClaim.id != claim.id)
|
||||||
.where(ExpenseClaim.occurred_at >= since)
|
.where(ExpenseClaim.occurred_at >= since)
|
||||||
|
.where(
|
||||||
|
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(
|
||||||
|
ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
|
||||||
|
self.db,
|
||||||
|
claim.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
recent_claims = list(self.db.scalars(stmt).all())
|
recent_claims = list(self.db.scalars(stmt).all())
|
||||||
return sum(1 for item in recent_claims if list(item.risk_flags_json or []))
|
return sum(1 for item in recent_claims if list(item.risk_flags_json or []))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
27
server/src/app/services/expense_claim_rule_fingerprint.py
Normal file
27
server/src/app/services/expense_claim_rule_fingerprint.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def build_risk_manifest_fingerprint(manifests: list[dict[str, Any]]) -> str:
|
||||||
|
ordered_manifests = sorted(
|
||||||
|
manifests,
|
||||||
|
key=lambda manifest: json.dumps(
|
||||||
|
manifest,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
normalized = json.dumps(
|
||||||
|
ordered_manifests,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
return f"sha256:{digest}"
|
||||||
297
server/src/app/services/expense_claim_standard_adjustment.py
Normal file
297
server/src/app/services/expense_claim_standard_adjustment.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.schemas.reimbursement import (
|
||||||
|
ExpenseClaimStandardAdjustmentPayload,
|
||||||
|
TravelReimbursementCalculatorRequest,
|
||||||
|
)
|
||||||
|
from app.services.expense_claim_constants import STANDARD_ADJUSTMENT_RISK_SOURCE
|
||||||
|
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||||
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimStandardAdjustmentMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
|
||||||
|
try:
|
||||||
|
raw_value = "" if value is None else value
|
||||||
|
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
return amount if amount >= Decimal("0.00") else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_adjustment_money(value: Decimal) -> str:
|
||||||
|
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
|
||||||
|
return f"{normalized:.2f}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_standard_adjustment_days(value: Any) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value if 1 <= value <= 365 else None
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
match = re.search(r"\d{1,3}", text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
days = int(match.group(0))
|
||||||
|
return days if 1 <= days <= 365 else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_standard_adjustment_text(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text or text in {"-", "N/A", "n/a"}:
|
||||||
|
return ""
|
||||||
|
if text in {"待补充", "未知", "暂无", "非必填"}:
|
||||||
|
return ""
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _iter_standard_adjustment_application_details(
|
||||||
|
self,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
details: list[dict[str, Any]] = []
|
||||||
|
for flag in list(claim.risk_flags_json or []):
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
continue
|
||||||
|
detail = flag.get("application_detail") or flag.get("applicationDetail")
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
details.append(detail)
|
||||||
|
related = flag.get("related_application") or flag.get("relatedApplication")
|
||||||
|
if isinstance(related, dict):
|
||||||
|
details.append(related)
|
||||||
|
return details
|
||||||
|
|
||||||
|
def _resolve_standard_adjustment_days(
|
||||||
|
self,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
entry: Any,
|
||||||
|
) -> int:
|
||||||
|
direct_days = self._normalize_standard_adjustment_days(
|
||||||
|
getattr(entry, "application_days", None)
|
||||||
|
)
|
||||||
|
if direct_days is not None:
|
||||||
|
return direct_days
|
||||||
|
|
||||||
|
for detail in self._iter_standard_adjustment_application_details(claim):
|
||||||
|
for key in ("application_days", "applicationDays", "days"):
|
||||||
|
detail_days = self._normalize_standard_adjustment_days(detail.get(key))
|
||||||
|
if detail_days is not None:
|
||||||
|
return detail_days
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
getattr(entry, "risk", None),
|
||||||
|
getattr(entry, "title", None),
|
||||||
|
item.item_reason,
|
||||||
|
claim.reason,
|
||||||
|
]
|
||||||
|
for text in candidates:
|
||||||
|
match = re.search(r"(\d{1,3})\s*(?:天|晚|夜)", str(text or ""))
|
||||||
|
if match:
|
||||||
|
days = self._normalize_standard_adjustment_days(match.group(1))
|
||||||
|
if days is not None:
|
||||||
|
return days
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _resolve_standard_adjustment_location(
|
||||||
|
self,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
) -> str:
|
||||||
|
for value in (item.item_location, claim.location):
|
||||||
|
text = self._normalize_standard_adjustment_text(value)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
for detail in self._iter_standard_adjustment_application_details(claim):
|
||||||
|
for key in ("application_location", "applicationLocation", "location", "city"):
|
||||||
|
text = self._normalize_standard_adjustment_text(detail.get(key))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _resolve_policy_standard_reimbursable_amount(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
entry: Any,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> Decimal | None:
|
||||||
|
item_type = str(item.item_type or "").strip().lower()
|
||||||
|
if item_type not in {"hotel", "hotel_ticket"}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
location = self._resolve_standard_adjustment_location(claim, item)
|
||||||
|
grade = str(claim.employee_grade or current_user.grade or "").strip()
|
||||||
|
if not location or not grade:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.travel_reimbursement_calculator import (
|
||||||
|
TravelReimbursementCalculatorService,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||||||
|
TravelReimbursementCalculatorRequest(
|
||||||
|
days=self._resolve_standard_adjustment_days(claim, item, entry),
|
||||||
|
location=location,
|
||||||
|
grade=grade,
|
||||||
|
),
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._normalize_standard_adjustment_amount(result.hotel_amount)
|
||||||
|
|
||||||
|
def _resolve_standard_adjustment_reimbursable_amount(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
entry: Any,
|
||||||
|
original_amount: Decimal,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> Decimal:
|
||||||
|
policy_amount = self._resolve_policy_standard_reimbursable_amount(
|
||||||
|
claim=claim,
|
||||||
|
item=item,
|
||||||
|
entry=entry,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
if policy_amount is not None:
|
||||||
|
return min(max(policy_amount, Decimal("0.00")), original_amount)
|
||||||
|
|
||||||
|
entry_amount = self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
|
||||||
|
if entry_amount is not None:
|
||||||
|
return min(max(entry_amount, Decimal("0.00")), original_amount)
|
||||||
|
return original_amount
|
||||||
|
|
||||||
|
def accept_standard_adjustment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
claim_id: str,
|
||||||
|
payload: ExpenseClaimStandardAdjustmentPayload,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> ExpenseClaim | None:
|
||||||
|
claim = self.get_claim(claim_id, current_user)
|
||||||
|
if claim is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._ensure_draft_claim(claim)
|
||||||
|
if self._is_expense_application_claim(claim):
|
||||||
|
raise ValueError("费用申请单不支持按报销标准重算。")
|
||||||
|
|
||||||
|
risk_entries = list(payload.risks or [])
|
||||||
|
if not risk_entries:
|
||||||
|
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
|
||||||
|
|
||||||
|
before_json = self._serialize_claim(claim)
|
||||||
|
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
|
||||||
|
now_text = datetime.now(UTC).isoformat()
|
||||||
|
adjustment_flags: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for index, entry in enumerate(risk_entries, start=1):
|
||||||
|
item_id = str(entry.item_id or "").strip()
|
||||||
|
item = item_map.get(item_id)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
original_amount = (
|
||||||
|
self._normalize_standard_adjustment_amount(entry.original_amount)
|
||||||
|
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
|
||||||
|
)
|
||||||
|
reimbursable_amount = self._resolve_standard_adjustment_reimbursable_amount(
|
||||||
|
claim=claim,
|
||||||
|
item=item,
|
||||||
|
entry=entry,
|
||||||
|
original_amount=original_amount,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(
|
||||||
|
Decimal("0.01")
|
||||||
|
)
|
||||||
|
item_label = (
|
||||||
|
str(item.item_reason or "").strip()
|
||||||
|
or str(entry.title or "").strip()
|
||||||
|
or f"费用明细第 {index} 条"
|
||||||
|
)
|
||||||
|
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
|
||||||
|
message = (
|
||||||
|
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
|
||||||
|
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
|
||||||
|
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
|
||||||
|
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
|
||||||
|
)
|
||||||
|
adjustment_flags.append(
|
||||||
|
with_risk_business_stage(
|
||||||
|
{
|
||||||
|
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||||
|
"event_type": "standard_adjustment_accepted",
|
||||||
|
"severity": "medium",
|
||||||
|
"label": "接受职级标准审核",
|
||||||
|
"title": "提交人接受职级最高报销标准",
|
||||||
|
"message": message,
|
||||||
|
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
|
||||||
|
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
|
||||||
|
"risk_id": str(entry.risk_id or "").strip(),
|
||||||
|
"source_risk": source_risk,
|
||||||
|
"item_id": item_id,
|
||||||
|
"original_amount": self._format_adjustment_money(original_amount),
|
||||||
|
"reimbursable_amount": self._format_adjustment_money(
|
||||||
|
reimbursable_amount
|
||||||
|
),
|
||||||
|
"employee_absorbed_amount": self._format_adjustment_money(
|
||||||
|
employee_absorbed_amount
|
||||||
|
),
|
||||||
|
"risk_domain": "amount",
|
||||||
|
"actionability": "review_decision",
|
||||||
|
"visibility_scope": "leader",
|
||||||
|
"created_at": now_text,
|
||||||
|
},
|
||||||
|
"reimbursement",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not adjustment_flags:
|
||||||
|
raise ValueError("未找到可按职级标准重算的费用明细。")
|
||||||
|
|
||||||
|
preserved_flags = [
|
||||||
|
flag
|
||||||
|
for flag in list(claim.risk_flags_json or [])
|
||||||
|
if not (
|
||||||
|
isinstance(flag, dict)
|
||||||
|
and str(flag.get("source") or "").strip()
|
||||||
|
== STANDARD_ADJUSTMENT_RISK_SOURCE
|
||||||
|
)
|
||||||
|
]
|
||||||
|
claim.risk_flags_json = dedupe_claim_risk_flags(
|
||||||
|
[*preserved_flags, *adjustment_flags]
|
||||||
|
)
|
||||||
|
self._sync_claim_from_items(claim)
|
||||||
|
self.refresh_claim_pre_review_state(claim, is_application_claim=False)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(claim)
|
||||||
|
|
||||||
|
self.audit_service.log_action(
|
||||||
|
actor=current_user.name or current_user.username,
|
||||||
|
action="expense_claim.standard_adjustment_accept",
|
||||||
|
resource_type="expense_claim",
|
||||||
|
resource_id=claim.id,
|
||||||
|
before_json=before_json,
|
||||||
|
after_json=self._serialize_claim(claim),
|
||||||
|
)
|
||||||
|
|
||||||
|
return claim
|
||||||
73
server/src/app/services/expense_claim_tenant_scope.py
Normal file
73
server/src/app/services/expense_claim_tenant_scope.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.expense_case import ExpenseCaseLink
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
|
||||||
|
DEFAULT_TENANT_ID = "default"
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimTenantScopeMixin:
|
||||||
|
@staticmethod
|
||||||
|
def normalize_tenant_id(value: str | None) -> str:
|
||||||
|
return str(value or DEFAULT_TENANT_ID).strip() or DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_context_tenant_id(cls, context_json: dict[str, Any] | None) -> str:
|
||||||
|
context = context_json or {}
|
||||||
|
return cls.normalize_tenant_id(
|
||||||
|
context.get("tenant_id") or context.get("tenantId")
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_claim_tenant_condition(cls, tenant_id: str | None) -> Any:
|
||||||
|
"""按 Expense Case Link 隔离 Claim;默认租户兼容尚未回填的旧单。"""
|
||||||
|
|
||||||
|
normalized_tenant = cls.normalize_tenant_id(tenant_id)
|
||||||
|
same_tenant_link = (
|
||||||
|
select(ExpenseCaseLink.id)
|
||||||
|
.where(
|
||||||
|
ExpenseCaseLink.resource_type == "expense_claim",
|
||||||
|
ExpenseCaseLink.resource_id == ExpenseClaim.id,
|
||||||
|
ExpenseCaseLink.tenant_id == normalized_tenant,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
if normalized_tenant != DEFAULT_TENANT_ID:
|
||||||
|
return same_tenant_link
|
||||||
|
|
||||||
|
any_tenant_link = (
|
||||||
|
select(ExpenseCaseLink.id)
|
||||||
|
.where(
|
||||||
|
ExpenseCaseLink.resource_type == "expense_claim",
|
||||||
|
ExpenseCaseLink.resource_id == ExpenseClaim.id,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
return or_(same_tenant_link, ~any_tenant_link)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def resolve_claim_tenant_id(cls, db: Any, claim_id: str | None) -> str:
|
||||||
|
"""从 Case Link 解析 Claim 租户;无 Link 的历史单仍归 default。"""
|
||||||
|
|
||||||
|
normalized_claim_id = str(claim_id or "").strip()
|
||||||
|
if not normalized_claim_id:
|
||||||
|
return DEFAULT_TENANT_ID
|
||||||
|
tenant_id = db.scalar(
|
||||||
|
select(ExpenseCaseLink.tenant_id).where(
|
||||||
|
ExpenseCaseLink.resource_type == "expense_claim",
|
||||||
|
ExpenseCaseLink.resource_id == normalized_claim_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return cls.normalize_tenant_id(tenant_id)
|
||||||
|
|
||||||
|
def apply_tenant_scope(
|
||||||
|
self,
|
||||||
|
stmt: Any,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> Any:
|
||||||
|
return stmt.where(self.build_claim_tenant_condition(current_user.tenant_id))
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
@@ -19,9 +18,7 @@ from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
|||||||
from app.schemas.reimbursement import (
|
from app.schemas.reimbursement import (
|
||||||
ExpenseClaimItemCreate,
|
ExpenseClaimItemCreate,
|
||||||
ExpenseClaimItemUpdate,
|
ExpenseClaimItemUpdate,
|
||||||
ExpenseClaimStandardAdjustmentPayload,
|
|
||||||
ExpenseClaimUpdate,
|
ExpenseClaimUpdate,
|
||||||
TravelReimbursementCalculatorRequest,
|
|
||||||
)
|
)
|
||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
from app.services.budget_types import BudgetControlError
|
from app.services.budget_types import BudgetControlError
|
||||||
@@ -37,294 +34,35 @@ from app.services.expense_claim_attachment_operations import ExpenseClaimAttachm
|
|||||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||||
from app.services.expense_claim_constants import (
|
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
||||||
RETURN_REASON_OPTIONS,
|
|
||||||
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
|
||||||
)
|
|
||||||
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
|
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
|
||||||
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
|
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
|
||||||
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
||||||
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
|
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
|
||||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
from app.services.expense_claim_errors import (
|
||||||
|
ExpenseClaimPreReviewBlockedError,
|
||||||
|
ExpenseClaimSubmissionBlockedError,
|
||||||
|
)
|
||||||
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
||||||
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
||||||
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
|
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
|
||||||
|
from app.services.expense_claim_pre_review_decision import (
|
||||||
|
pre_review_identity_matches,
|
||||||
|
pre_review_public_payload,
|
||||||
|
)
|
||||||
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
||||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||||
|
from app.services.expense_claim_standard_adjustment import (
|
||||||
|
ExpenseClaimStandardAdjustmentMixin,
|
||||||
|
)
|
||||||
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
|
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
|
||||||
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
|
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
|
||||||
from app.services.receipt_folder import ReceiptFolderService
|
from app.services.receipt_folder import ReceiptFolderService
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimStandardAdjustmentMixin:
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
|
|
||||||
try:
|
|
||||||
raw_value = "" if value is None else value
|
|
||||||
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
|
|
||||||
except (InvalidOperation, ValueError):
|
|
||||||
return None
|
|
||||||
return amount if amount >= Decimal("0.00") else None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_adjustment_money(value: Decimal) -> str:
|
|
||||||
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
|
|
||||||
return f"{normalized:.2f}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_standard_adjustment_days(value: Any) -> int | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, int):
|
|
||||||
return value if 1 <= value <= 365 else None
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
match = re.search(r"\d{1,3}", text)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
days = int(match.group(0))
|
|
||||||
return days if 1 <= days <= 365 else None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_standard_adjustment_text(value: Any) -> str:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not text or text in {"-", "N/A", "n/a"}:
|
|
||||||
return ""
|
|
||||||
if text in {"待补充", "未知", "暂无", "非必填"}:
|
|
||||||
return ""
|
|
||||||
return text
|
|
||||||
|
|
||||||
def _iter_standard_adjustment_application_details(self, claim: ExpenseClaim) -> list[dict[str, Any]]:
|
|
||||||
details: list[dict[str, Any]] = []
|
|
||||||
for flag in list(claim.risk_flags_json or []):
|
|
||||||
if not isinstance(flag, dict):
|
|
||||||
continue
|
|
||||||
detail = flag.get("application_detail") or flag.get("applicationDetail")
|
|
||||||
if isinstance(detail, dict):
|
|
||||||
details.append(detail)
|
|
||||||
related = flag.get("related_application") or flag.get("relatedApplication")
|
|
||||||
if isinstance(related, dict):
|
|
||||||
details.append(related)
|
|
||||||
return details
|
|
||||||
|
|
||||||
def _resolve_standard_adjustment_days(
|
|
||||||
self,
|
|
||||||
claim: ExpenseClaim,
|
|
||||||
item: ExpenseClaimItem,
|
|
||||||
entry: Any,
|
|
||||||
) -> int:
|
|
||||||
direct_days = self._normalize_standard_adjustment_days(getattr(entry, "application_days", None))
|
|
||||||
if direct_days is not None:
|
|
||||||
return direct_days
|
|
||||||
|
|
||||||
for detail in self._iter_standard_adjustment_application_details(claim):
|
|
||||||
for key in ("application_days", "applicationDays", "days"):
|
|
||||||
detail_days = self._normalize_standard_adjustment_days(detail.get(key))
|
|
||||||
if detail_days is not None:
|
|
||||||
return detail_days
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
getattr(entry, "risk", None),
|
|
||||||
getattr(entry, "title", None),
|
|
||||||
item.item_reason,
|
|
||||||
claim.reason,
|
|
||||||
]
|
|
||||||
for text in candidates:
|
|
||||||
match = re.search(r"(\d{1,3})\s*(?:天|晚|夜)", str(text or ""))
|
|
||||||
if match:
|
|
||||||
days = self._normalize_standard_adjustment_days(match.group(1))
|
|
||||||
if days is not None:
|
|
||||||
return days
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def _resolve_standard_adjustment_location(
|
|
||||||
self,
|
|
||||||
claim: ExpenseClaim,
|
|
||||||
item: ExpenseClaimItem,
|
|
||||||
) -> str:
|
|
||||||
for value in (item.item_location, claim.location):
|
|
||||||
text = self._normalize_standard_adjustment_text(value)
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
for detail in self._iter_standard_adjustment_application_details(claim):
|
|
||||||
for key in ("application_location", "applicationLocation", "location", "city"):
|
|
||||||
text = self._normalize_standard_adjustment_text(detail.get(key))
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _resolve_policy_standard_reimbursable_amount(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
claim: ExpenseClaim,
|
|
||||||
item: ExpenseClaimItem,
|
|
||||||
entry: Any,
|
|
||||||
current_user: CurrentUserContext,
|
|
||||||
) -> Decimal | None:
|
|
||||||
item_type = str(item.item_type or "").strip().lower()
|
|
||||||
if item_type not in {"hotel", "hotel_ticket"}:
|
|
||||||
return None
|
|
||||||
|
|
||||||
location = self._resolve_standard_adjustment_location(claim, item)
|
|
||||||
grade = str(claim.employee_grade or current_user.grade or "").strip()
|
|
||||||
if not location or not grade:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.services.travel_reimbursement_calculator import (
|
|
||||||
TravelReimbursementCalculatorService,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = TravelReimbursementCalculatorService(self.db).calculate(
|
|
||||||
TravelReimbursementCalculatorRequest(
|
|
||||||
days=self._resolve_standard_adjustment_days(claim, item, entry),
|
|
||||||
location=location,
|
|
||||||
grade=grade,
|
|
||||||
),
|
|
||||||
current_user,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self._normalize_standard_adjustment_amount(result.hotel_amount)
|
|
||||||
|
|
||||||
def _resolve_standard_adjustment_reimbursable_amount(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
claim: ExpenseClaim,
|
|
||||||
item: ExpenseClaimItem,
|
|
||||||
entry: Any,
|
|
||||||
original_amount: Decimal,
|
|
||||||
current_user: CurrentUserContext,
|
|
||||||
) -> Decimal:
|
|
||||||
policy_amount = self._resolve_policy_standard_reimbursable_amount(
|
|
||||||
claim=claim,
|
|
||||||
item=item,
|
|
||||||
entry=entry,
|
|
||||||
current_user=current_user,
|
|
||||||
)
|
|
||||||
if policy_amount is not None:
|
|
||||||
return min(max(policy_amount, Decimal("0.00")), original_amount)
|
|
||||||
|
|
||||||
entry_amount = self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
|
|
||||||
if entry_amount is not None:
|
|
||||||
return min(max(entry_amount, Decimal("0.00")), original_amount)
|
|
||||||
return original_amount
|
|
||||||
|
|
||||||
def accept_standard_adjustment(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
claim_id: str,
|
|
||||||
payload: ExpenseClaimStandardAdjustmentPayload,
|
|
||||||
current_user: CurrentUserContext,
|
|
||||||
) -> ExpenseClaim | None:
|
|
||||||
claim = self.get_claim(claim_id, current_user)
|
|
||||||
if claim is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
self._ensure_draft_claim(claim)
|
|
||||||
if self._is_expense_application_claim(claim):
|
|
||||||
raise ValueError("费用申请单不支持按报销标准重算。")
|
|
||||||
|
|
||||||
risk_entries = list(payload.risks or [])
|
|
||||||
if not risk_entries:
|
|
||||||
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
|
|
||||||
|
|
||||||
before_json = self._serialize_claim(claim)
|
|
||||||
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
|
|
||||||
now_text = datetime.now(UTC).isoformat()
|
|
||||||
adjustment_flags: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for index, entry in enumerate(risk_entries, start=1):
|
|
||||||
item_id = str(entry.item_id or "").strip()
|
|
||||||
item = item_map.get(item_id)
|
|
||||||
if item is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
original_amount = (
|
|
||||||
self._normalize_standard_adjustment_amount(entry.original_amount)
|
|
||||||
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
|
|
||||||
)
|
|
||||||
reimbursable_amount = self._resolve_standard_adjustment_reimbursable_amount(
|
|
||||||
claim=claim,
|
|
||||||
item=item,
|
|
||||||
entry=entry,
|
|
||||||
original_amount=original_amount,
|
|
||||||
current_user=current_user,
|
|
||||||
)
|
|
||||||
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01"))
|
|
||||||
item_label = (
|
|
||||||
str(item.item_reason or "").strip()
|
|
||||||
or str(entry.title or "").strip()
|
|
||||||
or f"费用明细第 {index} 条"
|
|
||||||
)
|
|
||||||
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
|
|
||||||
message = (
|
|
||||||
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
|
|
||||||
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
|
|
||||||
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
|
|
||||||
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
|
|
||||||
)
|
|
||||||
adjustment_flags.append(
|
|
||||||
with_risk_business_stage(
|
|
||||||
{
|
|
||||||
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
|
|
||||||
"event_type": "standard_adjustment_accepted",
|
|
||||||
"severity": "medium",
|
|
||||||
"label": "接受职级标准审核",
|
|
||||||
"title": "提交人接受职级最高报销标准",
|
|
||||||
"message": message,
|
|
||||||
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
|
|
||||||
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
|
|
||||||
"risk_id": str(entry.risk_id or "").strip(),
|
|
||||||
"source_risk": source_risk,
|
|
||||||
"item_id": item_id,
|
|
||||||
"original_amount": self._format_adjustment_money(original_amount),
|
|
||||||
"reimbursable_amount": self._format_adjustment_money(reimbursable_amount),
|
|
||||||
"employee_absorbed_amount": self._format_adjustment_money(employee_absorbed_amount),
|
|
||||||
"risk_domain": "amount",
|
|
||||||
"actionability": "review_decision",
|
|
||||||
"visibility_scope": "leader",
|
|
||||||
"created_at": now_text,
|
|
||||||
},
|
|
||||||
"reimbursement",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not adjustment_flags:
|
|
||||||
raise ValueError("未找到可按职级标准重算的费用明细。")
|
|
||||||
|
|
||||||
preserved_flags = [
|
|
||||||
flag
|
|
||||||
for flag in list(claim.risk_flags_json or [])
|
|
||||||
if not (
|
|
||||||
isinstance(flag, dict)
|
|
||||||
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
|
|
||||||
)
|
|
||||||
]
|
|
||||||
claim.risk_flags_json = dedupe_claim_risk_flags([*preserved_flags, *adjustment_flags])
|
|
||||||
self._sync_claim_from_items(claim)
|
|
||||||
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(claim)
|
|
||||||
|
|
||||||
self.audit_service.log_action(
|
|
||||||
actor=current_user.name or current_user.username,
|
|
||||||
action="expense_claim.standard_adjustment_accept",
|
|
||||||
resource_type="expense_claim",
|
|
||||||
resource_id=claim.id,
|
|
||||||
before_json=before_json,
|
|
||||||
after_json=self._serialize_claim(claim),
|
|
||||||
)
|
|
||||||
|
|
||||||
return claim
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimItemActionMixin:
|
class ExpenseClaimItemActionMixin:
|
||||||
def update_claim_item(
|
def update_claim_item(
|
||||||
self,
|
self,
|
||||||
@@ -497,6 +235,8 @@ class ExpenseClaimItemActionMixin:
|
|||||||
current_user: CurrentUserContext,
|
current_user: CurrentUserContext,
|
||||||
*,
|
*,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
|
pre_review_id: str = "",
|
||||||
|
pre_review_input_fingerprint: str = "",
|
||||||
before_commit: Callable[[BusinessEvent], None] | None = None,
|
before_commit: Callable[[BusinessEvent], None] | None = None,
|
||||||
) -> ExpenseClaim | None:
|
) -> ExpenseClaim | None:
|
||||||
claim = self.get_claim(claim_id, current_user)
|
claim = self.get_claim(claim_id, current_user)
|
||||||
@@ -516,6 +256,50 @@ class ExpenseClaimItemActionMixin:
|
|||||||
if missing_fields:
|
if missing_fields:
|
||||||
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
||||||
|
|
||||||
|
before_json = self._serialize_claim(claim)
|
||||||
|
pre_review_flag = self.refresh_claim_pre_review_state(
|
||||||
|
claim,
|
||||||
|
is_application_claim=is_application_claim,
|
||||||
|
)
|
||||||
|
if pre_review_flag is None:
|
||||||
|
raise RuntimeError("无法生成提交前预审结果。")
|
||||||
|
|
||||||
|
client_review_provided = bool(
|
||||||
|
str(pre_review_id or "").strip()
|
||||||
|
or str(pre_review_input_fingerprint or "").strip()
|
||||||
|
)
|
||||||
|
client_review_matches = pre_review_identity_matches(
|
||||||
|
pre_review_flag,
|
||||||
|
review_id=pre_review_id,
|
||||||
|
input_fingerprint=pre_review_input_fingerprint,
|
||||||
|
)
|
||||||
|
correlation_id = self._expense_cases.normalize_correlation_id(
|
||||||
|
correlation_id or str(pre_review_flag.get("review_id") or "")
|
||||||
|
)
|
||||||
|
_, pre_review_event = self._record_pre_review_event(
|
||||||
|
claim,
|
||||||
|
pre_review_flag=pre_review_flag,
|
||||||
|
current_user=current_user,
|
||||||
|
is_application_claim=is_application_claim,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
correlation_id = pre_review_event.correlation_id
|
||||||
|
decision = str(pre_review_flag.get("decision") or "")
|
||||||
|
review_error_code = (
|
||||||
|
"PRE_REVIEW_NEEDS_FIX"
|
||||||
|
if decision == "needs_fix"
|
||||||
|
else "PRE_REVIEW_CHANGED"
|
||||||
|
if client_review_provided and not client_review_matches
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
if review_error_code:
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(claim)
|
||||||
|
raise ExpenseClaimPreReviewBlockedError(
|
||||||
|
pre_review_public_payload(pre_review_flag) or {},
|
||||||
|
code=review_error_code,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
budget_flags = self._reserve_budget_for_submission(
|
budget_flags = self._reserve_budget_for_submission(
|
||||||
claim,
|
claim,
|
||||||
@@ -526,7 +310,6 @@ class ExpenseClaimItemActionMixin:
|
|||||||
if is_application_claim:
|
if is_application_claim:
|
||||||
raise
|
raise
|
||||||
budget_flags = list(exc.flags or [])
|
budget_flags = list(exc.flags or [])
|
||||||
before_json = self._serialize_claim(claim)
|
|
||||||
if is_application_claim:
|
if is_application_claim:
|
||||||
submitted_at = datetime.now(UTC)
|
submitted_at = datetime.now(UTC)
|
||||||
preserved_flags = [
|
preserved_flags = [
|
||||||
@@ -572,8 +355,6 @@ class ExpenseClaimItemActionMixin:
|
|||||||
budget_flags,
|
budget_flags,
|
||||||
business_stage="reimbursement",
|
business_stage="reimbursement",
|
||||||
)
|
)
|
||||||
if not self._has_ai_pre_review_flag(claim):
|
|
||||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
|
||||||
|
|
||||||
claim.status = "submitted"
|
claim.status = "submitted"
|
||||||
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
|
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
|
||||||
@@ -587,6 +368,7 @@ class ExpenseClaimItemActionMixin:
|
|||||||
actor_id=current_user.username,
|
actor_id=current_user.username,
|
||||||
tenant_id=getattr(current_user, "tenant_id", None),
|
tenant_id=getattr(current_user, "tenant_id", None),
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
|
causation_id=pre_review_event.id if pre_review_event is not None else None,
|
||||||
idempotency_key=(
|
idempotency_key=(
|
||||||
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
|
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
|
||||||
if claim.submitted_at is not None
|
if claim.submitted_at is not None
|
||||||
@@ -617,7 +399,7 @@ class ExpenseClaimItemActionMixin:
|
|||||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||||
claim = self.get_claim(claim_id, current_user)
|
claim = self.get_claim(claim_id, current_user)
|
||||||
if claim is None and current_user.is_admin:
|
if claim is None and current_user.is_admin:
|
||||||
candidate_claim = self.db.scalar(
|
candidate_stmt = (
|
||||||
select(ExpenseClaim)
|
select(ExpenseClaim)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ExpenseClaim.items),
|
selectinload(ExpenseClaim.items),
|
||||||
@@ -626,6 +408,9 @@ class ExpenseClaimItemActionMixin:
|
|||||||
)
|
)
|
||||||
.where(ExpenseClaim.id == claim_id)
|
.where(ExpenseClaim.id == claim_id)
|
||||||
)
|
)
|
||||||
|
candidate_claim = self.db.scalar(
|
||||||
|
self._access_policy.apply_tenant_scope(candidate_stmt, current_user)
|
||||||
|
)
|
||||||
if candidate_claim is not None:
|
if candidate_claim is not None:
|
||||||
claim = candidate_claim
|
claim = candidate_claim
|
||||||
if claim is None:
|
if claim is None:
|
||||||
|
|||||||
@@ -215,6 +215,13 @@ class ExpenseReceiptAssociationService:
|
|||||||
)
|
)
|
||||||
uploaded_count += 1
|
uploaded_count += 1
|
||||||
|
|
||||||
|
if uploaded_count > 0:
|
||||||
|
# 批次附件全部落入 Claim 后只刷新一次,避免沿用归集前的陈旧预审结论。
|
||||||
|
self.case_service.mark_claiming_started(expense_case)
|
||||||
|
self.claim_service.refresh_claim_pre_review_state(
|
||||||
|
claim,
|
||||||
|
is_application_claim=False,
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(claim)
|
self.db.refresh(claim)
|
||||||
self._discard_attachment_backups(mutations)
|
self._discard_attachment_backups(mutations)
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import json
|
|||||||
import re
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zipfile import BadZipFile
|
||||||
|
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.utils.exceptions import InvalidFileException
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -15,8 +17,8 @@ from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
|||||||
from app.services.agent_asset_spreadsheet import (
|
from app.services.agent_asset_spreadsheet import (
|
||||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||||
AgentAssetSpreadsheetManager,
|
|
||||||
TRAVEL_SPREADSHEET_RULE_CODES,
|
TRAVEL_SPREADSHEET_RULE_CODES,
|
||||||
|
AgentAssetSpreadsheetManager,
|
||||||
)
|
)
|
||||||
from app.services.expense_rule_runtime_defaults import (
|
from app.services.expense_rule_runtime_defaults import (
|
||||||
DEFAULT_SCENE_MATRIX_CONFIG,
|
DEFAULT_SCENE_MATRIX_CONFIG,
|
||||||
@@ -37,10 +39,6 @@ from app.services.expense_rule_runtime_models import (
|
|||||||
build_default_expense_rule_catalog,
|
build_default_expense_rule_catalog,
|
||||||
resolve_document_type_label,
|
resolve_document_type_label,
|
||||||
)
|
)
|
||||||
from app.services.expense_rule_runtime_standards import (
|
|
||||||
build_scene_submission_standard_markdown,
|
|
||||||
build_travel_risk_control_standard_markdown,
|
|
||||||
)
|
|
||||||
from app.services.expense_rule_runtime_spreadsheet_extractors import (
|
from app.services.expense_rule_runtime_spreadsheet_extractors import (
|
||||||
extract_hotel_season_limits,
|
extract_hotel_season_limits,
|
||||||
extract_normalized_transport_class_limits,
|
extract_normalized_transport_class_limits,
|
||||||
@@ -48,8 +46,28 @@ from app.services.expense_rule_runtime_spreadsheet_extractors import (
|
|||||||
map_transport_grade_row_to_bands,
|
map_transport_grade_row_to_bands,
|
||||||
transport_class_level_for_text,
|
transport_class_level_for_text,
|
||||||
)
|
)
|
||||||
|
from app.services.expense_rule_runtime_standards import (
|
||||||
|
build_scene_submission_standard_markdown,
|
||||||
|
build_travel_risk_control_standard_markdown,
|
||||||
|
)
|
||||||
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
|
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_SCENE_MATRIX_CONFIG",
|
||||||
|
"DEFAULT_SCENE_RULE_ASSET_CODE",
|
||||||
|
"DEFAULT_TRAVEL_POLICY_CONFIG",
|
||||||
|
"DEFAULT_TRAVEL_RULE_ASSET_CODE",
|
||||||
|
"DOCUMENT_TYPE_LABELS",
|
||||||
|
"ExpenseRuleRuntimeService",
|
||||||
|
"RuntimeTravelPolicy",
|
||||||
|
"SCENE_LABELS",
|
||||||
|
"build_default_expense_rule_catalog",
|
||||||
|
"build_scene_submission_standard_markdown",
|
||||||
|
"build_travel_risk_control_standard_markdown",
|
||||||
|
"resolve_document_type_label",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ExpenseRuleRuntimeService:
|
class ExpenseRuleRuntimeService:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
@@ -225,7 +243,7 @@ class ExpenseRuleRuntimeService:
|
|||||||
read_only=True,
|
read_only=True,
|
||||||
data_only=True,
|
data_only=True,
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, OSError):
|
except (BadZipFile, FileNotFoundError, InvalidFileException, OSError):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Any, Callable
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.linked_reimbursement_draft_job import (
|
from app.schemas.linked_reimbursement_draft_job import (
|
||||||
LinkedReimbursementDraftJobCreate,
|
LinkedReimbursementDraftJobCreate,
|
||||||
LinkedReimbursementDraftJobRead,
|
LinkedReimbursementDraftJobRead,
|
||||||
)
|
)
|
||||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||||
from app.schemas.orchestrator import OrchestratorRequest
|
from app.schemas.orchestrator import OrchestratorRequest
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.orchestrator import OrchestratorService
|
from app.services.orchestrator import OrchestratorService
|
||||||
|
|
||||||
|
|
||||||
TERMINAL_STATUSES = {"succeeded", "failed"}
|
TERMINAL_STATUSES = {"succeeded", "failed"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class LinkedReimbursementDraftJobState:
|
class LinkedReimbursementDraftJobState:
|
||||||
job_id: str
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
owner_username: str
|
owner_username: str
|
||||||
owner_name: str
|
owner_name: str
|
||||||
message: str
|
message: str
|
||||||
@@ -68,11 +70,15 @@ def create_linked_reimbursement_draft_job(
|
|||||||
current_user: CurrentUserContext,
|
current_user: CurrentUserContext,
|
||||||
) -> LinkedReimbursementDraftJobRead:
|
) -> LinkedReimbursementDraftJobRead:
|
||||||
context_json = dict(payload.context_json or {})
|
context_json = dict(payload.context_json or {})
|
||||||
|
tenant_id = ExpenseClaimAccessPolicy.normalize_tenant_id(current_user.tenant_id)
|
||||||
|
context_json.pop("tenantId", None)
|
||||||
|
context_json["tenant_id"] = tenant_id
|
||||||
context_json["entry_source"] = context_json.get("entry_source") or "workbench-ai"
|
context_json["entry_source"] = context_json.get("entry_source") or "workbench-ai"
|
||||||
context_json["session_type"] = context_json.get("session_type") or "expense"
|
context_json["session_type"] = context_json.get("session_type") or "expense"
|
||||||
job_id = f"linked-reimbursement-draft-{uuid4()}"
|
job_id = f"linked-reimbursement-draft-{uuid4()}"
|
||||||
state = LinkedReimbursementDraftJobState(
|
state = LinkedReimbursementDraftJobState(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
owner_username=str(current_user.username or "").strip(),
|
owner_username=str(current_user.username or "").strip(),
|
||||||
owner_name=str(current_user.name or "").strip(),
|
owner_name=str(current_user.name or "").strip(),
|
||||||
message=str(payload.message or "").strip(),
|
message=str(payload.message or "").strip(),
|
||||||
@@ -104,7 +110,11 @@ def run_linked_reimbursement_draft_job(
|
|||||||
_update_job(job_id, status="running", status_message="正在后台生成报销草稿...")
|
_update_job(job_id, status="running", status_message="正在后台生成报销草稿...")
|
||||||
try:
|
try:
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
if _can_use_direct_save_path(db, state.context_json):
|
if _can_use_direct_save_path(
|
||||||
|
db,
|
||||||
|
state.context_json,
|
||||||
|
tenant_id=state.tenant_id,
|
||||||
|
):
|
||||||
run_id, result, draft_payload = _run_direct_save_path(
|
run_id, result, draft_payload = _run_direct_save_path(
|
||||||
db=db,
|
db=db,
|
||||||
state=state,
|
state=state,
|
||||||
@@ -156,6 +166,10 @@ def _get_authorized_state(
|
|||||||
state = _jobs.get(normalized_job_id)
|
state = _jobs.get(normalized_job_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
return None
|
return None
|
||||||
|
if state.tenant_id != ExpenseClaimAccessPolicy.normalize_tenant_id(
|
||||||
|
current_user.tenant_id
|
||||||
|
):
|
||||||
|
return None
|
||||||
if current_user.is_admin:
|
if current_user.is_admin:
|
||||||
return state
|
return state
|
||||||
username = str(current_user.username or "").strip()
|
username = str(current_user.username or "").strip()
|
||||||
@@ -182,7 +196,12 @@ def _resolve_user_id(current_user: CurrentUserContext) -> str:
|
|||||||
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
|
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
|
||||||
|
|
||||||
|
|
||||||
def _can_use_direct_save_path(db: Session, context_json: dict[str, Any]) -> bool:
|
def _can_use_direct_save_path(
|
||||||
|
db: Session,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> bool:
|
||||||
review_action = str((context_json or {}).get("review_action") or "").strip()
|
review_action = str((context_json or {}).get("review_action") or "").strip()
|
||||||
if review_action != "save_draft":
|
if review_action != "save_draft":
|
||||||
return False
|
return False
|
||||||
@@ -193,41 +212,69 @@ def _can_use_direct_save_path(db: Session, context_json: dict[str, Any]) -> bool
|
|||||||
application_claim_no = str(review_values.get("application_claim_no") or "").strip()
|
application_claim_no = str(review_values.get("application_claim_no") or "").strip()
|
||||||
if not application_claim_no:
|
if not application_claim_no:
|
||||||
return False
|
return False
|
||||||
if application_claim_id:
|
return (
|
||||||
return True
|
_find_application_claim(
|
||||||
return _find_application_claim_by_no(db, application_claim_no) is not None
|
db,
|
||||||
|
claim_id=application_claim_id,
|
||||||
|
claim_no=application_claim_no,
|
||||||
def _find_application_claim_by_no(db: Session, claim_no: str) -> ExpenseClaim | None:
|
tenant_id=tenant_id,
|
||||||
normalized_claim_no = str(claim_no or "").strip()
|
|
||||||
if not normalized_claim_no:
|
|
||||||
return None
|
|
||||||
claim = db.scalar(
|
|
||||||
select(ExpenseClaim)
|
|
||||||
.where(ExpenseClaim.claim_no == normalized_claim_no)
|
|
||||||
.limit(1)
|
|
||||||
)
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_application_claim(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
claim_id: str = "",
|
||||||
|
claim_no: str = "",
|
||||||
|
tenant_id: str,
|
||||||
|
) -> ExpenseClaim | None:
|
||||||
|
normalized_claim_id = str(claim_id or "").strip()
|
||||||
|
normalized_claim_no = str(claim_no or "").strip()
|
||||||
|
if not normalized_claim_id and not normalized_claim_no:
|
||||||
|
return None
|
||||||
|
stmt = select(ExpenseClaim)
|
||||||
|
if normalized_claim_id:
|
||||||
|
stmt = stmt.where(ExpenseClaim.id == normalized_claim_id)
|
||||||
|
if normalized_claim_no:
|
||||||
|
stmt = stmt.where(ExpenseClaim.claim_no == normalized_claim_no)
|
||||||
|
stmt = stmt.where(
|
||||||
|
ExpenseClaimAccessPolicy.build_claim_tenant_condition(tenant_id)
|
||||||
|
)
|
||||||
|
claim = db.scalar(stmt.limit(1))
|
||||||
if claim is not None and ExpenseClaimService._is_expense_application_claim(claim):
|
if claim is not None and ExpenseClaimService._is_expense_application_claim(claim):
|
||||||
return claim
|
return claim
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_direct_context_json(db: Session, context_json: dict[str, Any]) -> dict[str, Any]:
|
def _build_direct_context_json(
|
||||||
|
db: Session,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
direct_context = dict(context_json or {})
|
direct_context = dict(context_json or {})
|
||||||
|
direct_context.pop("tenantId", None)
|
||||||
|
direct_context["tenant_id"] = ExpenseClaimAccessPolicy.normalize_tenant_id(
|
||||||
|
tenant_id
|
||||||
|
)
|
||||||
review_values = dict(direct_context.get("review_form_values") or {})
|
review_values = dict(direct_context.get("review_form_values") or {})
|
||||||
scene_selection = dict(direct_context.get("expense_scene_selection") or {})
|
scene_selection = dict(direct_context.get("expense_scene_selection") or {})
|
||||||
application_claim_id = str(review_values.get("application_claim_id") or "").strip()
|
application_claim_id = str(review_values.get("application_claim_id") or "").strip()
|
||||||
application_claim_no = str(review_values.get("application_claim_no") or "").strip()
|
application_claim_no = str(review_values.get("application_claim_no") or "").strip()
|
||||||
if not application_claim_id and application_claim_no:
|
application_claim = _find_application_claim(
|
||||||
application_claim = _find_application_claim_by_no(db, application_claim_no)
|
db,
|
||||||
if application_claim is not None:
|
claim_id=application_claim_id,
|
||||||
|
claim_no=application_claim_no,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
if application_claim is None:
|
||||||
|
raise ValueError("关联申请单不存在或不属于当前租户。")
|
||||||
review_values["application_claim_id"] = application_claim.id
|
review_values["application_claim_id"] = application_claim.id
|
||||||
|
review_values["application_claim_no"] = application_claim.claim_no
|
||||||
scene_selection["application_claim_id"] = application_claim.id
|
scene_selection["application_claim_id"] = application_claim.id
|
||||||
scene_selection["application_claim_no"] = str(
|
scene_selection["application_claim_no"] = application_claim.claim_no
|
||||||
scene_selection.get("application_claim_no")
|
|
||||||
or application_claim.claim_no
|
|
||||||
or application_claim_no
|
|
||||||
).strip()
|
|
||||||
direct_context["review_form_values"] = review_values
|
direct_context["review_form_values"] = review_values
|
||||||
if scene_selection:
|
if scene_selection:
|
||||||
direct_context["expense_scene_selection"] = scene_selection
|
direct_context["expense_scene_selection"] = scene_selection
|
||||||
@@ -257,7 +304,11 @@ def _run_direct_save_path(
|
|||||||
user_id=_resolve_user_id(current_user),
|
user_id=_resolve_user_id(current_user),
|
||||||
message=state.message,
|
message=state.message,
|
||||||
ontology=ontology,
|
ontology=ontology,
|
||||||
context_json=_build_direct_context_json(db, state.context_json),
|
context_json=_build_direct_context_json(
|
||||||
|
db,
|
||||||
|
state.context_json,
|
||||||
|
tenant_id=state.tenant_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
claim_id = str(result.get("claim_id") or "").strip()
|
claim_id = str(result.get("claim_id") or "").strip()
|
||||||
claim_no = str(result.get("claim_no") or "").strip()
|
claim_no = str(result.get("claim_no") or "").strip()
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ EXPLICIT_ENTERTAINMENT_KEYWORDS = (
|
|||||||
"商务宴请",
|
"商务宴请",
|
||||||
"接待餐",
|
"接待餐",
|
||||||
)
|
)
|
||||||
|
ENGLISH_FINANCE_BUSINESS_KEYWORDS = (
|
||||||
|
"reimbursement",
|
||||||
|
"expenseclaim",
|
||||||
|
"travelapplication",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OntologyDetectionMixin:
|
class OntologyDetectionMixin:
|
||||||
@@ -101,6 +106,8 @@ class OntologyDetectionMixin:
|
|||||||
|
|
||||||
if self._looks_like_expense_application(compact_query):
|
if self._looks_like_expense_application(compact_query):
|
||||||
return True
|
return True
|
||||||
|
if any(keyword in compact_query for keyword in ENGLISH_FINANCE_BUSINESS_KEYWORDS):
|
||||||
|
return True
|
||||||
|
|
||||||
domain_keywords = [
|
domain_keywords = [
|
||||||
keyword
|
keyword
|
||||||
|
|||||||
@@ -537,6 +537,7 @@ class StewardActionExecutor:
|
|||||||
"session_type": "expense",
|
"session_type": "expense",
|
||||||
"entry_source": "steward_action_executor",
|
"entry_source": "steward_action_executor",
|
||||||
"review_action": "save_draft",
|
"review_action": "save_draft",
|
||||||
|
"tenant_id": current_user.tenant_id,
|
||||||
"review_form_values": review_form_values,
|
"review_form_values": review_form_values,
|
||||||
"user_input_text": self._resolve_message(request),
|
"user_input_text": self._resolve_message(request),
|
||||||
"role_codes": current_user.role_codes,
|
"role_codes": current_user.role_codes,
|
||||||
|
|||||||
@@ -385,6 +385,12 @@ class UserAgentReviewMessageMixin:
|
|||||||
if str(item).strip()
|
if str(item).strip()
|
||||||
],
|
],
|
||||||
is_admin=bool(payload.context_json.get("is_admin")),
|
is_admin=bool(payload.context_json.get("is_admin")),
|
||||||
|
tenant_id=str(
|
||||||
|
payload.context_json.get("tenant_id")
|
||||||
|
or payload.context_json.get("tenantId")
|
||||||
|
or "default"
|
||||||
|
).strip()
|
||||||
|
or "default",
|
||||||
department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(),
|
department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -589,6 +595,12 @@ class UserAgentReviewMessageMixin:
|
|||||||
if str(item).strip()
|
if str(item).strip()
|
||||||
],
|
],
|
||||||
is_admin=bool(payload.context_json.get("is_admin")),
|
is_admin=bool(payload.context_json.get("is_admin")),
|
||||||
|
tenant_id=str(
|
||||||
|
payload.context_json.get("tenant_id")
|
||||||
|
or payload.context_json.get("tenantId")
|
||||||
|
or "default"
|
||||||
|
).strip()
|
||||||
|
or "default",
|
||||||
department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(),
|
department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -422,6 +422,13 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(
|
|||||||
assert claim is not None
|
assert claim is not None
|
||||||
attached_items = [item for item in claim.items if item.invoice_id]
|
attached_items = [item for item in claim.items if item.invoice_id]
|
||||||
assert len(attached_items) == 2
|
assert len(attached_items) == 2
|
||||||
|
pre_review_flags = [
|
||||||
|
flag
|
||||||
|
for flag in list(claim.risk_flags_json or [])
|
||||||
|
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||||
|
]
|
||||||
|
assert len(pre_review_flags) == 1
|
||||||
|
assert pre_review_flags[0]["created_at"]
|
||||||
receipt_links = list(
|
receipt_links = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt")
|
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt")
|
||||||
@@ -438,6 +445,13 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(
|
|||||||
"receipt_received",
|
"receipt_received",
|
||||||
"attachment_associated",
|
"attachment_associated",
|
||||||
}
|
}
|
||||||
|
expense_case = db.scalar(
|
||||||
|
select(ExpenseCase).where(
|
||||||
|
ExpenseCase.id == receipt_links[0].expense_case_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert expense_case is not None
|
||||||
|
assert expense_case.current_stage == "claiming"
|
||||||
|
|
||||||
linked_receipts = receipt_service.list_receipts(
|
linked_receipts = receipt_service.list_receipts(
|
||||||
current_user=current_user, status_filter="linked"
|
current_user=current_user, status_filter="linked"
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ def test_expense_case_timeline_rejects_cross_tenant_lookup(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert response.json()["detail"] == "该单据尚未纳入统一费用事件。"
|
assert response.json()["detail"] == "费用单据不存在。"
|
||||||
|
|
||||||
|
|
||||||
def test_expense_case_timeline_returns_not_covered_for_claim_without_case(
|
def test_expense_case_timeline_returns_not_covered_for_claim_without_case(
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ from sqlalchemy.pool import StaticPool
|
|||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.budget import BudgetAllocation
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.services.agent_foundation import AgentFoundationService
|
from app.services.agent_foundation import AgentFoundationService
|
||||||
from app.services.expense_cases import ExpenseCaseService
|
from app.services.expense_cases import ExpenseCaseService
|
||||||
|
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||||
from app.services.expense_claim_workflow_constants import (
|
from app.services.expense_claim_workflow_constants import (
|
||||||
APPLICATION_ARCHIVE_STAGE,
|
APPLICATION_ARCHIVE_STAGE,
|
||||||
APPLICATION_LINK_STATUS_STAGE,
|
APPLICATION_LINK_STATUS_STAGE,
|
||||||
@@ -120,6 +121,302 @@ def test_event_write_uses_caller_transaction_and_tenant_scope() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_review_records_one_idempotent_expense_case_event() -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="pre-review-owner@example.com",
|
||||||
|
name="张三",
|
||||||
|
role_codes=["user"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="default",
|
||||||
|
)
|
||||||
|
with build_session() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="PRE-REVIEW-MANAGER",
|
||||||
|
name="李经理",
|
||||||
|
email="pre-review-manager@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="PRE-REVIEW-OWNER",
|
||||||
|
name="张三",
|
||||||
|
email=current_user.username,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW", employee=employee)
|
||||||
|
claim.risk_flags_json = [
|
||||||
|
{
|
||||||
|
"source": "manual_risk",
|
||||||
|
"severity": "high",
|
||||||
|
"label": "票据风险",
|
||||||
|
"message": "票据金额与行程不匹配。",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
db.add_all([manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
first = service.pre_review_claim(
|
||||||
|
claim.id,
|
||||||
|
current_user,
|
||||||
|
correlation_id="pre-review-request-1",
|
||||||
|
idempotency_key="pre-review-request-1",
|
||||||
|
)
|
||||||
|
repeated = service.pre_review_claim(
|
||||||
|
claim.id,
|
||||||
|
current_user,
|
||||||
|
correlation_id="pre-review-request-1",
|
||||||
|
idempotency_key="pre-review-request-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first is not None and repeated is not None
|
||||||
|
events = list(
|
||||||
|
db.scalars(
|
||||||
|
select(BusinessEvent).where(
|
||||||
|
BusinessEvent.aggregate_id == claim.id,
|
||||||
|
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
assert event.correlation_id == "pre-review-request-1"
|
||||||
|
assert event.idempotency_key.startswith("pre-review:")
|
||||||
|
assert event.payload_json["review_status"] == "failed"
|
||||||
|
assert event.payload_json["passed"] is False
|
||||||
|
assert event.payload_json["blocking_risk_count"] == 1
|
||||||
|
assert event.payload_json["business_stage"] == "reimbursement"
|
||||||
|
assert "重大风险" in event.payload_json["message"]
|
||||||
|
assert db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_review_event_failure_rolls_back_claim_and_case(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="pre-review-rollback@example.com",
|
||||||
|
name="张三",
|
||||||
|
role_codes=["user"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="default",
|
||||||
|
)
|
||||||
|
with build_session() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="PRE-REVIEW-ROLLBACK-MANAGER",
|
||||||
|
name="李经理",
|
||||||
|
email="pre-review-rollback-manager@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="PRE-REVIEW-ROLLBACK",
|
||||||
|
name="张三",
|
||||||
|
email=current_user.username,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW-ROLLBACK", employee=employee)
|
||||||
|
original_flags = [
|
||||||
|
{
|
||||||
|
"source": "manual_risk",
|
||||||
|
"severity": "high",
|
||||||
|
"label": "原始风险",
|
||||||
|
"message": "必须保留。",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
claim.risk_flags_json = original_flags
|
||||||
|
db.add_all([manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
claim_id = claim.id
|
||||||
|
|
||||||
|
def fail_event(*_args, **_kwargs):
|
||||||
|
raise RuntimeError("simulated pre-review event failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
|
||||||
|
with pytest.raises(RuntimeError, match="simulated pre-review event failure"):
|
||||||
|
ExpenseClaimService(db).pre_review_claim(
|
||||||
|
claim_id,
|
||||||
|
current_user,
|
||||||
|
correlation_id="pre-review-rollback",
|
||||||
|
)
|
||||||
|
|
||||||
|
persisted_claim = db.get(ExpenseClaim, claim_id)
|
||||||
|
assert persisted_claim is not None
|
||||||
|
assert persisted_claim.risk_flags_json == original_flags
|
||||||
|
assert db.scalar(select(ExpenseCase)) is None
|
||||||
|
assert db.scalar(select(ExpenseCaseLink)) is None
|
||||||
|
assert db.scalar(select(BusinessEvent)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_blocks_fixable_pre_review_before_budget_and_submission_event(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="pre-review-block-owner@example.com",
|
||||||
|
name="张三",
|
||||||
|
role_codes=["user"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="default",
|
||||||
|
)
|
||||||
|
with build_session() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="PRE-REVIEW-BLOCK-MANAGER",
|
||||||
|
name="李经理",
|
||||||
|
email="pre-review-block-manager@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="PRE-REVIEW-BLOCK-OWNER",
|
||||||
|
name="张三",
|
||||||
|
email=current_user.username,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW-BLOCK", employee=employee)
|
||||||
|
claim.risk_flags_json = [
|
||||||
|
{
|
||||||
|
"source": "manual_risk",
|
||||||
|
"severity": "high",
|
||||||
|
"actionability": "fixable_by_submitter",
|
||||||
|
"business_stage": "reimbursement",
|
||||||
|
"label": "票据与明细不一致",
|
||||||
|
"message": "请更正费用明细或重新上传正确票据。",
|
||||||
|
"item_ids": [claim.items[0].id],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
db.add_all([manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||||
|
assert reviewed is not None
|
||||||
|
pre_review_flag = next(
|
||||||
|
flag
|
||||||
|
for flag in reviewed.risk_flags_json
|
||||||
|
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||||
|
)
|
||||||
|
assert pre_review_flag["decision"] == "needs_fix"
|
||||||
|
|
||||||
|
def fail_budget(*_args, **_kwargs):
|
||||||
|
raise AssertionError("预审阻断后不应占用预算")
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_reserve_budget_for_submission", fail_budget)
|
||||||
|
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||||
|
service.submit_claim(
|
||||||
|
claim.id,
|
||||||
|
current_user,
|
||||||
|
pre_review_id=pre_review_flag["review_id"],
|
||||||
|
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error_info.value.review["decision"] == "needs_fix"
|
||||||
|
persisted_claim = db.get(ExpenseClaim, claim.id)
|
||||||
|
assert persisted_claim is not None
|
||||||
|
assert persisted_claim.status == "draft"
|
||||||
|
assert persisted_claim.submitted_at is None
|
||||||
|
pre_review_events = list(
|
||||||
|
db.scalars(
|
||||||
|
select(BusinessEvent).where(
|
||||||
|
BusinessEvent.aggregate_id == claim.id,
|
||||||
|
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
assert len(pre_review_events) == 1
|
||||||
|
assert db.scalar(
|
||||||
|
select(BusinessEvent).where(
|
||||||
|
BusinessEvent.aggregate_id == claim.id,
|
||||||
|
BusinessEvent.event_type == "claim_submitted",
|
||||||
|
)
|
||||||
|
) is None
|
||||||
|
assert db.scalar(select(BudgetReservation)) is None
|
||||||
|
assert db.scalar(select(BudgetTransaction)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_rechecks_dynamic_risk_context_and_rejects_stale_ready_review(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="dynamic-review-owner@example.com",
|
||||||
|
name="张三",
|
||||||
|
role_codes=["user"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="default",
|
||||||
|
)
|
||||||
|
with build_session() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="DYNAMIC-REVIEW-MANAGER",
|
||||||
|
name="李经理",
|
||||||
|
email="dynamic-review-manager@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="DYNAMIC-REVIEW-OWNER",
|
||||||
|
name="张三",
|
||||||
|
email=current_user.username,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = build_claim(
|
||||||
|
claim_no="RE-CASE-DYNAMIC-REVIEW",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
review_calls = 0
|
||||||
|
|
||||||
|
def dynamic_review(_claim):
|
||||||
|
nonlocal review_calls
|
||||||
|
review_calls += 1
|
||||||
|
risk_flags = []
|
||||||
|
if review_calls > 1:
|
||||||
|
risk_flags = [
|
||||||
|
{
|
||||||
|
"source": "submission_review",
|
||||||
|
"severity": "high",
|
||||||
|
"actionability": "fixable_by_submitter",
|
||||||
|
"business_stage": "reimbursement",
|
||||||
|
"label": "重复发票",
|
||||||
|
"message": "预审后发现同一发票已被其他单据使用。",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"risk_flags": risk_flags,
|
||||||
|
"rule_set_fingerprint": "rules-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_run_ai_submission_review", dynamic_review)
|
||||||
|
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||||
|
assert reviewed is not None
|
||||||
|
ready_flag = next(
|
||||||
|
flag
|
||||||
|
for flag in reviewed.risk_flags_json
|
||||||
|
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||||
|
)
|
||||||
|
assert ready_flag["decision"] == "ready"
|
||||||
|
|
||||||
|
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||||
|
service.submit_claim(
|
||||||
|
claim.id,
|
||||||
|
current_user,
|
||||||
|
pre_review_id=ready_flag["review_id"],
|
||||||
|
pre_review_input_fingerprint=ready_flag["input_fingerprint"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error_info.value.code == "PRE_REVIEW_NEEDS_FIX"
|
||||||
|
assert error_info.value.review["review_id"] != ready_flag["review_id"]
|
||||||
|
assert error_info.value.review["decision"] == "needs_fix"
|
||||||
|
assert db.get(ExpenseClaim, claim.id).status == "draft"
|
||||||
|
assert db.scalar(
|
||||||
|
select(BusinessEvent).where(
|
||||||
|
BusinessEvent.aggregate_id == claim.id,
|
||||||
|
BusinessEvent.event_type == "claim_submitted",
|
||||||
|
)
|
||||||
|
) is None
|
||||||
|
pre_review_events = list(
|
||||||
|
db.scalars(
|
||||||
|
select(BusinessEvent).where(
|
||||||
|
BusinessEvent.aggregate_id == claim.id,
|
||||||
|
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
assert len(pre_review_events) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_bootstrap_excludes_migration_owned_tables(
|
def test_legacy_bootstrap_excludes_migration_owned_tables(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -215,12 +512,24 @@ def test_submit_claim_creates_case_link_and_structured_event() -> None:
|
|||||||
assert submitted.status == "submitted"
|
assert submitted.status == "submitted"
|
||||||
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == submitted.id))
|
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == submitted.id))
|
||||||
assert link is not None
|
assert link is not None
|
||||||
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == submitted.id))
|
pre_review_event = db.scalar(
|
||||||
assert event is not None
|
select(BusinessEvent).where(
|
||||||
assert event.event_type == "claim_submitted"
|
BusinessEvent.aggregate_id == submitted.id,
|
||||||
assert event.delivery_status == "pending"
|
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||||
assert event.payload_json["previous_status"] == "draft"
|
)
|
||||||
assert event.payload_json["next_status"] == "submitted"
|
)
|
||||||
|
submitted_event = db.scalar(
|
||||||
|
select(BusinessEvent).where(
|
||||||
|
BusinessEvent.aggregate_id == submitted.id,
|
||||||
|
BusinessEvent.event_type == "claim_submitted",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert pre_review_event is not None and submitted_event is not None
|
||||||
|
assert submitted_event.delivery_status == "pending"
|
||||||
|
assert submitted_event.correlation_id == pre_review_event.correlation_id
|
||||||
|
assert submitted_event.causation_id == pre_review_event.id
|
||||||
|
assert submitted_event.payload_json["previous_status"] == "draft"
|
||||||
|
assert submitted_event.payload_json["next_status"] == "submitted"
|
||||||
|
|
||||||
|
|
||||||
def test_payment_event_failure_rolls_back_payment_archive_and_nested_audit(
|
def test_payment_event_failure_rolls_back_payment_archive_and_nested_audit(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.models.employee import Employee
|
|||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
|
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||||
from app.services.expense_claim_workflow_constants import (
|
from app.services.expense_claim_workflow_constants import (
|
||||||
APPLICATION_LINK_STATUS_STAGE,
|
APPLICATION_LINK_STATUS_STAGE,
|
||||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||||
@@ -311,7 +312,7 @@ def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
|
def test_high_risk_application_under_90_percent_routes_to_budget_manager() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP")
|
department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP")
|
||||||
_seed_budget_allocation(
|
_seed_budget_allocation(
|
||||||
@@ -362,16 +363,169 @@ def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manage
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert approved is not None
|
assert approved is not None
|
||||||
assert approved.status == "approved"
|
assert approved.status == "submitted"
|
||||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
assert approved.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||||
route_flag = [
|
route_flag = [
|
||||||
flag
|
flag
|
||||||
for flag in approved.risk_flags_json
|
for flag in approved.risk_flags_json
|
||||||
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
|
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
|
||||||
][0]
|
][0]
|
||||||
assert route_flag["requires_budget_review"] is False
|
assert route_flag["requires_budget_review"] is True
|
||||||
assert route_flag["route"] == "approval_done"
|
assert route_flag["route"] == "budget_manager"
|
||||||
assert route_flag["current_risk_count"] == 1
|
assert route_flag["current_risk_count"] == 1
|
||||||
|
assert any("申请信息风险" in reason for reason in route_flag["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixable_high_risk_application_is_blocked_before_direct_manager() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
department, _manager, _budget_manager, employee = _seed_people(
|
||||||
|
db,
|
||||||
|
suffix="FIXABLE-RISK-APP",
|
||||||
|
)
|
||||||
|
_seed_budget_allocation(
|
||||||
|
db,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
amount=Decimal("10000.00"),
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="APP-20260530-FIXABLE-RISK",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="客户现场支持",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
risk_flags_json=[
|
||||||
|
{
|
||||||
|
"source": "manual_risk",
|
||||||
|
"severity": "high",
|
||||||
|
"actionability": "fixable_by_submitter",
|
||||||
|
"label": "申请事由不完整",
|
||||||
|
"message": "请先补充客户与项目说明。",
|
||||||
|
"business_stage": "expense_application",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username=employee.email,
|
||||||
|
name=employee.name,
|
||||||
|
employee_id=employee.id,
|
||||||
|
role_codes=["user"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="default",
|
||||||
|
)
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
|
||||||
|
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||||
|
assert reviewed is not None
|
||||||
|
pre_review_flag = next(
|
||||||
|
flag
|
||||||
|
for flag in reviewed.risk_flags_json
|
||||||
|
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||||
|
)
|
||||||
|
assert pre_review_flag["decision"] == "needs_fix"
|
||||||
|
|
||||||
|
with pytest.raises(ExpenseClaimPreReviewBlockedError):
|
||||||
|
service.submit_claim(
|
||||||
|
claim.id,
|
||||||
|
current_user,
|
||||||
|
pre_review_id=pre_review_flag["review_id"],
|
||||||
|
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db.get(ExpenseClaim, claim.id).status == "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_decision_high_risk_application_flows_from_pre_review_to_p8() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
department, manager, _budget_manager, employee = _seed_people(
|
||||||
|
db,
|
||||||
|
suffix="REVIEW-RISK-APP",
|
||||||
|
)
|
||||||
|
_seed_budget_allocation(
|
||||||
|
db,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
amount=Decimal("10000.00"),
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="APP-20260530-REVIEW-RISK",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="客户现场支持",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
risk_flags_json=[
|
||||||
|
{
|
||||||
|
"source": "manual_risk",
|
||||||
|
"severity": "high",
|
||||||
|
"actionability": "review_decision",
|
||||||
|
"label": "特殊项目风险",
|
||||||
|
"message": "该项目需预算管理者确认。",
|
||||||
|
"business_stage": "expense_application",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username=employee.email,
|
||||||
|
name=employee.name,
|
||||||
|
employee_id=employee.id,
|
||||||
|
role_codes=["user"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="default",
|
||||||
|
)
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
|
||||||
|
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||||
|
assert reviewed is not None
|
||||||
|
pre_review_flag = next(
|
||||||
|
flag
|
||||||
|
for flag in reviewed.risk_flags_json
|
||||||
|
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||||
|
)
|
||||||
|
assert pre_review_flag["decision"] == "ready_with_review"
|
||||||
|
submitted = service.submit_claim(
|
||||||
|
claim.id,
|
||||||
|
current_user,
|
||||||
|
pre_review_id=pre_review_flag["review_id"],
|
||||||
|
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||||
|
)
|
||||||
|
assert submitted is not None
|
||||||
|
assert submitted.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
||||||
|
|
||||||
|
routed = service.approve_claim(
|
||||||
|
claim.id,
|
||||||
|
CurrentUserContext(
|
||||||
|
username=manager.email,
|
||||||
|
name=manager.name,
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="default",
|
||||||
|
),
|
||||||
|
opinion="业务必要,同意申请。",
|
||||||
|
)
|
||||||
|
assert routed is not None
|
||||||
|
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||||
|
|
||||||
|
|
||||||
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||||
@@ -520,6 +674,65 @@ def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolved_high_risk_application_does_not_route_to_budget_manager() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
department, manager, _budget_manager, employee = _seed_people(
|
||||||
|
db,
|
||||||
|
suffix="RESOLVED-RISK-APP",
|
||||||
|
)
|
||||||
|
_seed_budget_allocation(
|
||||||
|
db,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
amount=Decimal("10000.00"),
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="APP-20260530-RESOLVED-RISK",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="客户现场支持",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
risk_flags_json=[
|
||||||
|
{
|
||||||
|
"source": "submission_review",
|
||||||
|
"severity": "high",
|
||||||
|
"label": "已整改风险",
|
||||||
|
"message": "申请人已完成补充说明。",
|
||||||
|
"business_stage": "expense_application",
|
||||||
|
"resolution_status": "resolved",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
routed = ExpenseClaimService(db).approve_claim(
|
||||||
|
claim.id,
|
||||||
|
CurrentUserContext(
|
||||||
|
username=manager.email,
|
||||||
|
name=manager.name,
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
opinion="风险已整改,同意申请。",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert routed is not None
|
||||||
|
assert routed.status == "approved"
|
||||||
|
assert routed.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||||
|
|
||||||
|
|
||||||
def test_budget_manager_blank_opinion_defaults_to_agree_when_budget_under_warning() -> None:
|
def test_budget_manager_blank_opinion_defaults_to_agree_when_budget_under_warning() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL")
|
department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL")
|
||||||
|
|||||||
196
server/tests/test_expense_claim_pre_review_decision.py
Normal file
196
server/tests/test_expense_claim_pre_review_decision.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.services.expense_claim_pre_review_decision import (
|
||||||
|
build_pre_review_decision,
|
||||||
|
pre_review_identity_matches,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_claim(*, risk_flags: list[dict] | None = None) -> ExpenseClaim:
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-pre-review-decision",
|
||||||
|
claim_no="RE-PRE-REVIEW-DECISION",
|
||||||
|
employee_id="employee-pre-review",
|
||||||
|
employee_name="张三",
|
||||||
|
department_id="department-pre-review",
|
||||||
|
department_name="市场部",
|
||||||
|
project_code="PRJ-PRE-REVIEW",
|
||||||
|
expense_type="travel",
|
||||||
|
reason="客户现场差旅",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("88.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
risk_flags_json=list(risk_flags or []),
|
||||||
|
)
|
||||||
|
claim.items = [
|
||||||
|
ExpenseClaimItem(
|
||||||
|
id="item-pre-review-decision",
|
||||||
|
claim_id=claim.id,
|
||||||
|
item_date=date(2026, 7, 16),
|
||||||
|
item_type="travel",
|
||||||
|
item_reason="客户现场交通",
|
||||||
|
item_location="上海",
|
||||||
|
item_note="",
|
||||||
|
item_amount=Decimal("88.00"),
|
||||||
|
invoice_id="receipt.png",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_review_identity_is_stable_when_risk_input_order_changes() -> None:
|
||||||
|
risk_flags = [
|
||||||
|
{"source": "manual_a", "severity": "low", "message": "A"},
|
||||||
|
{"source": "manual_b", "severity": "medium", "message": "B"},
|
||||||
|
]
|
||||||
|
first = build_pre_review_decision(
|
||||||
|
build_claim(risk_flags=risk_flags),
|
||||||
|
risk_flags=risk_flags,
|
||||||
|
business_stage="reimbursement",
|
||||||
|
platform_rule_set_fingerprint="rules-v1",
|
||||||
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
repeated = build_pre_review_decision(
|
||||||
|
build_claim(risk_flags=list(reversed(risk_flags))),
|
||||||
|
risk_flags=list(reversed(risk_flags)),
|
||||||
|
business_stage="reimbursement",
|
||||||
|
platform_rule_set_fingerprint="rules-v1",
|
||||||
|
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repeated["review_id"] == first["review_id"]
|
||||||
|
assert repeated["input_fingerprint"] == first["input_fingerprint"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_review_identity_is_stable_when_nested_risk_ids_change_order() -> None:
|
||||||
|
first_flags = [
|
||||||
|
{
|
||||||
|
"source": "manual_risk",
|
||||||
|
"severity": "medium",
|
||||||
|
"message": "多条明细需复核。",
|
||||||
|
"item_ids": ["item-b", "item-a"],
|
||||||
|
"basic_rule_refs": ["rule-b", "rule-a"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
repeated_flags = [
|
||||||
|
{
|
||||||
|
**first_flags[0],
|
||||||
|
"item_ids": ["item-a", "item-b"],
|
||||||
|
"basic_rule_refs": ["rule-a", "rule-b"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
first = build_pre_review_decision(
|
||||||
|
build_claim(risk_flags=first_flags),
|
||||||
|
risk_flags=first_flags,
|
||||||
|
business_stage="reimbursement",
|
||||||
|
platform_rule_set_fingerprint="rules-v1",
|
||||||
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
repeated = build_pre_review_decision(
|
||||||
|
build_claim(risk_flags=repeated_flags),
|
||||||
|
risk_flags=repeated_flags,
|
||||||
|
business_stage="reimbursement",
|
||||||
|
platform_rule_set_fingerprint="rules-v1",
|
||||||
|
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repeated["review_id"] == first["review_id"]
|
||||||
|
assert repeated["review_context_fingerprint"] == first[
|
||||||
|
"review_context_fingerprint"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_review_identity_changes_when_dynamic_findings_change() -> None:
|
||||||
|
claim = build_claim()
|
||||||
|
ready = build_pre_review_decision(
|
||||||
|
claim,
|
||||||
|
risk_flags=[],
|
||||||
|
business_stage="reimbursement",
|
||||||
|
platform_rule_set_fingerprint="rules-v1",
|
||||||
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
changed = build_pre_review_decision(
|
||||||
|
claim,
|
||||||
|
risk_flags=[
|
||||||
|
{
|
||||||
|
"source": "submission_review",
|
||||||
|
"severity": "high",
|
||||||
|
"actionability": "fixable_by_submitter",
|
||||||
|
"message": "预审后发现同一发票已被其他单据使用。",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
business_stage="reimbursement",
|
||||||
|
platform_rule_set_fingerprint="rules-v1",
|
||||||
|
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert changed["input_fingerprint"] == ready["input_fingerprint"]
|
||||||
|
assert changed["review_context_fingerprint"] != ready[
|
||||||
|
"review_context_fingerprint"
|
||||||
|
]
|
||||||
|
assert changed["review_id"] != ready["review_id"]
|
||||||
|
assert changed["decision"] == "needs_fix"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_review_blocks_high_fixable_risk_and_normalizes_string_item_id() -> None:
|
||||||
|
claim = build_claim()
|
||||||
|
decision = build_pre_review_decision(
|
||||||
|
claim,
|
||||||
|
risk_flags=[
|
||||||
|
{
|
||||||
|
"source": "manual_risk",
|
||||||
|
"severity": "high",
|
||||||
|
"actionability": "fixable_by_submitter",
|
||||||
|
"message": "住宿金额超过标准,请修正。",
|
||||||
|
"item_ids": "item-pre-review-decision",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
business_stage="reimbursement",
|
||||||
|
platform_rule_set_fingerprint="rules-v1",
|
||||||
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decision["decision"] == "needs_fix"
|
||||||
|
assert decision["blocking_count"] == 1
|
||||||
|
assert decision["findings"][0]["item_ids"] == ["item-pre-review-decision"]
|
||||||
|
assert (
|
||||||
|
decision["findings"][0]["remediation"]["alternative_action"]
|
||||||
|
== "accept_standard_limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_review_routes_budget_governance_risk_to_approval_instead_of_blocking() -> None:
|
||||||
|
claim = build_claim()
|
||||||
|
decision = build_pre_review_decision(
|
||||||
|
claim,
|
||||||
|
risk_flags=[
|
||||||
|
{
|
||||||
|
"source": "budget_control",
|
||||||
|
"severity": "critical",
|
||||||
|
"risk_domain": "budget",
|
||||||
|
"actionability": "budget_governance",
|
||||||
|
"message": "预算使用率超过治理阈值。",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
business_stage="reimbursement",
|
||||||
|
platform_rule_set_fingerprint="rules-v1",
|
||||||
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decision["decision"] == "ready_with_review"
|
||||||
|
assert decision["blocking_count"] == 0
|
||||||
|
assert decision["findings"][0]["disposition"] == "review"
|
||||||
|
assert pre_review_identity_matches(
|
||||||
|
{**decision},
|
||||||
|
review_id=decision["review_id"],
|
||||||
|
input_fingerprint=decision["input_fingerprint"],
|
||||||
|
)
|
||||||
@@ -31,6 +31,7 @@ from app.services.budget import BudgetService
|
|||||||
from app.services.document_preview import DocumentPreviewAssets
|
from app.services.document_preview import DocumentPreviewAssets
|
||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||||
|
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||||
from app.services.expense_claim_workflow_constants import (
|
from app.services.expense_claim_workflow_constants import (
|
||||||
APPLICATION_ARCHIVE_STAGE,
|
APPLICATION_ARCHIVE_STAGE,
|
||||||
APPLICATION_LINK_STATUS_STAGE,
|
APPLICATION_LINK_STATUS_STAGE,
|
||||||
@@ -249,7 +250,7 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim(
|
|||||||
issues = service._validate_claim_for_submission(claim)
|
issues = service._validate_claim_for_submission(claim)
|
||||||
|
|
||||||
assert "业务地点未完善" in issues
|
assert "业务地点未完善" in issues
|
||||||
assert any("缺少地点" in item for item in issues)
|
assert not any("缺少地点" in item for item in issues)
|
||||||
|
|
||||||
|
|
||||||
def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
|
def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
|
||||||
@@ -3256,7 +3257,7 @@ def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path)
|
|||||||
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
|
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
|
||||||
|
|
||||||
|
|
||||||
def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
|
def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="emp-1",
|
username="emp-1",
|
||||||
name="张三",
|
name="张三",
|
||||||
@@ -3271,10 +3272,10 @@ def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
claim_id = claim.id
|
claim_id = claim.id
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="只有 admin 管理员可以删除单据"):
|
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
|
||||||
|
|
||||||
assert db.get(ExpenseClaim, claim_id) is not None
|
assert deleted is not None
|
||||||
|
assert db.get(ExpenseClaim, claim_id) is None
|
||||||
|
|
||||||
|
|
||||||
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
|
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
|
||||||
@@ -3411,7 +3412,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
|||||||
assert submitted.approval_stage == "直属领导审批"
|
assert submitted.approval_stage == "直属领导审批"
|
||||||
assert submitted.submitted_at is not None
|
assert submitted.submitted_at is not None
|
||||||
|
|
||||||
def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypatch) -> None:
|
def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="emp-submit@example.com",
|
username="emp-submit@example.com",
|
||||||
name="submitter",
|
name="submitter",
|
||||||
@@ -3419,10 +3420,15 @@ def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypa
|
|||||||
is_admin=False,
|
is_admin=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def fail_review(self, reviewed_claim):
|
original_review = ExpenseClaimService._run_ai_submission_review
|
||||||
raise AssertionError("submit should reuse upload-time pre-review")
|
review_calls = 0
|
||||||
|
|
||||||
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fail_review)
|
def count_review(self, reviewed_claim):
|
||||||
|
nonlocal review_calls
|
||||||
|
review_calls += 1
|
||||||
|
return original_review(self, reviewed_claim)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", count_review)
|
||||||
|
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
manager = Employee(
|
manager = Employee(
|
||||||
@@ -3462,8 +3468,18 @@ def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypa
|
|||||||
|
|
||||||
assert submitted is not None
|
assert submitted is not None
|
||||||
assert submitted.status == "submitted"
|
assert submitted.status == "submitted"
|
||||||
assert any(flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json)
|
assert review_calls == 1
|
||||||
assert any(flag.get("source") == "ai_pre_review" for flag in submitted.risk_flags_json)
|
assert not any(
|
||||||
|
flag.get("label") == "upload-time-warning"
|
||||||
|
for flag in submitted.risk_flags_json
|
||||||
|
)
|
||||||
|
pre_review_flag = next(
|
||||||
|
flag
|
||||||
|
for flag in submitted.risk_flags_json
|
||||||
|
if flag.get("source") == "ai_pre_review"
|
||||||
|
)
|
||||||
|
assert pre_review_flag["review_id"]
|
||||||
|
assert pre_review_flag["input_fingerprint"].startswith("sha256:")
|
||||||
|
|
||||||
|
|
||||||
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
|
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
|
||||||
@@ -3732,7 +3748,7 @@ def test_submit_claim_backfills_department_from_current_employee() -> None:
|
|||||||
assert submitted.approval_stage == "直属领导审批"
|
assert submitted.approval_stage == "直属领导审批"
|
||||||
|
|
||||||
|
|
||||||
def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -3798,19 +3814,22 @@ def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
|||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
submitted = service.submit_claim(claim.id, current_user)
|
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||||
|
service.submit_claim(claim.id, current_user)
|
||||||
|
|
||||||
assert submitted is not None
|
blocked = db.get(ExpenseClaim, claim.id)
|
||||||
assert submitted.status == "submitted"
|
assert blocked is not None
|
||||||
assert submitted.approval_stage == "直属领导审批"
|
assert blocked.status == "draft"
|
||||||
assert submitted.submitted_at is not None
|
assert blocked.submitted_at is None
|
||||||
|
assert error_info.value.review["decision"] == "needs_fix"
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
|
finding["severity"] == "high"
|
||||||
for flag in list(submitted.risk_flags_json or [])
|
and finding["disposition"] == "fix"
|
||||||
|
for finding in error_info.value.review["findings"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -3968,34 +3987,22 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
|||||||
fake_platform_route_review,
|
fake_platform_route_review,
|
||||||
)
|
)
|
||||||
|
|
||||||
submitted = service.submit_claim(claim.id, current_user)
|
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||||
|
service.submit_claim(claim.id, current_user)
|
||||||
|
|
||||||
assert submitted is not None
|
blocked = db.get(ExpenseClaim, claim.id)
|
||||||
assert submitted.status == "submitted"
|
assert blocked is not None
|
||||||
assert submitted.approval_stage == "直属领导审批"
|
assert blocked.status == "draft"
|
||||||
assert any(
|
assert error_info.value.review["decision"] == "needs_fix"
|
||||||
isinstance(flag, dict)
|
route_findings = [
|
||||||
and str(flag.get("source") or "").strip() == "submission_review"
|
finding
|
||||||
and (
|
for finding in error_info.value.review["findings"]
|
||||||
"多城市" in str(flag.get("message") or "")
|
if "多城市" in finding["message"] or "终点" in finding["message"]
|
||||||
or "终点" in str(flag.get("message") or "")
|
|
||||||
)
|
|
||||||
for flag in list(submitted.risk_flags_json or [])
|
|
||||||
)
|
|
||||||
route_flags = [
|
|
||||||
flag
|
|
||||||
for flag in list(submitted.risk_flags_json or [])
|
|
||||||
if isinstance(flag, dict)
|
|
||||||
and str(flag.get("source") or "").strip() == "submission_review"
|
|
||||||
and str(flag.get("label") or "").strip() in {"行程终点异常", "多城市行程待说明"}
|
|
||||||
]
|
]
|
||||||
assert route_flags
|
assert route_findings
|
||||||
assert all(flag.get("item_ids") for flag in route_flags)
|
assert any(
|
||||||
assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags)
|
"travel-item-2" in finding["item_ids"]
|
||||||
assert not any(
|
for finding in route_findings
|
||||||
isinstance(flag, dict)
|
|
||||||
and str(flag.get("label") or "").strip() == "多城市行程缺少说明中风险"
|
|
||||||
for flag in list(submitted.risk_flags_json or [])
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4147,7 +4154,7 @@ def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
|
def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -4282,16 +4289,21 @@ def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_re
|
|||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
submitted = service.submit_claim(claim.id, current_user)
|
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||||
|
service.submit_claim(claim.id, current_user)
|
||||||
|
|
||||||
assert submitted is not None
|
blocked = db.get(ExpenseClaim, claim.id)
|
||||||
assert submitted.status == "submitted"
|
assert blocked is not None
|
||||||
assert submitted.approval_stage == "直属领导审批"
|
assert blocked.status == "draft"
|
||||||
|
assert error_info.value.review["decision"] == "needs_fix"
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(flag, dict)
|
finding.get("remediation", {}).get("alternative_action")
|
||||||
and str(flag.get("source") or "").strip() == "submission_review"
|
== "accept_standard_limit"
|
||||||
and "住宿标准" in str(flag.get("message") or "")
|
for finding in error_info.value.review["findings"]
|
||||||
for flag in list(submitted.risk_flags_json or [])
|
)
|
||||||
|
assert any(
|
||||||
|
"住宿" in finding["message"] or "酒店" in finding["message"]
|
||||||
|
for finding in error_info.value.review["findings"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4949,13 +4961,13 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
|||||||
for flag in returned.risk_flags_json
|
for flag in returned.risk_flags_json
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="只有高级财务人员可以删除"):
|
with pytest.raises(ValueError, match="申请人本人可以删除单据"):
|
||||||
service.delete_claim(claim_id, current_user)
|
service.delete_claim(claim_id, current_user)
|
||||||
|
|
||||||
assert db.get(ExpenseClaim, claim_id) is not None
|
assert db.get(ExpenseClaim, claim_id) is not None
|
||||||
|
|
||||||
|
|
||||||
def test_executive_can_delete_submitted_claim() -> None:
|
def test_executive_cannot_delete_submitted_claim_without_admin_role() -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="executive-delete@example.com",
|
username="executive-delete@example.com",
|
||||||
name="高管",
|
name="高管",
|
||||||
@@ -4985,11 +4997,10 @@ def test_executive_can_delete_submitted_claim() -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
claim_id = claim.id
|
claim_id = claim.id
|
||||||
|
|
||||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
with pytest.raises(ValueError, match="只有草稿"):
|
||||||
|
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||||
|
|
||||||
assert deleted is not None
|
assert db.get(ExpenseClaim, claim_id) is not None
|
||||||
assert deleted.claim_no == "EXP-DEL-EXEC-101"
|
|
||||||
assert db.get(ExpenseClaim, claim_id) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_direct_manager_cannot_delete_application_claim() -> None:
|
def test_direct_manager_cannot_delete_application_claim() -> None:
|
||||||
@@ -6784,16 +6795,16 @@ def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert approved is not None
|
assert approved is not None
|
||||||
assert approved.status == "submitted"
|
assert approved.status == "approved"
|
||||||
assert approved.approval_stage == "预算管理者审批"
|
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(flag, dict)
|
isinstance(flag, dict)
|
||||||
and flag.get("event_type") == "expense_application_approval"
|
and flag.get("event_type") == "expense_application_approval"
|
||||||
and flag.get("opinion") == "同意"
|
and flag.get("opinion") == "同意"
|
||||||
and flag.get("next_approval_stage") == "预算管理者审批"
|
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
|
||||||
for flag in approved.risk_flags_json
|
for flag in approved.risk_flags_json
|
||||||
)
|
)
|
||||||
assert reimbursement_claim_query(db).count() == 0
|
assert reimbursement_claim_query(db).count() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
|
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
|
||||||
|
|||||||
318
server/tests/test_expense_claim_tenant_and_case_stage.py
Normal file
318
server/tests/test_expense_claim_tenant_and_case_stage.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.budget import BudgetAllocation
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.models.organization import OrganizationUnit
|
||||||
|
from app.schemas.ontology import OntologyParseResult
|
||||||
|
from app.schemas.steward import StewardActionExecuteRequest, StewardTask
|
||||||
|
from app.schemas.user_agent import UserAgentRequest
|
||||||
|
from app.services.expense_cases import ExpenseCaseService
|
||||||
|
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||||
|
from app.services.expense_claim_workflow_constants import (
|
||||||
|
APPLICATION_LINK_STATUS_STAGE,
|
||||||
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
)
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
from app.services.steward_action_executor import StewardActionExecutor
|
||||||
|
from app.services.travel_reimbursement_calculator import (
|
||||||
|
TravelReimbursementCalculatorService,
|
||||||
|
)
|
||||||
|
from app.services.user_agent import UserAgentService
|
||||||
|
|
||||||
|
|
||||||
|
def build_session() -> Session:
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ontology_draft_event_uses_context_tenant() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="TENANT-DRAFT-EMPLOYEE",
|
||||||
|
name="租户草稿员工",
|
||||||
|
email="tenant-draft@example.com",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||||
|
run_id="tenant-draft-run",
|
||||||
|
user_id=employee.email,
|
||||||
|
message="2026-07-16 在上海拜访客户,交通费 32 元,保存草稿",
|
||||||
|
ontology=OntologyParseResult(run_id="tenant-draft-run"),
|
||||||
|
context_json={
|
||||||
|
"tenant_id": "tenant-expense-a",
|
||||||
|
"name": employee.name,
|
||||||
|
"review_action": "save_draft",
|
||||||
|
"review_form_values": {
|
||||||
|
"expense_type": "交通费",
|
||||||
|
"occurred_date": "2026-07-16",
|
||||||
|
"location": "上海",
|
||||||
|
"reason": "客户拜访",
|
||||||
|
"amount": "32元",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
event = db.scalar(
|
||||||
|
select(BusinessEvent).where(BusinessEvent.aggregate_id == result["claim_id"])
|
||||||
|
)
|
||||||
|
link = db.scalar(
|
||||||
|
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == result["claim_id"])
|
||||||
|
)
|
||||||
|
assert event is not None
|
||||||
|
assert event.tenant_id == "tenant-expense-a"
|
||||||
|
assert link is not None
|
||||||
|
assert link.tenant_id == "tenant-expense-a"
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSubmitTenantProbe(ExpenseClaimReviewPreviewMixin):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.submission_user: CurrentUserContext | None = None
|
||||||
|
|
||||||
|
def upsert_draft_from_ontology(self, **_kwargs):
|
||||||
|
return {"claim_id": "tenant-review-claim", "draft_only": True}
|
||||||
|
|
||||||
|
def submit_claim(self, _claim_id: str, current_user: CurrentUserContext):
|
||||||
|
self.submission_user = current_user
|
||||||
|
return SimpleNamespace(
|
||||||
|
id="tenant-review-claim",
|
||||||
|
claim_no="BX-TENANT-REVIEW",
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="直属领导审批",
|
||||||
|
amount=Decimal("32.00"),
|
||||||
|
invoice_count=1,
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_next_step_uses_context_tenant() -> None:
|
||||||
|
probe = ReviewSubmitTenantProbe()
|
||||||
|
result = probe.save_or_submit_from_ontology(
|
||||||
|
run_id="tenant-review-run",
|
||||||
|
user_id="tenant-review@example.com",
|
||||||
|
message="确认提交",
|
||||||
|
ontology=OntologyParseResult(run_id="tenant-review-run"),
|
||||||
|
context_json={
|
||||||
|
"review_action": "next_step",
|
||||||
|
"tenant_id": "tenant-expense-b",
|
||||||
|
"name": "租户预览员工",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "submitted"
|
||||||
|
assert probe.submission_user is not None
|
||||||
|
assert probe.submission_user.tenant_id == "tenant-expense-b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_calculators_use_context_tenant(monkeypatch) -> None:
|
||||||
|
captured_tenants: list[str] = []
|
||||||
|
|
||||||
|
def fake_calculate(_self, _payload, current_user):
|
||||||
|
captured_tenants.append(current_user.tenant_id)
|
||||||
|
return SimpleNamespace(
|
||||||
|
grade="P4",
|
||||||
|
days=2,
|
||||||
|
matched_city="上海",
|
||||||
|
hotel_rate=Decimal("300.00"),
|
||||||
|
hotel_amount=Decimal("600.00"),
|
||||||
|
total_allowance_rate=Decimal("100.00"),
|
||||||
|
allowance_amount=Decimal("200.00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(TravelReimbursementCalculatorService, "calculate", fake_calculate)
|
||||||
|
with build_session() as db:
|
||||||
|
expense_service = ExpenseClaimService(db)
|
||||||
|
expense_service._build_expense_review_preview_calculation_copy(
|
||||||
|
user_id="tenant-calculator@example.com",
|
||||||
|
message="2026-07-16 至 2026-07-17 去上海出差,交通费 32 元",
|
||||||
|
ontology=OntologyParseResult(run_id="tenant-calculator-preview"),
|
||||||
|
context_json={
|
||||||
|
"tenant_id": "tenant-calculator",
|
||||||
|
"employee_grade": "P4",
|
||||||
|
"location": "上海",
|
||||||
|
"occurred_date": "2026-07-16",
|
||||||
|
"amount": "32元",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = UserAgentRequest(
|
||||||
|
run_id="tenant-calculator-message",
|
||||||
|
user_id="tenant-calculator@example.com",
|
||||||
|
message="上海出差",
|
||||||
|
ontology=OntologyParseResult(run_id="tenant-calculator-message"),
|
||||||
|
context_json={
|
||||||
|
"tenant_id": "tenant-calculator",
|
||||||
|
"grade": "P4",
|
||||||
|
"name": "租户测算员工",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
UserAgentService(db)._build_travel_receipt_estimate_copy(
|
||||||
|
payload,
|
||||||
|
travel_receipt_state={
|
||||||
|
"destination": "上海",
|
||||||
|
"days": 2,
|
||||||
|
"ticket_type_label": "火车票",
|
||||||
|
"ticket_amount": "32",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured_tenants == ["tenant-calculator", "tenant-calculator"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_steward_reimbursement_context_uses_current_tenant() -> None:
|
||||||
|
task = StewardTask(
|
||||||
|
task_id="tenant-steward-task",
|
||||||
|
task_type="reimbursement",
|
||||||
|
assigned_agent="reimbursement_assistant",
|
||||||
|
title="租户报销",
|
||||||
|
summary="上海客户拜访交通费 32 元",
|
||||||
|
requested_action="save_draft",
|
||||||
|
ontology_fields={
|
||||||
|
"expense_type": "transport",
|
||||||
|
"time_range": "2026-07-16",
|
||||||
|
"location": "上海",
|
||||||
|
"reason": "客户拜访",
|
||||||
|
"amount": "32元",
|
||||||
|
},
|
||||||
|
missing_fields=[],
|
||||||
|
confirmation_required=False,
|
||||||
|
)
|
||||||
|
request = StewardActionExecuteRequest(
|
||||||
|
action_type="create_reimbursement_draft",
|
||||||
|
message="保存报销草稿",
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="tenant-steward@example.com",
|
||||||
|
name="租户小财管家用户",
|
||||||
|
role_codes=["user"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="tenant-steward",
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
context = StewardActionExecutor(db)._build_reimbursement_context_json(
|
||||||
|
request,
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context["tenant_id"] == "tenant-steward"
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_approval_keeps_case_at_approved_to_spend() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
department = OrganizationUnit(
|
||||||
|
unit_code="TENANT-CASE-TRAVEL",
|
||||||
|
name="租户差旅部",
|
||||||
|
unit_type="department",
|
||||||
|
)
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="TENANT-CASE-MANAGER",
|
||||||
|
name="租户差旅经理",
|
||||||
|
email="tenant-case-manager@example.com",
|
||||||
|
organization_unit=department,
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="TENANT-CASE-EMPLOYEE",
|
||||||
|
name="租户差旅员工",
|
||||||
|
email="tenant-case-employee@example.com",
|
||||||
|
manager=manager,
|
||||||
|
organization_unit=department,
|
||||||
|
)
|
||||||
|
db.add_all([department, manager, employee])
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
BudgetAllocation(
|
||||||
|
budget_no="BUD-TENANT-CASE-TRAVEL",
|
||||||
|
fiscal_year=2026,
|
||||||
|
period_type="year",
|
||||||
|
period_key="2026",
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
cost_center=None,
|
||||||
|
project_code=None,
|
||||||
|
subject_code="travel",
|
||||||
|
subject_name="差旅费",
|
||||||
|
original_amount=Decimal("50000.00"),
|
||||||
|
adjusted_amount=Decimal("0.00"),
|
||||||
|
status="active",
|
||||||
|
warning_threshold=Decimal("80.00"),
|
||||||
|
control_action="block",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
application = ExpenseClaim(
|
||||||
|
claim_no="AP-TENANT-CASE-GENERATE",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
project_code="PRJ-TENANT-CASE",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="客户现场差旅",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add(application)
|
||||||
|
db.flush()
|
||||||
|
ExpenseCaseService(db).ensure_case_for_claim(
|
||||||
|
application,
|
||||||
|
tenant_id="tenant-case-stage",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
approved = ExpenseClaimService(db).approve_claim(
|
||||||
|
application.id,
|
||||||
|
CurrentUserContext(
|
||||||
|
username=manager.email,
|
||||||
|
name=manager.name,
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="tenant-case-stage",
|
||||||
|
),
|
||||||
|
opinion="业务必要,同意申请",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert approved is not None
|
||||||
|
assert approved.status == "approved"
|
||||||
|
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||||
|
expense_case = db.scalar(select(ExpenseCase))
|
||||||
|
assert expense_case is not None
|
||||||
|
assert expense_case.tenant_id == "tenant-case-stage"
|
||||||
|
assert expense_case.current_stage == "approved_to_spend"
|
||||||
|
events = list(
|
||||||
|
db.scalars(
|
||||||
|
select(BusinessEvent)
|
||||||
|
.where(BusinessEvent.expense_case_id == expense_case.id)
|
||||||
|
.order_by(BusinessEvent.occurred_at)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
assert [event.event_type for event in events] == [
|
||||||
|
"application_approved",
|
||||||
|
"reimbursement_draft_generated",
|
||||||
|
]
|
||||||
|
assert {event.tenant_id for event in events} == {"tenant-case-stage"}
|
||||||
289
server/tests/test_expense_claim_tenant_scope.py
Normal file
289
server/tests/test_expense_claim_tenant_scope.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
||||||
|
from app.schemas.reimbursement import ExpenseClaimUpdate
|
||||||
|
from app.services.budget import BudgetService
|
||||||
|
from app.services.expense_cases import ExpenseCaseService
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
from app.test_helpers.db import build_in_memory_session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def _build_claim(*, claim_id: str, claim_no: str, employee: Employee) -> ExpenseClaim:
|
||||||
|
return ExpenseClaim(
|
||||||
|
id=claim_id,
|
||||||
|
claim_no=claim_no,
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id="tenant-scope-department",
|
||||||
|
department_name="租户隔离部",
|
||||||
|
project_code=None,
|
||||||
|
expense_type="office",
|
||||||
|
reason=f"{claim_no} 原始事由",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("88.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||||
|
submitted_at=None,
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user(tenant_id: str) -> CurrentUserContext:
|
||||||
|
return CurrentUserContext(
|
||||||
|
username="same-owner@example.com",
|
||||||
|
name="同名员工",
|
||||||
|
role_codes=["user"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_identity_claims_are_isolated_by_server_tenant() -> None:
|
||||||
|
session_factory = build_in_memory_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
employee = Employee(
|
||||||
|
id="tenant-scope-employee",
|
||||||
|
employee_no="TENANT-SCOPE-001",
|
||||||
|
name="同名员工",
|
||||||
|
email="same-owner@example.com",
|
||||||
|
)
|
||||||
|
tenant_a_claim = _build_claim(
|
||||||
|
claim_id="tenant-a-claim",
|
||||||
|
claim_no="RE-TENANT-A",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
tenant_b_claim = _build_claim(
|
||||||
|
claim_id="tenant-b-claim",
|
||||||
|
claim_no="RE-TENANT-B",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
legacy_default_claim = _build_claim(
|
||||||
|
claim_id="legacy-default-claim",
|
||||||
|
claim_no="RE-LEGACY-DEFAULT",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
db.add_all([employee, tenant_a_claim, tenant_b_claim, legacy_default_claim])
|
||||||
|
db.flush()
|
||||||
|
case_service = ExpenseCaseService(db)
|
||||||
|
case_service.ensure_case_for_claim(tenant_a_claim, tenant_id="tenant-a")
|
||||||
|
case_service.ensure_case_for_claim(tenant_b_claim, tenant_id="tenant-b")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
tenant_a_user = _current_user("tenant-a")
|
||||||
|
tenant_b_user = _current_user("tenant-b")
|
||||||
|
default_user = _current_user("default")
|
||||||
|
|
||||||
|
assert {claim.id for claim in service.list_claims(tenant_a_user)} == {
|
||||||
|
tenant_a_claim.id
|
||||||
|
}
|
||||||
|
assert {claim.id for claim in service.list_claims(tenant_b_user)} == {
|
||||||
|
tenant_b_claim.id
|
||||||
|
}
|
||||||
|
assert {claim.id for claim in service.list_claims(default_user)} == {
|
||||||
|
legacy_default_claim.id
|
||||||
|
}
|
||||||
|
assert service.get_claim(tenant_b_claim.id, tenant_a_user) is None
|
||||||
|
assert service.get_claim(tenant_a_claim.id, tenant_b_user) is None
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.update_claim(
|
||||||
|
claim_id=tenant_b_claim.id,
|
||||||
|
payload=ExpenseClaimUpdate(reason="跨租户篡改"),
|
||||||
|
current_user=tenant_a_user,
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(ExpenseClaim, tenant_b_claim.id).reason == "RE-TENANT-B 原始事由"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_cross_tenant_risk_history(db):
|
||||||
|
employee = Employee(
|
||||||
|
id="tenant-history-employee",
|
||||||
|
employee_no="TENANT-HISTORY-001",
|
||||||
|
name="同名风险员工",
|
||||||
|
email="same-risk-owner@example.com",
|
||||||
|
)
|
||||||
|
clean_claim = _build_claim(
|
||||||
|
claim_id="tenant-a-clean-claim",
|
||||||
|
claim_no="RE-TENANT-A-CLEAN",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
risky_claim = _build_claim(
|
||||||
|
claim_id="tenant-b-risky-claim",
|
||||||
|
claim_no="RE-TENANT-B-RISKY",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
risky_claim.risk_flags_json = [
|
||||||
|
{
|
||||||
|
"source": "submission_review",
|
||||||
|
"severity": "high",
|
||||||
|
"label": "跨租户风险",
|
||||||
|
"message": "该风险只属于 tenant-b。",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
db.add_all([employee, clean_claim, risky_claim])
|
||||||
|
db.flush()
|
||||||
|
case_service = ExpenseCaseService(db)
|
||||||
|
case_service.ensure_case_for_claim(clean_claim, tenant_id="tenant-a")
|
||||||
|
case_service.ensure_case_for_claim(risky_claim, tenant_id="tenant-b")
|
||||||
|
db.commit()
|
||||||
|
return clean_claim
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_tenant_risk_history_does_not_pollute_pre_review(monkeypatch) -> None:
|
||||||
|
with build_in_memory_session_factory()() as db:
|
||||||
|
clean_claim = _seed_cross_tenant_risk_history(db)
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
monkeypatch.setattr(service, "_resolve_claim_manager_name", lambda _claim: "直属领导")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_run_travel_policy_review",
|
||||||
|
lambda _claim: {"flags": [], "blocking_reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_run_scene_policy_review",
|
||||||
|
lambda _claim: {"flags": [], "blocking_reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"evaluate_platform_risk_rules",
|
||||||
|
lambda _claim, **_kwargs: {"flags": [], "rule_set_fingerprint": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service._count_recent_risky_claims(clean_claim) == 0
|
||||||
|
pre_review = service.refresh_claim_pre_review_state(
|
||||||
|
clean_claim,
|
||||||
|
is_application_claim=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pre_review is not None
|
||||||
|
assert pre_review["decision"] == "ready"
|
||||||
|
assert not any(
|
||||||
|
isinstance(flag, dict) and str(flag.get("label") or "").startswith("历史风险")
|
||||||
|
for flag in clean_claim.risk_flags_json
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_tenant_risk_history_does_not_route_to_p8(monkeypatch) -> None:
|
||||||
|
with build_in_memory_session_factory()() as db:
|
||||||
|
clean_claim = _seed_cross_tenant_risk_history(db)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
BudgetService,
|
||||||
|
"analyze_claim_budget",
|
||||||
|
lambda _self, _claim: {
|
||||||
|
"score": 100,
|
||||||
|
"rating": "pass",
|
||||||
|
"risk_level": "low",
|
||||||
|
"summary": "预算正常",
|
||||||
|
"metrics": {},
|
||||||
|
"budget_context": {"budget_applicable": False},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
|
||||||
|
assert service._count_recent_substantive_risky_claims(clean_claim) == 0
|
||||||
|
route = service._build_approval_route_decision(
|
||||||
|
clean_claim,
|
||||||
|
is_application_claim=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert route["historical_risk_count"] == 0
|
||||||
|
assert route["requires_budget_review"] is False
|
||||||
|
assert route["route"] == "finance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_lookup_never_returns_cross_tenant_claim_and_keeps_default_legacy() -> None:
|
||||||
|
session_factory = build_in_memory_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
employee = Employee(
|
||||||
|
id="tenant-draft-employee",
|
||||||
|
employee_no="TENANT-DRAFT-001",
|
||||||
|
name="同名草稿员工",
|
||||||
|
email="same-draft-owner@example.com",
|
||||||
|
)
|
||||||
|
tenant_a_claim = _build_claim(
|
||||||
|
claim_id="tenant-a-draft",
|
||||||
|
claim_no="RE-TENANT-A-DRAFT",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
tenant_b_claim = _build_claim(
|
||||||
|
claim_id="tenant-b-draft",
|
||||||
|
claim_no="RE-TENANT-B-DRAFT",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
legacy_default_claim = _build_claim(
|
||||||
|
claim_id="legacy-default-draft",
|
||||||
|
claim_no="RE-LEGACY-DEFAULT-DRAFT",
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
db.add_all([employee, tenant_a_claim, tenant_b_claim, legacy_default_claim])
|
||||||
|
db.flush()
|
||||||
|
case_service = ExpenseCaseService(db)
|
||||||
|
case_service.ensure_case_for_claim(tenant_a_claim, tenant_id="tenant-a")
|
||||||
|
case_service.ensure_case_for_claim(tenant_b_claim, tenant_id="tenant-b")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
tenant_b_ontology = OntologyParseResult(
|
||||||
|
run_id="tenant-b-draft-lookup",
|
||||||
|
entities=[
|
||||||
|
OntologyEntity(
|
||||||
|
type="expense_claim",
|
||||||
|
value=tenant_b_claim.claim_no,
|
||||||
|
normalized_value=tenant_b_claim.claim_no,
|
||||||
|
confidence=1.0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
empty_ontology = OntologyParseResult(run_id="tenant-draft-candidate")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service._find_target_claim(
|
||||||
|
ontology=empty_ontology,
|
||||||
|
context_json={
|
||||||
|
"tenant_id": "tenant-a",
|
||||||
|
"draft_claim_id": tenant_b_claim.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
service._find_target_claim(
|
||||||
|
ontology=tenant_b_ontology,
|
||||||
|
context_json={"tenantId": "tenant-a"},
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
association_candidate = service._find_association_candidate(
|
||||||
|
ontology=empty_ontology,
|
||||||
|
context_json={
|
||||||
|
"tenant_id": "tenant-a",
|
||||||
|
"draft_claim_id": tenant_b_claim.id,
|
||||||
|
},
|
||||||
|
user_id=employee.email,
|
||||||
|
employee=employee,
|
||||||
|
)
|
||||||
|
assert association_candidate is not None
|
||||||
|
assert association_candidate.id == tenant_a_claim.id
|
||||||
|
|
||||||
|
legacy_by_id = service._find_target_claim(
|
||||||
|
ontology=empty_ontology,
|
||||||
|
context_json={
|
||||||
|
"tenant_id": "default",
|
||||||
|
"draft_claim_id": legacy_default_claim.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert legacy_by_id is not None
|
||||||
|
assert legacy_by_id.id == legacy_default_claim.id
|
||||||
78
server/tests/test_expense_rule_runtime_resilience.py
Normal file
78
server/tests/test_expense_rule_runtime_resilience.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from openpyxl.utils.exceptions import InvalidFileException
|
||||||
|
|
||||||
|
from app.services.agent_asset_spreadsheet import (
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
AgentAssetSpreadsheetManager,
|
||||||
|
)
|
||||||
|
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||||
|
from app.services.expense_rule_runtime_models import build_default_expense_rule_catalog
|
||||||
|
|
||||||
|
|
||||||
|
def _spreadsheet_asset() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
config_json={
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"rule_document": {"storage_key": "corrupted.xlsx"},
|
||||||
|
},
|
||||||
|
current_version="v1.0.0",
|
||||||
|
name="临时损坏规则",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _spreadsheet_version() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(content="", version="v1.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_unreadable_workbook_is_skipped(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
workbook_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
AgentAssetSpreadsheetManager,
|
||||||
|
"resolve_storage_path",
|
||||||
|
lambda _manager, _storage_key: workbook_path,
|
||||||
|
)
|
||||||
|
catalog = build_default_expense_rule_catalog()
|
||||||
|
default_catalog = deepcopy(catalog)
|
||||||
|
|
||||||
|
ExpenseRuleRuntimeService(db=None)._apply_spreadsheet_runtime_payload(
|
||||||
|
catalog,
|
||||||
|
asset=_spreadsheet_asset(),
|
||||||
|
version=_spreadsheet_version(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert catalog == default_catalog
|
||||||
|
assert catalog.travel_policy is not None
|
||||||
|
assert catalog.scene_policies
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupted_spreadsheet_is_skipped_without_breaking_default_catalog(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
corrupted_workbook = tmp_path / "corrupted.xlsx"
|
||||||
|
corrupted_workbook.write_bytes(b"not-an-xlsx-archive")
|
||||||
|
|
||||||
|
_assert_unreadable_workbook_is_skipped(monkeypatch, corrupted_workbook)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_spreadsheet_format_is_skipped_without_breaking_catalog(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
invalid_workbook = tmp_path / "invalid.xlsx"
|
||||||
|
invalid_workbook.touch()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.expense_rule_runtime.load_workbook",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||||
|
InvalidFileException("unsupported workbook format")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_assert_unreadable_workbook_is_skipped(monkeypatch, invalid_workbook)
|
||||||
@@ -13,7 +13,10 @@ from app.main import create_app
|
|||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary
|
from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary
|
||||||
|
from app.services.expense_cases import ExpenseCaseService
|
||||||
from app.services.linked_reimbursement_draft_jobs import (
|
from app.services.linked_reimbursement_draft_jobs import (
|
||||||
|
_build_direct_context_json,
|
||||||
|
_find_application_claim,
|
||||||
clear_linked_reimbursement_draft_jobs_for_tests,
|
clear_linked_reimbursement_draft_jobs_for_tests,
|
||||||
)
|
)
|
||||||
from app.services.orchestrator import OrchestratorService
|
from app.services.orchestrator import OrchestratorService
|
||||||
@@ -150,6 +153,122 @@ def test_linked_reimbursement_draft_job_runs_after_conversation_leaves(monkeypat
|
|||||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_job_overrides_forged_tenant_and_rejects_same_owner_cross_tenant(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||||
|
captured_contexts = []
|
||||||
|
|
||||||
|
def fake_run(self, payload):
|
||||||
|
captured_contexts.append(dict(payload.context_json or {}))
|
||||||
|
return OrchestratorResponse(
|
||||||
|
run_id="run-linked-tenant-guard",
|
||||||
|
conversation_id=None,
|
||||||
|
selected_agent="user_agent",
|
||||||
|
route_reason="验证租户边界。",
|
||||||
|
permission_level="draft_write",
|
||||||
|
status="succeeded",
|
||||||
|
result={
|
||||||
|
"message": "报销草稿已生成。",
|
||||||
|
"draft_payload": {
|
||||||
|
"claim_id": "tenant-guard-draft",
|
||||||
|
"claim_no": "RE-TENANT-GUARD",
|
||||||
|
"status": "draft",
|
||||||
|
"expense_type": "travel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requires_confirmation=False,
|
||||||
|
trace_summary=OrchestratorTraceSummary(
|
||||||
|
scenario="expense",
|
||||||
|
intent="draft",
|
||||||
|
tool_count=1,
|
||||||
|
failed_tool_count=0,
|
||||||
|
selected_capability_codes=[],
|
||||||
|
degraded=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(OrchestratorService, "run", fake_run)
|
||||||
|
try:
|
||||||
|
client, _session_factory = build_client(monkeypatch)
|
||||||
|
tenant_a_headers = {
|
||||||
|
"x-auth-username": "same-owner@example.com",
|
||||||
|
"x-auth-name": "Same Owner",
|
||||||
|
"x-auth-role-codes": "user",
|
||||||
|
"x-auth-tenant-id": "tenant-a",
|
||||||
|
}
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/reimbursements/linked-reimbursement-draft-jobs",
|
||||||
|
headers=tenant_a_headers,
|
||||||
|
json={
|
||||||
|
"message": "创建普通报销草稿",
|
||||||
|
"context_json": {
|
||||||
|
"tenant_id": "tenant-forged",
|
||||||
|
"tenantId": "tenant-forged-camel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
assert captured_contexts == [
|
||||||
|
{
|
||||||
|
"tenant_id": "tenant-a",
|
||||||
|
"entry_source": "workbench-ai",
|
||||||
|
"session_type": "expense",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
job_id = response.json()["job_id"]
|
||||||
|
cross_tenant_response = client.get(
|
||||||
|
f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}",
|
||||||
|
headers={**tenant_a_headers, "x-auth-tenant-id": "tenant-b"},
|
||||||
|
)
|
||||||
|
assert cross_tenant_response.status_code == 404
|
||||||
|
finally:
|
||||||
|
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_application_lookup_and_context_are_tenant_scoped() -> None:
|
||||||
|
session_factory = build_in_memory_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
seed_employee_and_application(db)
|
||||||
|
application = db.get(ExpenseClaim, "application-linked-draft-fast")
|
||||||
|
assert application is not None
|
||||||
|
ExpenseCaseService(db).ensure_case_for_claim(
|
||||||
|
application,
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
_find_application_claim(
|
||||||
|
db,
|
||||||
|
claim_no="AP-202606-FAST",
|
||||||
|
tenant_id="tenant-b",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
resolved = _find_application_claim(
|
||||||
|
db,
|
||||||
|
claim_no="AP-202606-FAST",
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
)
|
||||||
|
assert resolved is not None
|
||||||
|
context = _build_direct_context_json(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
"tenant_id": "tenant-forged",
|
||||||
|
"tenantId": "tenant-forged-camel",
|
||||||
|
"review_form_values": {
|
||||||
|
"application_claim_no": "AP-202606-FAST",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
)
|
||||||
|
assert context["tenant_id"] == "tenant-a"
|
||||||
|
assert "tenantId" not in context
|
||||||
|
assert context["review_form_values"]["application_claim_id"] == application.id
|
||||||
|
|
||||||
|
|
||||||
def test_linked_reimbursement_draft_job_uses_direct_save_path(monkeypatch) -> None:
|
def test_linked_reimbursement_draft_job_uses_direct_save_path(monkeypatch) -> None:
|
||||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.schemas.user_agent import UserAgentRequest
|
|||||||
from app.services.document_preview import DocumentPreviewAssets
|
from app.services.document_preview import DocumentPreviewAssets
|
||||||
from app.services.expense_cases import ExpenseCaseService
|
from app.services.expense_cases import ExpenseCaseService
|
||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.ocr import OcrService
|
from app.services.ocr import OcrService
|
||||||
from app.services.user_agent import UserAgentService
|
from app.services.user_agent import UserAgentService
|
||||||
|
|
||||||
@@ -124,6 +125,161 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
|
|||||||
return claim, item
|
return claim, item
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_pre_review_and_submit_share_structured_handshake() -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
claim, item = seed_claim(db)
|
||||||
|
claim.invoice_count = 1
|
||||||
|
item.invoice_id = "office-receipt.png"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"x-auth-username": "zhangsan@example.com",
|
||||||
|
"x-request-id": "pre-review-endpoint-ready-1",
|
||||||
|
}
|
||||||
|
review_response = client.post(
|
||||||
|
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert review_response.status_code == 200
|
||||||
|
review = review_response.json()["pre_review"]
|
||||||
|
assert review["decision"] in {"ready", "ready_with_review"}
|
||||||
|
assert review["review_id"]
|
||||||
|
assert review["input_fingerprint"].startswith("sha256:")
|
||||||
|
assert review["rule_set_fingerprint"].startswith("sha256:")
|
||||||
|
|
||||||
|
submit_response = client.post(
|
||||||
|
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||||
|
headers={"x-auth-username": "zhangsan@example.com"},
|
||||||
|
json={
|
||||||
|
"pre_review_id": review["review_id"],
|
||||||
|
"input_fingerprint": review["input_fingerprint"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert submit_response.status_code == 200
|
||||||
|
assert submit_response.json()["status"] == "submitted"
|
||||||
|
with session_factory() as db:
|
||||||
|
events = list(
|
||||||
|
db.scalars(
|
||||||
|
select(BusinessEvent)
|
||||||
|
.where(BusinessEvent.aggregate_id == "claim-attachment-1")
|
||||||
|
.order_by(BusinessEvent.occurred_at.asc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
assert [event.event_type for event in events] == [
|
||||||
|
"claim_pre_review_completed",
|
||||||
|
"claim_submitted",
|
||||||
|
]
|
||||||
|
assert events[0].correlation_id == "pre-review-endpoint-ready-1"
|
||||||
|
assert events[1].correlation_id == events[0].correlation_id
|
||||||
|
assert events[1].causation_id == events[0].id
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_submit_returns_structured_pre_review_conflict() -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
claim, item = seed_claim(db)
|
||||||
|
claim.invoice_count = 1
|
||||||
|
item.invoice_id = "wrong-receipt.png"
|
||||||
|
claim.risk_flags_json = [
|
||||||
|
{
|
||||||
|
"source": "manual_risk",
|
||||||
|
"severity": "high",
|
||||||
|
"actionability": "fixable_by_submitter",
|
||||||
|
"business_stage": "reimbursement",
|
||||||
|
"label": "票据与明细不一致",
|
||||||
|
"message": "请更正费用明细或重新上传正确票据。",
|
||||||
|
"item_ids": [item.id],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||||
|
headers={"x-auth-username": "zhangsan@example.com"},
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
detail = response.json()["detail"]
|
||||||
|
assert detail["code"] == "PRE_REVIEW_NEEDS_FIX"
|
||||||
|
assert detail["review"]["decision"] == "needs_fix"
|
||||||
|
assert detail["review"]["blocking_count"] == 1
|
||||||
|
assert detail["review"]["findings"][0]["remediation"]["action"] == "edit_item_note"
|
||||||
|
with session_factory() as db:
|
||||||
|
claim = db.get(ExpenseClaim, "claim-attachment-1")
|
||||||
|
assert claim is not None
|
||||||
|
assert claim.status == "draft"
|
||||||
|
assert claim.submitted_at is None
|
||||||
|
assert db.scalar(
|
||||||
|
select(BusinessEvent).where(
|
||||||
|
BusinessEvent.aggregate_id == claim.id,
|
||||||
|
BusinessEvent.event_type == "claim_submitted",
|
||||||
|
)
|
||||||
|
) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
claim, item = seed_claim(db)
|
||||||
|
claim.invoice_count = 1
|
||||||
|
item.invoice_id = "office-receipt.png"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
headers = {"x-auth-username": "zhangsan@example.com"}
|
||||||
|
review_response = client.post(
|
||||||
|
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert review_response.status_code == 200
|
||||||
|
review = review_response.json()["pre_review"]
|
||||||
|
assert review["decision"] in {"ready", "ready_with_review"}
|
||||||
|
|
||||||
|
def changed_review(_service, _claim):
|
||||||
|
return {
|
||||||
|
"risk_flags": [
|
||||||
|
{
|
||||||
|
"source": "submission_review",
|
||||||
|
"severity": "medium",
|
||||||
|
"actionability": "review_decision",
|
||||||
|
"business_stage": "reimbursement",
|
||||||
|
"label": "新增历史风险",
|
||||||
|
"message": "预审后发现新的历史风险记录,请确认后重试。",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rule_set_fingerprint": "rules-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ExpenseClaimService,
|
||||||
|
"_run_ai_submission_review",
|
||||||
|
changed_review,
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"pre_review_id": review["review_id"],
|
||||||
|
"input_fingerprint": review["input_fingerprint"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
detail = response.json()["detail"]
|
||||||
|
assert detail["code"] == "PRE_REVIEW_CHANGED"
|
||||||
|
assert detail["review"]["review_id"] != review["review_id"]
|
||||||
|
assert detail["review"]["decision"] == "ready_with_review"
|
||||||
|
with session_factory() as db:
|
||||||
|
claim = db.get(ExpenseClaim, "claim-attachment-1")
|
||||||
|
assert claim is not None
|
||||||
|
assert claim.status == "draft"
|
||||||
|
|
||||||
|
|
||||||
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
|
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
|
||||||
client, session_factory = build_client()
|
client, session_factory = build_client()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
@@ -843,7 +999,7 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path
|
|||||||
f"/api/v1/reimbursements/claims/{claim_id}",
|
f"/api/v1/reimbursements/claims/{claim_id}",
|
||||||
headers={
|
headers={
|
||||||
"x-auth-username": "zhangsan@example.com",
|
"x-auth-username": "zhangsan@example.com",
|
||||||
"x-auth-name": "张三",
|
"x-auth-name": "Zhang San",
|
||||||
"x-auth-employee-no": "E10001",
|
"x-auth-employee-no": "E10001",
|
||||||
"x-auth-role-codes": "user",
|
"x-auth-role-codes": "user",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -187,6 +187,12 @@ function sanitizeHeaders(headers) {
|
|||||||
function buildApiResponseError(response, payload, { auth, handleUnauthorized }) {
|
function buildApiResponseError(response, payload, { auth, handleUnauthorized }) {
|
||||||
const error = new Error(resolveErrorMessage(payload))
|
const error = new Error(resolveErrorMessage(payload))
|
||||||
error.status = response.status
|
error.status = response.status
|
||||||
|
error.payload = payload
|
||||||
|
error.detail = payload?.detail
|
||||||
|
if (payload?.detail && typeof payload.detail === 'object') {
|
||||||
|
error.code = String(payload.detail.code || '').trim()
|
||||||
|
error.review = payload.detail.review || null
|
||||||
|
}
|
||||||
if (response.status === 401 && auth && handleUnauthorized && readAuthAccessToken()) {
|
if (response.status === 401 && auth && handleUnauthorized && readAuthAccessToken()) {
|
||||||
error.code = 'AUTH_SESSION_EXPIRED'
|
error.code = 'AUTH_SESSION_EXPIRED'
|
||||||
notifyAuthSessionExpired()
|
notifyAuthSessionExpired()
|
||||||
|
|||||||
@@ -224,10 +224,26 @@ export function updateExpenseClaimItem(claimId, itemId, payload) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitExpenseClaim(claimId) {
|
function createPreReviewRequestId() {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||||
|
return `pre-review:${globalThis.crypto.randomUUID()}`
|
||||||
|
}
|
||||||
|
return `pre-review:${Date.now()}:${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preReviewExpenseClaim(claimId, requestId = '') {
|
||||||
|
const normalizedRequestId = String(requestId || '').trim() || createPreReviewRequestId()
|
||||||
|
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pre-review`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Request-ID': normalizedRequestId },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitExpenseClaim(claimId, payload = {}) {
|
||||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/submit`, {
|
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/submit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify(payload || {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ const EVENT_PRESENTATION = {
|
|||||||
application_returned: { label: '费用申请被退回', icon: 'mdi mdi-arrow-u-left-top', tone: 'warning' },
|
application_returned: { label: '费用申请被退回', icon: 'mdi mdi-arrow-u-left-top', tone: 'warning' },
|
||||||
claim_returned: { label: '报销单被退回', icon: 'mdi mdi-arrow-u-left-top', tone: 'warning' },
|
claim_returned: { label: '报销单被退回', icon: 'mdi mdi-arrow-u-left-top', tone: 'warning' },
|
||||||
reimbursement_draft_generated: { label: '报销草稿已自动生成', icon: 'mdi mdi-robot-outline', tone: 'success' },
|
reimbursement_draft_generated: { label: '报销草稿已自动生成', icon: 'mdi mdi-robot-outline', tone: 'success' },
|
||||||
|
receipt_received: { label: '票据已进入费用事件', icon: 'mdi mdi-receipt-text-plus-outline', tone: 'info' },
|
||||||
|
attachment_associated: { label: '票据已归集到报销草稿', icon: 'mdi mdi-file-link-outline', tone: 'success' },
|
||||||
|
application_pre_review_completed: { label: '费用申请 AI 预审已完成', icon: 'mdi mdi-shield-search-outline', tone: 'success' },
|
||||||
|
claim_pre_review_completed: { label: '报销单 AI 预审已完成', icon: 'mdi mdi-shield-search-outline', tone: 'success' },
|
||||||
payment_completed: { label: '付款已完成', icon: 'mdi mdi-cash-check', tone: 'success' },
|
payment_completed: { label: '付款已完成', icon: 'mdi mdi-cash-check', tone: 'success' },
|
||||||
application_archived: { label: '费用申请已归档', icon: 'mdi mdi-archive-check-outline', tone: 'success' }
|
application_archived: { label: '费用申请已归档', icon: 'mdi mdi-archive-check-outline', tone: 'success' }
|
||||||
}
|
}
|
||||||
@@ -125,6 +129,32 @@ function buildEventSummary(eventType, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventType === 'receipt_received') {
|
||||||
|
const fileName = compactText(readField(payload, 'file_name', 'fileName'))
|
||||||
|
return fileName
|
||||||
|
? `${fileName} 已进入费用事件并完成票据识别。`
|
||||||
|
: '票据已进入费用事件并完成识别。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'attachment_associated') {
|
||||||
|
const claimNo = compactText(readField(payload, 'claim_no', 'claimNo'))
|
||||||
|
return claimNo
|
||||||
|
? `票据已自动归集到报销草稿 ${claimNo}。`
|
||||||
|
: '票据已自动归集到当前报销草稿。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['application_pre_review_completed', 'claim_pre_review_completed'].includes(eventType)) {
|
||||||
|
const message = compactText(payload?.message)
|
||||||
|
if (message) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
const passed = readField(payload, 'passed', 'passed')
|
||||||
|
const blockingCount = Number(readField(payload, 'blocking_risk_count', 'blockingRiskCount') || 0)
|
||||||
|
return passed === false
|
||||||
|
? `AI 预审发现 ${blockingCount} 条重大风险,请处理后重新预审。`
|
||||||
|
: 'AI 预审已通过,可以继续提交审批。'
|
||||||
|
}
|
||||||
|
|
||||||
if (eventType === 'application_archived') {
|
if (eventType === 'application_archived') {
|
||||||
const reimbursementClaimNo = compactText(readField(payload, 'reimbursement_claim_no', 'reimbursementClaimNo'))
|
const reimbursementClaimNo = compactText(readField(payload, 'reimbursement_claim_no', 'reimbursementClaimNo'))
|
||||||
if (reimbursementClaimNo) {
|
if (reimbursementClaimNo) {
|
||||||
@@ -175,13 +205,17 @@ function normalizeEvent(event, index) {
|
|||||||
const occurredAt = readField(event, 'occurred_at', 'occurredAt')
|
const occurredAt = readField(event, 'occurred_at', 'occurredAt')
|
||||||
const timestamp = new Date(occurredAt || '').getTime()
|
const timestamp = new Date(occurredAt || '').getTime()
|
||||||
const payload = readField(event, 'payload_json', 'payloadJson') || {}
|
const payload = readField(event, 'payload_json', 'payloadJson') || {}
|
||||||
|
const isFailedPreReview = (
|
||||||
|
['application_pre_review_completed', 'claim_pre_review_completed'].includes(eventType)
|
||||||
|
&& readField(payload, 'passed', 'passed') === false
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: compactText(event?.id) || `${eventType || 'event'}-${index}`,
|
id: compactText(event?.id) || `${eventType || 'event'}-${index}`,
|
||||||
eventType,
|
eventType,
|
||||||
label: presentation.label,
|
label: presentation.label,
|
||||||
icon: presentation.icon,
|
icon: presentation.icon,
|
||||||
tone: presentation.tone,
|
tone: isFailedPreReview ? 'warning' : presentation.tone,
|
||||||
summary: buildEventSummary(eventType, payload),
|
summary: buildEventSummary(eventType, payload),
|
||||||
actorLabel: eventType === 'historical_claim_imported' ? '系统' : resolveActorLabel(event),
|
actorLabel: eventType === 'historical_claim_imported' ? '系统' : resolveActorLabel(event),
|
||||||
occurredAt: compactText(occurredAt),
|
occurredAt: compactText(occurredAt),
|
||||||
|
|||||||
13
web/src/views/scripts/travelRequestDetailPreReview.js
Normal file
13
web/src/views/scripts/travelRequestDetailPreReview.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function mapPreReviewFindingsToRiskFlags(preReview = {}) {
|
||||||
|
const findings = Array.isArray(preReview?.findings) ? preReview.findings : []
|
||||||
|
return findings.map((finding) => ({
|
||||||
|
...finding,
|
||||||
|
source: String(finding?.source || 'pre_review_finding').trim() || 'pre_review_finding',
|
||||||
|
label: finding?.message || '提交前预审风险',
|
||||||
|
business_stage: String(
|
||||||
|
finding?.business_stage || finding?.businessStage || 'reimbursement'
|
||||||
|
).trim() || 'reimbursement',
|
||||||
|
risk_domain: finding?.risk_domain || finding?.riskDomain || '',
|
||||||
|
visibility_scope: finding?.visibility_scope || finding?.visibilityScope || ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch } from 'vue'
|
|||||||
import {
|
import {
|
||||||
acceptExpenseClaimStandardAdjustment,
|
acceptExpenseClaimStandardAdjustment,
|
||||||
calculateTravelReimbursement,
|
calculateTravelReimbursement,
|
||||||
|
preReviewExpenseClaim,
|
||||||
submitExpenseClaim,
|
submitExpenseClaim,
|
||||||
updateExpenseClaim
|
updateExpenseClaim
|
||||||
} from '../../services/reimbursements.js'
|
} from '../../services/reimbursements.js'
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
normalizeRiskTone,
|
normalizeRiskTone,
|
||||||
resolveRiskTags
|
resolveRiskTags
|
||||||
} from './travelRequestDetailInsights.js'
|
} from './travelRequestDetailInsights.js'
|
||||||
|
import { mapPreReviewFindingsToRiskFlags } from './travelRequestDetailPreReview.js'
|
||||||
import {
|
import {
|
||||||
buildCurrentStandardAdjustmentMap,
|
buildCurrentStandardAdjustmentMap,
|
||||||
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
|
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
|
||||||
@@ -139,11 +141,16 @@ export function useTravelRequestDetailRiskSubmit({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyClaimRiskFlagsPayload(payload) {
|
function applyClaimRiskFlagsPayload(payload) {
|
||||||
const flags = Array.isArray(payload?.claim_risk_flags)
|
const flags = [
|
||||||
? payload.claim_risk_flags
|
payload?.claim_risk_flags,
|
||||||
: Array.isArray(payload?.claimRiskFlags)
|
payload?.claimRiskFlags,
|
||||||
? payload.claimRiskFlags
|
payload?.risk_flags_json,
|
||||||
|
payload?.riskFlags
|
||||||
|
].find(Array.isArray) || (
|
||||||
|
Array.isArray(payload?.pre_review?.findings)
|
||||||
|
? mapPreReviewFindingsToRiskFlags(payload.pre_review)
|
||||||
: null
|
: null
|
||||||
|
)
|
||||||
if (!flags) {
|
if (!flags) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -657,7 +664,24 @@ export function useTravelRequestDetailRiskSubmit({
|
|||||||
|
|
||||||
async function runSubmitRequest(claimId, documentNo, isApplication, taskSeq) {
|
async function runSubmitRequest(claimId, documentNo, isApplication, taskSeq) {
|
||||||
try {
|
try {
|
||||||
const payload = await submitExpenseClaim(claimId)
|
const reviewedClaim = await preReviewExpenseClaim(claimId)
|
||||||
|
if (taskSeq !== submitTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyClaimRiskFlagsPayload(reviewedClaim)
|
||||||
|
const preReview = reviewedClaim?.pre_review || reviewedClaim?.preReview || null
|
||||||
|
if (String(preReview?.decision || '').trim() === 'needs_fix') {
|
||||||
|
toast(preReview?.message || '自动检测发现需要先整改的风险,请处理后重新提交。')
|
||||||
|
emit('request-updated', { claimId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await submitExpenseClaim(claimId, {
|
||||||
|
pre_review_id: String(preReview?.review_id || preReview?.reviewId || '').trim() || null,
|
||||||
|
input_fingerprint: String(
|
||||||
|
preReview?.input_fingerprint || preReview?.inputFingerprint || ''
|
||||||
|
).trim() || null
|
||||||
|
})
|
||||||
if (taskSeq !== submitTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
|
if (taskSeq !== submitTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -678,6 +702,12 @@ export function useTravelRequestDetailRiskSubmit({
|
|||||||
emit('request-updated', { claimId })
|
emit('request-updated', { claimId })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (taskSeq === submitTaskSeq) {
|
if (taskSeq === submitTaskSeq) {
|
||||||
|
if (['PRE_REVIEW_NEEDS_FIX', 'PRE_REVIEW_CHANGED'].includes(error?.code)) {
|
||||||
|
applyClaimRiskFlagsPayload({ pre_review: error?.review })
|
||||||
|
toast(error?.review?.message || error?.message || '自动检测发现需要先整改的风险。')
|
||||||
|
emit('request-updated', { claimId })
|
||||||
|
return
|
||||||
|
}
|
||||||
toast(error?.message || '提交审批失败,请稍后重试。')
|
toast(error?.message || '提交审批失败,请稍后重试。')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -693,7 +723,6 @@ export function useTravelRequestDetailRiskSubmit({
|
|||||||
submitTaskSeq += 1
|
submitTaskSeq += 1
|
||||||
submitBusy.value = false
|
submitBusy.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeRiskSubmit() {
|
function disposeRiskSubmit() {
|
||||||
resetSubmitWorkState()
|
resetSubmitWorkState()
|
||||||
if (highlightedRiskCardTimer) {
|
if (highlightedRiskCardTimer) {
|
||||||
|
|||||||
@@ -243,6 +243,44 @@ async function testRejectsWithCustomTimeoutMessage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testPreservesStructuredPreReviewConflict() {
|
||||||
|
const review = {
|
||||||
|
review_id: 'review-conflict-001',
|
||||||
|
decision: 'needs_fix',
|
||||||
|
message: '住宿费超过标准,请先调整。',
|
||||||
|
findings: [{ risk_id: 'risk-001', severity: 'high' }]
|
||||||
|
}
|
||||||
|
global.fetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
detail: {
|
||||||
|
code: 'PRE_REVIEW_NEEDS_FIX',
|
||||||
|
message: '预审发现需整改风险。',
|
||||||
|
review
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => apiRequest('/reimbursements/claims/claim-001/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}'
|
||||||
|
}),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.status, 409)
|
||||||
|
assert.equal(error.code, 'PRE_REVIEW_NEEDS_FIX')
|
||||||
|
assert.equal(error.message, '预审发现需整改风险。')
|
||||||
|
assert.deepEqual(error.review, review)
|
||||||
|
assert.deepEqual(error.detail.review, review)
|
||||||
|
assert.deepEqual(error.payload.detail.review, review)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
await testUsesCustomContentTypeHeader()
|
await testUsesCustomContentTypeHeader()
|
||||||
await testSupportsBlobResponses()
|
await testSupportsBlobResponses()
|
||||||
@@ -251,6 +289,7 @@ async function run() {
|
|||||||
await testRejectsCustomAuthorizationOverride()
|
await testRejectsCustomAuthorizationOverride()
|
||||||
await testUnauthorizedResponsePublishesSessionExpiredEvent()
|
await testUnauthorizedResponsePublishesSessionExpiredEvent()
|
||||||
await testFormatsValidationErrors()
|
await testFormatsValidationErrors()
|
||||||
|
await testPreservesStructuredPreReviewConflict()
|
||||||
await testRejectsWithCustomTimeoutMessage()
|
await testRejectsWithCustomTimeoutMessage()
|
||||||
console.log('api-request tests passed')
|
console.log('api-request tests passed')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,48 @@ test('expense case timeline gives returned events an actionable reason', () => {
|
|||||||
assert.equal(timeline.events[0].summary, '住宿票据缺少入住人信息')
|
assert.equal(timeline.events[0].summary, '住宿票据缺少入住人信息')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('expense case timeline explains receipt collection, association and AI pre-review', () => {
|
||||||
|
const timeline = buildExpenseCaseTimelineViewModel({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: 'receipt-1',
|
||||||
|
event_type: 'receipt_received',
|
||||||
|
actor_id: 'employee@example.com',
|
||||||
|
occurred_at: '2026-07-13T01:00:00Z',
|
||||||
|
payload_json: { file_name: '上海出差高铁票.pdf' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'association-1',
|
||||||
|
event_type: 'attachment_associated',
|
||||||
|
actor_id: 'employee@example.com',
|
||||||
|
occurred_at: '2026-07-13T01:01:00Z',
|
||||||
|
payload_json: { claim_no: 'BX-20260713-001' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pre-review-1',
|
||||||
|
event_type: 'claim_pre_review_completed',
|
||||||
|
actor_id: 'employee@example.com',
|
||||||
|
occurred_at: '2026-07-13T01:02:00Z',
|
||||||
|
payload_json: {
|
||||||
|
passed: false,
|
||||||
|
blocking_risk_count: 1,
|
||||||
|
message: '自动检测发现 1 条重大风险,请逐条填写原因后再提交审批。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(timeline.events.map((event) => event.label), [
|
||||||
|
'票据已进入费用事件',
|
||||||
|
'票据已归集到报销草稿',
|
||||||
|
'报销单 AI 预审已完成'
|
||||||
|
])
|
||||||
|
assert.match(timeline.events[0].summary, /上海出差高铁票\.pdf/)
|
||||||
|
assert.match(timeline.events[1].summary, /BX-20260713-001/)
|
||||||
|
assert.equal(timeline.events[2].tone, 'warning')
|
||||||
|
assert.match(timeline.events[2].summary, /1 条重大风险/)
|
||||||
|
})
|
||||||
|
|
||||||
test('expense case timeline explains a historical import without pretending it is a business action', () => {
|
test('expense case timeline explains a historical import without pretending it is a business action', () => {
|
||||||
const timeline = buildExpenseCaseTimelineViewModel({
|
const timeline = buildExpenseCaseTimelineViewModel({
|
||||||
events: [
|
events: [
|
||||||
|
|||||||
51
web/tests/reimbursement-pre-review-service.test.mjs
Normal file
51
web/tests/reimbursement-pre-review-service.test.mjs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
preReviewExpenseClaim,
|
||||||
|
submitExpenseClaim
|
||||||
|
} from '../src/services/reimbursements.js'
|
||||||
|
|
||||||
|
test('reimbursement service sends pre-review request identity and submit handshake payload', async () => {
|
||||||
|
const requests = []
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
globalThis.fetch = async (url, options = {}) => {
|
||||||
|
requests.push({ url: String(url), options })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
async json() {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await preReviewExpenseClaim('claim / 001', 'pre-review:test-request-001')
|
||||||
|
await submitExpenseClaim('claim / 001', {
|
||||||
|
pre_review_id: 'review-ready-001',
|
||||||
|
input_fingerprint: 'sha256:ready-input'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(requests.length, 2)
|
||||||
|
assert.equal(
|
||||||
|
requests[0].url,
|
||||||
|
'/api/v1/reimbursements/claims/claim%20%2F%20001/pre-review'
|
||||||
|
)
|
||||||
|
assert.equal(requests[0].options.method, 'POST')
|
||||||
|
assert.equal(requests[0].options.headers['X-Request-ID'], 'pre-review:test-request-001')
|
||||||
|
assert.equal(requests[0].options.body, '{}')
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
requests[1].url,
|
||||||
|
'/api/v1/reimbursements/claims/claim%20%2F%20001/submit'
|
||||||
|
)
|
||||||
|
assert.equal(requests[1].options.method, 'POST')
|
||||||
|
assert.deepEqual(JSON.parse(requests[1].options.body), {
|
||||||
|
pre_review_id: 'review-ready-001',
|
||||||
|
input_fingerprint: 'sha256:ready-input'
|
||||||
|
})
|
||||||
|
})
|
||||||
33
web/tests/travel-request-detail-pre-review.test.mjs
Normal file
33
web/tests/travel-request-detail-pre-review.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { buildAttachmentRiskCards } from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||||
|
import { mapPreReviewFindingsToRiskFlags } from '../src/views/scripts/travelRequestDetailPreReview.js'
|
||||||
|
|
||||||
|
|
||||||
|
test('structured pre-review findings remain visible as actionable risk cards', () => {
|
||||||
|
const flags = mapPreReviewFindingsToRiskFlags({
|
||||||
|
findings: [
|
||||||
|
{
|
||||||
|
risk_id: 'risk-visible-001',
|
||||||
|
source: 'platform_risk',
|
||||||
|
severity: 'high',
|
||||||
|
actionability: 'fixable_by_submitter',
|
||||||
|
business_stage: 'reimbursement',
|
||||||
|
message: '发票抬头不匹配,请重新上传。',
|
||||||
|
item_ids: ['item-001']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(flags[0].source, 'platform_risk')
|
||||||
|
assert.notEqual(flags[0].source, 'ai_pre_review')
|
||||||
|
const cards = buildAttachmentRiskCards({
|
||||||
|
expenseItems: [{ id: 'item-001', itemNote: '' }],
|
||||||
|
claimRiskFlags: flags,
|
||||||
|
businessStage: 'reimbursement'
|
||||||
|
})
|
||||||
|
assert.equal(cards.length, 1)
|
||||||
|
assert.equal(cards[0].risk, '发票抬头不匹配,请重新上传。')
|
||||||
|
assert.equal(cards[0].actionability, 'fixable_by_submitter')
|
||||||
|
})
|
||||||
@@ -59,9 +59,9 @@ const deleteDialogComponent = readFileSync(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function extractFunction(source, name) {
|
function extractFunction(source, name) {
|
||||||
let signatureIndex = source.indexOf(`function ${name}(`)
|
let signatureIndex = source.indexOf(`async function ${name}(`)
|
||||||
if (signatureIndex === -1) {
|
if (signatureIndex === -1) {
|
||||||
signatureIndex = source.indexOf(`async function ${name}(`)
|
signatureIndex = source.indexOf(`function ${name}(`)
|
||||||
}
|
}
|
||||||
assert.notEqual(signatureIndex, -1, `${name} should exist`)
|
assert.notEqual(signatureIndex, -1, `${name} should exist`)
|
||||||
|
|
||||||
@@ -84,6 +84,46 @@ function extractFunction(source, name) {
|
|||||||
assert.fail(`${name} body should be closed`)
|
assert.fail(`${name} body should be closed`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSubmitRunner({
|
||||||
|
claimId = 'claim-001',
|
||||||
|
preReviewExpenseClaim,
|
||||||
|
submitExpenseClaim,
|
||||||
|
applyClaimRiskFlagsPayload = () => {},
|
||||||
|
toast = () => {},
|
||||||
|
emit = () => {}
|
||||||
|
}) {
|
||||||
|
const runSubmitRequest = extractFunction(detailViewScript, 'runSubmitRequest')
|
||||||
|
const factory = new Function(
|
||||||
|
'preReviewExpenseClaim',
|
||||||
|
'submitExpenseClaim',
|
||||||
|
'applyClaimRiskFlagsPayload',
|
||||||
|
'toast',
|
||||||
|
'emit',
|
||||||
|
'request',
|
||||||
|
'submitConfirmDialogOpen',
|
||||||
|
'submitBusy',
|
||||||
|
'submitTaskSeq',
|
||||||
|
`${runSubmitRequest}; return runSubmitRequest`
|
||||||
|
)
|
||||||
|
const submitConfirmDialogOpen = { value: true }
|
||||||
|
const submitBusy = { value: true }
|
||||||
|
return {
|
||||||
|
run: factory(
|
||||||
|
preReviewExpenseClaim,
|
||||||
|
submitExpenseClaim,
|
||||||
|
applyClaimRiskFlagsPayload,
|
||||||
|
toast,
|
||||||
|
emit,
|
||||||
|
{ value: { claimId } },
|
||||||
|
submitConfirmDialogOpen,
|
||||||
|
submitBusy,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
submitConfirmDialogOpen,
|
||||||
|
submitBusy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('detail submit opens a confirmation dialog before calling submit API', () => {
|
test('detail submit opens a confirmation dialog before calling submit API', () => {
|
||||||
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
|
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
|
||||||
assert.match(detailViewTemplate, /:secondary-text="submitConfirmSecondaryText"/)
|
assert.match(detailViewTemplate, /:secondary-text="submitConfirmSecondaryText"/)
|
||||||
@@ -94,7 +134,7 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
|||||||
|
|
||||||
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
|
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
|
||||||
assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/)
|
assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/)
|
||||||
assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
|
assert.match(detailViewScript, /preReviewExpenseClaim/)
|
||||||
assert.match(detailViewScript, /const submitActionLabel = computed/)
|
assert.match(detailViewScript, /const submitActionLabel = computed/)
|
||||||
assert.match(detailViewScript, /const submitConfirmSecondaryText = computed/)
|
assert.match(detailViewScript, /const submitConfirmSecondaryText = computed/)
|
||||||
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
|
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
|
||||||
@@ -111,10 +151,120 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
|||||||
assert.doesNotMatch(confirmSubmitRequest, /await /)
|
assert.doesNotMatch(confirmSubmitRequest, /await /)
|
||||||
assert.doesNotMatch(confirmSubmitRequest, /submitExpenseClaim/)
|
assert.doesNotMatch(confirmSubmitRequest, /submitExpenseClaim/)
|
||||||
const runSubmitRequest = extractFunction(detailViewScript, 'runSubmitRequest')
|
const runSubmitRequest = extractFunction(detailViewScript, 'runSubmitRequest')
|
||||||
assert.match(runSubmitRequest, /submitExpenseClaim\(claimId\)/)
|
const preReviewIndex = runSubmitRequest.indexOf('preReviewExpenseClaim(claimId)')
|
||||||
|
const submitIndex = runSubmitRequest.indexOf('submitExpenseClaim(claimId,')
|
||||||
|
assert.ok(preReviewIndex > -1)
|
||||||
|
assert.ok(submitIndex > preReviewIndex)
|
||||||
|
assert.match(runSubmitRequest, /pre_review_id:[\s\S]*preReview\?\.review_id/)
|
||||||
|
assert.match(runSubmitRequest, /input_fingerprint:[\s\S]*preReview\?\.input_fingerprint/)
|
||||||
assert.match(runSubmitRequest, /taskSeq !== submitTaskSeq/)
|
assert.match(runSubmitRequest, /taskSeq !== submitTaskSeq/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('detail submit uses the ready pre-review identity and fingerprint', async () => {
|
||||||
|
const calls = []
|
||||||
|
const appliedPayloads = []
|
||||||
|
const { run, submitBusy } = createSubmitRunner({
|
||||||
|
preReviewExpenseClaim: async (claimId) => {
|
||||||
|
calls.push(['pre-review', claimId])
|
||||||
|
return {
|
||||||
|
claim_risk_flags: [],
|
||||||
|
pre_review: {
|
||||||
|
decision: 'ready',
|
||||||
|
review_id: 'review-ready-001',
|
||||||
|
input_fingerprint: 'sha256:ready-input'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submitExpenseClaim: async (claimId, payload) => {
|
||||||
|
calls.push(['submit', claimId, payload])
|
||||||
|
return { status: 'submitted', approval_stage: 'manager' }
|
||||||
|
},
|
||||||
|
applyClaimRiskFlagsPayload: (payload) => appliedPayloads.push(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
await run('claim-001', 'BX-001', false, 1)
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['pre-review', 'claim-001'],
|
||||||
|
[
|
||||||
|
'submit',
|
||||||
|
'claim-001',
|
||||||
|
{
|
||||||
|
pre_review_id: 'review-ready-001',
|
||||||
|
input_fingerprint: 'sha256:ready-input'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
])
|
||||||
|
assert.equal(appliedPayloads.length, 1)
|
||||||
|
assert.equal(submitBusy.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detail submit stops after needs_fix and refreshes visible risks', async () => {
|
||||||
|
let submitCalls = 0
|
||||||
|
const appliedPayloads = []
|
||||||
|
const emitted = []
|
||||||
|
const toasts = []
|
||||||
|
const reviewedClaim = {
|
||||||
|
claim_risk_flags: [{ source: 'policy', severity: 'high' }],
|
||||||
|
pre_review: {
|
||||||
|
decision: 'needs_fix',
|
||||||
|
message: '住宿费超标,请先按标准调整。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { run, submitBusy } = createSubmitRunner({
|
||||||
|
preReviewExpenseClaim: async () => reviewedClaim,
|
||||||
|
submitExpenseClaim: async () => {
|
||||||
|
submitCalls += 1
|
||||||
|
return { status: 'submitted' }
|
||||||
|
},
|
||||||
|
applyClaimRiskFlagsPayload: (payload) => appliedPayloads.push(payload),
|
||||||
|
toast: (message) => toasts.push(message),
|
||||||
|
emit: (...args) => emitted.push(args)
|
||||||
|
})
|
||||||
|
|
||||||
|
await run('claim-001', 'BX-001', false, 1)
|
||||||
|
|
||||||
|
assert.equal(submitCalls, 0)
|
||||||
|
assert.deepEqual(appliedPayloads, [reviewedClaim])
|
||||||
|
assert.deepEqual(emitted, [['request-updated', { claimId: 'claim-001' }]])
|
||||||
|
assert.deepEqual(toasts, ['住宿费超标,请先按标准调整。'])
|
||||||
|
assert.equal(submitBusy.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('structured pre-review 409 requests detail refresh so backend risks are refilled', async () => {
|
||||||
|
const emitted = []
|
||||||
|
const toasts = []
|
||||||
|
const structuredError = Object.assign(new Error('提交前预审已失效。'), {
|
||||||
|
code: 'PRE_REVIEW_NEEDS_FIX',
|
||||||
|
review: {
|
||||||
|
decision: 'needs_fix',
|
||||||
|
message: '发票抬头不匹配,请重新上传。',
|
||||||
|
findings: [{ risk_id: 'risk-001', severity: 'high' }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const { run, submitBusy } = createSubmitRunner({
|
||||||
|
preReviewExpenseClaim: async () => ({
|
||||||
|
claim_risk_flags: [],
|
||||||
|
pre_review: {
|
||||||
|
decision: 'ready',
|
||||||
|
review_id: 'review-stale',
|
||||||
|
input_fingerprint: 'sha256:stale'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
submitExpenseClaim: async () => {
|
||||||
|
throw structuredError
|
||||||
|
},
|
||||||
|
toast: (message) => toasts.push(message),
|
||||||
|
emit: (...args) => emitted.push(args)
|
||||||
|
})
|
||||||
|
|
||||||
|
await run('claim-001', 'BX-001', false, 1)
|
||||||
|
|
||||||
|
assert.deepEqual(toasts, ['发票抬头不匹配,请重新上传。'])
|
||||||
|
assert.deepEqual(emitted, [['request-updated', { claimId: 'claim-001' }]])
|
||||||
|
assert.equal(submitBusy.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
test('detail submit warns on missing risk explanation and supports standard adjustment', () => {
|
test('detail submit warns on missing risk explanation and supports standard adjustment', () => {
|
||||||
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
|
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
|
||||||
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/)
|
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/)
|
||||||
|
|||||||
Reference in New Issue
Block a user