From 6bdf65bc24187c09846fee42fe8a45eed4a00927 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 16 Jul 2026 11:42:08 +0800 Subject: [PATCH] feat(expenses): add authoritative pre-review workflow --- .../CONCEPT.md | 12 + .../TODO.md | 11 + ...e-pre-review-tenant-routing-regressions.md | 15 + .../v1/endpoints/reimbursement_pre_review.py | 116 ++++ .../app/api/v1/endpoints/reimbursements.py | 66 +- server/src/app/schemas/expense_case.py | 9 + server/src/app/schemas/reimbursement.py | 46 +- server/src/app/services/expense_cases.py | 8 + .../services/expense_claim_access_policy.py | 7 +- .../expense_claim_application_link.py | 543 ++++++++++++++++ .../services/expense_claim_approval_flow.py | 1 + .../expense_claim_approval_routing.py | 63 +- .../expense_claim_attachment_operations.py | 4 +- .../expense_claim_document_item_builder.py | 7 + .../app/services/expense_claim_draft_flow.py | 578 ++---------------- .../src/app/services/expense_claim_errors.py | 18 + .../services/expense_claim_platform_risk.py | 38 +- .../app/services/expense_claim_pre_review.py | 199 +++--- .../expense_claim_pre_review_decision.py | 394 ++++++++++++ .../services/expense_claim_review_preview.py | 12 + .../app/services/expense_claim_risk_review.py | 14 +- .../expense_claim_rule_fingerprint.py | 27 + .../expense_claim_standard_adjustment.py | 297 +++++++++ .../services/expense_claim_tenant_scope.py | 73 +++ server/src/app/services/expense_claims.py | 343 ++--------- .../services/expense_receipt_association.py | 7 + .../src/app/services/expense_rule_runtime.py | 30 +- .../linked_reimbursement_draft_jobs.py | 111 +++- server/src/app/services/ontology_detection.py | 7 + .../app/services/steward_action_executor.py | 1 + .../services/user_agent_review_messages.py | 12 + .../tests/test_attachment_association_jobs.py | 14 + server/tests/test_expense_case_endpoints.py | 2 +- server/tests/test_expense_case_service.py | 323 +++++++++- .../test_expense_claim_approval_routing.py | 223 ++++++- .../test_expense_claim_pre_review_decision.py | 196 ++++++ server/tests/test_expense_claim_service.py | 141 +++-- ...est_expense_claim_tenant_and_case_stage.py | 318 ++++++++++ .../tests/test_expense_claim_tenant_scope.py | 289 +++++++++ .../test_expense_rule_runtime_resilience.py | 78 +++ .../test_linked_reimbursement_draft_jobs.py | 119 ++++ server/tests/test_reimbursement_endpoints.py | 158 ++++- web/src/services/api.js | 6 + web/src/services/reimbursements.js | 20 +- web/src/utils/expenseCaseTimeline.js | 36 +- .../scripts/travelRequestDetailPreReview.js | 13 + .../useTravelRequestDetailRiskSubmit.js | 41 +- web/tests/api-request.test.mjs | 39 ++ web/tests/expense-case-timeline.test.mjs | 42 ++ .../reimbursement-pre-review-service.test.mjs | 51 ++ .../travel-request-detail-pre-review.test.mjs | 33 + ...vel-request-detail-submit-confirm.test.mjs | 158 ++++- 52 files changed, 4300 insertions(+), 1069 deletions(-) create mode 100644 document/development/2026-07-16/dev-logs/bugs/expense-pre-review-tenant-routing-regressions.md create mode 100644 server/src/app/api/v1/endpoints/reimbursement_pre_review.py create mode 100644 server/src/app/services/expense_claim_application_link.py create mode 100644 server/src/app/services/expense_claim_pre_review_decision.py create mode 100644 server/src/app/services/expense_claim_rule_fingerprint.py create mode 100644 server/src/app/services/expense_claim_standard_adjustment.py create mode 100644 server/src/app/services/expense_claim_tenant_scope.py create mode 100644 server/tests/test_expense_claim_pre_review_decision.py create mode 100644 server/tests/test_expense_claim_tenant_and_case_stage.py create mode 100644 server/tests/test_expense_claim_tenant_scope.py create mode 100644 server/tests/test_expense_rule_runtime_resilience.py create mode 100644 web/src/views/scripts/travelRequestDetailPreReview.js create mode 100644 web/tests/reimbursement-pre-review-service.test.mjs create mode 100644 web/tests/travel-request-detail-pre-review.test.mjs diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md index ec3166b..8de36f5 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md @@ -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。 - 新策略先进入 shadow,再 Canary,最后按动作开放自动化。 @@ -250,6 +254,7 @@ - `expense_case_created` - `historical_claim_imported`:迁移前旧单的当前快照;只证明已纳入统一费用事件,不重建或伪造迁移前审批历史。 - `application_generated` / `application_submitted` / `application_approved` +- `application_pre_review_completed` / `claim_pre_review_completed` - `receipt_received` / `receipt_verified` / `ocr_corrected` - `field_suggested` / `field_accepted` / `field_edited` / `field_rejected` - `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(规划阶段):只沉淀功能规划,没有修改业务代码、数据库结构或现有接口。 - 2026-07-13(P0 基础切片):新增 `ExpenseCase`、`ExpenseCaseLink`、`BusinessEvent` 模型与 `ExpenseCaseService`,提供 `/api/v1/expense-cases/by-claim/{claim_id}` 时间线查询接口。 diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md index 2d69a60..6b4e4d3 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md @@ -73,9 +73,16 @@ - [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。 - [ ] [CONCEPT: 数据与契约] 为申请、票据、草稿、提交、退回、审批、付款和归档接入统一 correlation ID。 + 当前进度:报销显式预审与提交已共享 correlation/causation,AI 草稿和票据归集已透传租户与稳定幂等键;申请、审批、付款和外部连接器仍需继续统一。 - [ ] [CONCEPT: 业务事件与 AI 决策] 建立事务 Outbox:申请、提交、退回、审批、支付和入账状态与事件同事务提交,消费端按事件 ID 幂等处理。 - [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 失败整体回滚。 +- [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 检查对外层事务的隐式提交。 证据:`user_agent_application.py`、`budget.py`、`test_reimbursement_endpoints.py`、`test_user_agent_service.py`;容器测试覆盖有效预算预占、`application_submitted` 事件、提交风险标记、事件失败整体回滚及保存草稿无提交副作用。 - [x] [CONCEPT: 业务事件与 AI 决策] 为 AI 工作台和小财管家申请草稿补齐新建/更新事件、租户透传、事务回滚和动作幂等。 @@ -118,6 +125,8 @@ 证据:附件任务 composable 将待确认视为安全终态,不发送伪成功通知;自动完成提供“查看草稿”,申请候选提供“查看候选申请”,不伪造报销草稿。 - [x] [CONCEPT: 零录入附件关联任务契约] 会话刷新后恢复新任务结果、候选和异常状态,旧任务结果仍可正常展示。 证据:`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: 移动端] 接通真实登录恢复、路由守卫、报销列表、详情、草稿、上传和审批 API。 @@ -194,6 +203,7 @@ - [x] [CONCEPT: 测试方案] 为 AI 新建申请直接提交补充统一事务回归,覆盖提交成功、事件失败回滚和仅保存草稿三条边界。 证据:容器内直接提交定向测试 3 项、申请提交回归 5 项、预算与 Expense Case 回归 13 项通过;相关 Python 文件 `ruff --select F,I` 通过。 - [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。 + 当前进度:已补充预审决策确定性、嵌套无序集合、动态 findings 变化、申请/报销统一阻断、P8 全链路、真实预算前阻断、事件幂等/回滚、同用户名租户隔离、后台任务 tenant 伪造和 Case `approved_to_spend → claiming` 阶段测试;学习、自动化和节省仍待后续阶段。 - [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。 证据:`test_auth_session_endpoints.py`、`test_auth_service.py`、`test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。 - [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。 @@ -204,6 +214,7 @@ 证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;tmpfs PostgreSQL 17 中 4 项通过,覆盖空库升级、重复升级、关键约束/索引、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。 - [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 + 当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通;付款回执、ERP 入账和对账仍未接入。 - [x] [CONCEPT: 测试方案] 跑通首个个人出行方式切片的 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。 证据:容器内记忆、预览决策、迁移与所有权组合回归 56 项通过、1 项条件跳过;一次性 PostgreSQL 迁移循环 1 项通过;前端申请快速预览、个人记忆、Steward 与会话恢复组合 83 项通过,Vite 生产构建通过,Python Ruff F/I 与 `git diff --check` 通过。 - [x] [CONCEPT: 测试方案] 跑通首个行为采集切片:AI 申请预填 → 用户接受/显式修改 → 草稿或提交结果同事务落账。 diff --git a/document/development/2026-07-16/dev-logs/bugs/expense-pre-review-tenant-routing-regressions.md b/document/development/2026-07-16/dev-logs/bugs/expense-pre-review-tenant-routing-regressions.md new file mode 100644 index 0000000..fc47eb9 --- /dev/null +++ b/document/development/2026-07-16/dev-logs/bugs/expense-pre-review-tenant-routing-regressions.md @@ -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` 通过。 diff --git a/server/src/app/api/v1/endpoints/reimbursement_pre_review.py b/server/src/app/api/v1/endpoints/reimbursement_pre_review.py new file mode 100644 index 0000000..9747b82 --- /dev/null +++ b/server/src/app/api/v1/endpoints/reimbursement_pre_review.py @@ -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", + ) diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index eb5800d..9b998be 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -21,6 +21,7 @@ from app.schemas.reimbursement import ( ExpenseClaimRead, ExpenseClaimReturnPayload, ExpenseClaimStandardAdjustmentPayload, + ExpenseClaimSubmitPayload, ExpenseClaimUpdate, ReimbursementCreate, ReimbursementRead, @@ -28,11 +29,17 @@ from app.schemas.reimbursement import ( TravelReimbursementCalculatorResponse, ) 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.reimbursement import ReimbursementService 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() DbSession = Annotated[Session, Depends(get_db)] 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: - service = ExpenseClaimService(db) - try: - claim = service.pre_review_claim(claim_id, current_user) - except ValueError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error - - if claim is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") - return claim +def pre_review_expense_claim( + claim_id: str, + db: DbSession, + current_user: CurrentUser, + request_id: RequestIdHeader = None, +) -> ExpenseClaimRead: + return pre_review_expense_claim_or_http_error( + ExpenseClaimService(db), + claim_id, + current_user, + request_id, + ) @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: - service = ExpenseClaimService(db) - try: - claim = service.submit_claim(claim_id, current_user) - except ValueError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error - - if claim is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") - return claim +def submit_expense_claim( + claim_id: str, + db: DbSession, + current_user: CurrentUser, + payload: ExpenseClaimSubmitPayload | None = None, +) -> ExpenseClaimRead: + return submit_expense_claim_or_http_error( + ExpenseClaimService(db), + claim_id, + current_user, + payload, + ) @router.post( @@ -764,18 +775,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser if claim is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") - 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", - ) + return expense_claim_deletion_response(claim) @router.get( diff --git a/server/src/app/schemas/expense_case.py b/server/src/app/schemas/expense_case.py index 9e335f3..14b838b 100644 --- a/server/src/app/schemas/expense_case.py +++ b/server/src/app/schemas/expense_case.py @@ -29,8 +29,17 @@ class BusinessEventPayloadRead(BaseModel): next_approval_stage: str = "" reason: str = "" opinion: str = "" + message: str = "" application_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) diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index 4a8ecff..d925f94 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Any, Literal 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) +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): model_config = ConfigDict(from_attributes=True) @@ -172,6 +215,7 @@ class ExpenseClaimRead(BaseModel): created_at: datetime updated_at: datetime items: list[ExpenseClaimItemRead] = Field(default_factory=list) + pre_review: ExpenseClaimPreReviewRead | None = None @field_validator("risk_flags_json", mode="before") @classmethod diff --git a/server/src/app/services/expense_cases.py b/server/src/app/services/expense_cases.py index 83052ce..147f90a 100644 --- a/server/src/app/services/expense_cases.py +++ b/server/src/app/services/expense_cases.py @@ -242,6 +242,14 @@ class ExpenseCaseService: self.db.flush() 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( self, claim: ExpenseClaim, diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 5590666..86b9c34 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -12,6 +12,7 @@ from app.models.financial_record import ExpenseClaim from app.models.organization import OrganizationUnit from app.models.role import Role 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 ( APPLICATION_ARCHIVE_STAGE, ARCHIVE_ACCOUNTING_STAGE, @@ -22,7 +23,6 @@ from app.services.expense_claim_workflow_constants import ( PAYMENT_PENDING_STATUS, ) - PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"} 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: self.db = db @@ -697,6 +697,7 @@ class ExpenseClaimAccessPolicy: return [and_(*pending_budget_approval_parts)] 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) if current_user.is_admin: return stmt.where(ExpenseClaim.status == "submitted") @@ -721,6 +722,7 @@ class ExpenseClaimAccessPolicy: *, include_approval_scope: bool = False, ) -> Any: + stmt = self.apply_tenant_scope(stmt, current_user) if current_user.is_admin: if include_approval_scope: return stmt @@ -768,6 +770,7 @@ class ExpenseClaimAccessPolicy: return stmt.where(or_(*conditions)) 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() if not self.has_archive_center_access(current_user): owned_conditions = self.build_personal_claim_conditions(current_user) diff --git a/server/src/app/services/expense_claim_application_link.py b/server/src/app/services/expense_claim_application_link.py new file mode 100644 index 0000000..6e11886 --- /dev/null +++ b/server/src/app/services/expense_claim_application_link.py @@ -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 {} diff --git a/server/src/app/services/expense_claim_approval_flow.py b/server/src/app/services/expense_claim_approval_flow.py index 54bfc87..2c17410 100644 --- a/server/src/app/services/expense_claim_approval_flow.py +++ b/server/src/app/services/expense_claim_approval_flow.py @@ -296,6 +296,7 @@ class ExpenseClaimApprovalFlowMixin: }, expense_case=expense_case, relation_type="generated_reimbursement", + update_case_state=False, ) self.db.commit() diff --git a/server/src/app/services/expense_claim_approval_routing.py b/server/src/app/services/expense_claim_approval_routing.py index 3fbfa11..cd0e0c8 100644 --- a/server/src/app/services/expense_claim_approval_routing.py +++ b/server/src/app/services/expense_claim_approval_routing.py @@ -14,6 +14,7 @@ from app.services.expense_claim_risk_stage import ( risk_flag_business_stage, with_risk_business_stage, ) +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin class ExpenseClaimApprovalRoutingMixin: @@ -73,6 +74,14 @@ class ExpenseClaimApprovalRoutingMixin: claim.risk_flags_json, 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_reasons = ( [f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"] @@ -80,7 +89,7 @@ class ExpenseClaimApprovalRoutingMixin: else [] ) reasons = self._dedupe_reasons( - budget_reasons + [*budget_reasons, *application_risk_reasons] if is_application_claim else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons] ) @@ -95,9 +104,11 @@ class ExpenseClaimApprovalRoutingMixin: label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核" if is_application_claim: message = ( - "系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。" + "系统根据预算占用与高风险复核结果判断," + "该申请单需要预算管理者二次确认。" if requires_budget_review - else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。" + else "系统根据预算占用与风险复核结果判断," + "该申请单可跳过预算管理者复核。" ) else: message = ( @@ -184,6 +195,37 @@ class ExpenseClaimApprovalRoutingMixin: reasons.append(f"{label}:{message}" if message else label) 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: filters = [] if claim.employee_id: @@ -199,6 +241,14 @@ class ExpenseClaimApprovalRoutingMixin: .where(or_(*filters)) .where(ExpenseClaim.id != claim.id) .where(ExpenseClaim.occurred_at >= since) + .where( + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition( + ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id( + self.db, + claim.id, + ) + ) + ) ) return sum( 1 @@ -211,6 +261,13 @@ class ExpenseClaimApprovalRoutingMixin: ) 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() if source in self._ROUTE_IGNORED_SOURCES: return False diff --git a/server/src/app/services/expense_claim_attachment_operations.py b/server/src/app/services/expense_claim_attachment_operations.py index 3c57a24..297ac1e 100644 --- a/server/src/app/services/expense_claim_attachment_operations.py +++ b/server/src/app/services/expense_claim_attachment_operations.py @@ -322,7 +322,9 @@ class ExpenseClaimAttachmentOperationsMixin: source_score = cls._attachment_ocr_signal_score(source_receipt_document) upload_score = cls._attachment_ocr_signal_score(upload_ocr_document) if source_score <= 0: - return upload_ocr_document if upload_score > 0 else None + # OCR 已返回文档但没有任何有效信号时,也要进入高风险校验, + # 不能回落成“待识别”的中风险状态放过普通图片或空白附件。 + return upload_ocr_document if upload_score <= 0: return source_receipt_document diff --git a/server/src/app/services/expense_claim_document_item_builder.py b/server/src/app/services/expense_claim_document_item_builder.py index 076eec8..5617203 100644 --- a/server/src/app/services/expense_claim_document_item_builder.py +++ b/server/src/app/services/expense_claim_document_item_builder.py @@ -87,6 +87,9 @@ from app.services.expense_claim_constants import ( TRAVEL_POLICY_TRAIN_CLASS_PATTERNS, 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_amounts import ( extract_amount_candidates, @@ -470,6 +473,10 @@ class ExpenseClaimDocumentItemBuilderMixin: document_info["fields"] = document_info.get("document_fields") 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: document_type = str(document.get("document_type") or "").strip() mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type) diff --git a/server/src/app/services/expense_claim_draft_flow.py b/server/src/app/services/expense_claim_draft_flow.py index 985ce08..4bc3362 100644 --- a/server/src/app/services/expense_claim_draft_flow.py +++ b/server/src/app/services/expense_claim_draft_flow.py @@ -1,546 +1,30 @@ from __future__ import annotations -import json -import re -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 datetime import UTC, date, datetime +from decimal import Decimal from typing import Any -from sqlalchemy import func, or_, select -from sqlalchemy import inspect as sqlalchemy_inspect +from sqlalchemy import or_, select 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.financial_record import ExpenseClaim, ExpenseClaimItem -from app.schemas.ontology import OntologyEntity, OntologyParseResult -from app.schemas.reimbursement import ( - ExpenseClaimItemCreate, - ExpenseClaimItemUpdate, - ExpenseClaimUpdate, - TravelReimbursementCalculatorRequest, +from app.schemas.ontology import OntologyParseResult +from app.services.expense_claim_application_link import ( + APPROVED_APPLICATION_LINK_STATUSES as APPROVED_APPLICATION_LINK_STATUSES, ) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager -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_application_link import ExpenseClaimApplicationLinkMixin 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, - PERSISTENT_EXPENSE_REVIEW_ACTIONS, - RETURN_REASON_OPTIONS, + EDITABLE_CLAIM_STATUSES, MAX_CLAIM_NO_RETRY_ATTEMPTS, - DOCUMENT_DATE_PATTERN, - SYSTEM_GENERATED_REASON_PREFIXES, - 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, + MAX_DRAFT_CLAIMS_PER_USER, + SYSTEM_GENERATED_ITEM_TYPES, ) -from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin -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 +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin -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 = 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: +class ExpenseClaimDraftAttachmentAssociationMixin(ExpenseClaimTenantScopeMixin): def _find_target_claim( self, *, @@ -556,7 +40,16 @@ class ExpenseClaimDraftAttachmentAssociationMixin: draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() 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): return claim return None @@ -573,6 +66,11 @@ class ExpenseClaimDraftAttachmentAssociationMixin: select(ExpenseClaim) .where(ExpenseClaim.claim_no.in_(claim_codes)) .where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES)) + .where( + self.build_claim_tenant_condition( + self.normalize_context_tenant_id(context_json) + ) + ) .limit(1) ) return self.db.scalar(stmt) @@ -587,7 +85,16 @@ class ExpenseClaimDraftAttachmentAssociationMixin: ) -> ExpenseClaim | None: draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() 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): return claim @@ -612,6 +119,11 @@ class ExpenseClaimDraftAttachmentAssociationMixin: select(ExpenseClaim) .where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES)) .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()) .limit(1) ) @@ -1024,6 +536,12 @@ class ExpenseClaimDraftFlowMixin(ExpenseClaimApplicationLinkMixin, ExpenseClaimD claim, event_type=("claim_draft_created" if is_new_claim else "claim_draft_updated"), 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, idempotency_key=run_id, previous_status=str((before_json or {}).get("status") or ""), diff --git a/server/src/app/services/expense_claim_errors.py b/server/src/app/services/expense_claim_errors.py index 1f2a9a8..1c066fc 100644 --- a/server/src/app/services/expense_claim_errors.py +++ b/server/src/app/services/expense_claim_errors.py @@ -5,3 +5,21 @@ class ExpenseClaimSubmissionBlockedError(ValueError): def __init__(self, issues: list[str]) -> None: self.issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()] 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) diff --git a/server/src/app/services/expense_claim_platform_risk.py b/server/src/app/services/expense_claim_platform_risk.py index 5b1aa53..5a1e96a 100644 --- a/server/src/app/services/expense_claim_platform_risk.py +++ b/server/src/app/services/expense_claim_platform_risk.py @@ -20,16 +20,14 @@ from app.services.expense_claim_platform_context_tools import ( extract_known_cities_from_text, 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_route_risk import resolve_multi_city_related_item_ids from app.services.expense_claim_platform_text_risk import ( 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_normalizer import normalize_risk_rule_manifest from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor @@ -38,21 +36,25 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor class ExpenseClaimPlatformRiskMixin: _DEFAULT_RISK_BUSINESS_STAGE = "reimbursement" _SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"} - def evaluate_platform_risk_rules( self, claim: ExpenseClaim, *, rule_codes: list[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) manifests = self._load_platform_risk_rule_manifests( rule_codes=rule_codes, business_stage=normalized_stage, ) + rule_set_fingerprint = build_risk_manifest_fingerprint(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.append( @@ -71,7 +73,6 @@ class ExpenseClaimPlatformRiskMixin: for manifest in manifests: if not self._risk_manifest_applies_to_claim(manifest, claim=claim, contexts=contexts): continue - flag = self._evaluate_platform_risk_manifest( manifest, claim=claim, @@ -94,7 +95,22 @@ class ExpenseClaimPlatformRiskMixin: 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)) - 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( self, diff --git a/server/src/app/services/expense_claim_pre_review.py b/server/src/app/services/expense_claim_pre_review.py index 4690198..3f386ee 100644 --- a/server/src/app/services/expense_claim_pre_review.py +++ b/server/src/app/services/expense_claim_pre_review.py @@ -6,8 +6,12 @@ from typing import Any from app.api.deps import CurrentUserContext from app.models.financial_record import ExpenseClaim 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_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: @@ -15,6 +19,9 @@ class ExpenseClaimPreReviewMixin: self, claim_id: str, current_user: CurrentUserContext, + *, + correlation_id: str | None = None, + idempotency_key: str | None = None, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: @@ -34,48 +41,32 @@ class ExpenseClaimPreReviewMixin: raise ExpenseClaimSubmissionBlockedError(missing_fields) before_json = self._serialize_claim(claim) - reviewed_at = datetime.now(UTC) - 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, - 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, - ), - ), + pre_review_flag = self.refresh_claim_pre_review_state( + 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.submitted_at = None - self.db.commit() - self.db.refresh(claim) + 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.refresh(claim) + except Exception: + self.db.rollback() + raise self.audit_service.log_action( actor=current_user.name or current_user.username, @@ -87,43 +78,31 @@ class ExpenseClaimPreReviewMixin: ) 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 def _build_ai_pre_review_flag( *, - passed: bool, - blocking_count: int, - reviewed_at: datetime, + decision_payload: dict[str, Any], business_stage: str, ) -> dict[str, Any]: - if passed: - message = "自动检测通过,费用明细和附件可提交审批。" - else: - message = f"自动检测发现 {blocking_count} 条重大风险,请逐条填写原因后再提交审批。" + decision = str(decision_payload.get("decision") or "ready_with_review") + passed = decision != "needs_fix" + blocking_count = int(decision_payload.get("blocking_count") or 0) return with_risk_business_stage( { "source": "ai_pre_review", "event_type": "expense_claim_ai_pre_review", "severity": "info" if passed else "high", - "label": "自动检测通过" if passed else "自动检测未通过", - "message": message, + "label": "自动检测通过" if decision == "ready" else ( + "自动检测待复核" if passed else "自动检测未通过" + ), + "message": str(decision_payload.get("message") or ""), "status": "passed" if passed else "failed", "passed": passed, "blocking_risk_count": blocking_count, + **decision_payload, "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, ) @@ -143,15 +122,29 @@ class ExpenseClaimPreReviewMixin: ] 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( self, claim: ExpenseClaim, *, is_application_claim: bool | None = None, reviewed_at: datetime | None = None, - ) -> bool: + ) -> dict[str, Any] | None: if claim is None: - return False + return None if is_application_claim is None: is_application_claim = self._is_expense_application_claim(claim) @@ -174,31 +167,79 @@ class ExpenseClaimPreReviewMixin: review_flags = dedupe_claim_risk_flags( [*preserved_flags, *list(application_review.get("flags") or [])] ) + platform_rule_set_fingerprint = str( + application_review.get("rule_set_fingerprint") or "" + ) else: review_result = self._run_ai_submission_review(claim) 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) + business_stage = risk_business_stage_for_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, - 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( - is_application_claim=is_application_claim, - ), - ), + pre_review_flag, ) if not is_application_claim: claim.approval_stage = "\u5f85\u63d0\u4ea4" claim.submitted_at = None - return True + return pre_review_flag - @staticmethod - def _has_ai_pre_review_flag(claim: ExpenseClaim) -> bool: - return any( - isinstance(flag, dict) - and str(flag.get("source") or "").strip() == "ai_pre_review" - for flag in list(claim.risk_flags_json or []) + def _record_pre_review_event( + self, + claim: ExpenseClaim, + *, + pre_review_flag: dict[str, Any], + 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 ""), + }, ) diff --git a/server/src/app/services/expense_claim_pre_review_decision.py b/server/src/app/services/expense_claim_pre_review_decision.py new file mode 100644 index 0000000..39eca5d --- /dev/null +++ b/server/src/app/services/expense_claim_pre_review_decision.py @@ -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() diff --git a/server/src/app/services/expense_claim_review_preview.py b/server/src/app/services/expense_claim_review_preview.py index dad351c..d35d453 100644 --- a/server/src/app/services/expense_claim_review_preview.py +++ b/server/src/app/services/expense_claim_review_preview.py @@ -154,6 +154,12 @@ class ExpenseClaimReviewPreviewMixin: if str(item).strip() ], 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(), ) @@ -344,6 +350,12 @@ class ExpenseClaimReviewPreviewMixin: name=str(context_json.get("name") or user_id or "anonymous").strip() or "anonymous", role_codes=[], is_admin=False, + tenant_id=str( + context_json.get("tenant_id") + or context_json.get("tenantId") + or "default" + ).strip() + or "default", ), ) except ValueError: diff --git a/server/src/app/services/expense_claim_risk_review.py b/server/src/app/services/expense_claim_risk_review.py index 97257e6..067effc 100644 --- a/server/src/app/services/expense_claim_risk_review.py +++ b/server/src/app/services/expense_claim_risk_review.py @@ -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_risk_flags import dedupe_claim_risk_flags 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 logger = get_logger("app.services.expense_claim_risk_review") @@ -110,6 +111,9 @@ class ExpenseClaimRiskReviewMixin( "status": "submitted", "approval_stage": "直属领导审批", "risk_flags": final_risk_flags, + "rule_set_fingerprint": str( + platform_risk_review.get("rule_set_fingerprint") or "" + ), "message": ( f"报销单 {claim.claim_no} 已完成自动检测," f"现已提交给直属领导 {manager_name or '审批人'} 审批。" @@ -144,9 +148,15 @@ class ExpenseClaimRiskReviewMixin( .where(or_(*filters)) .where(ExpenseClaim.id != claim.id) .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()) return sum(1 for item in recent_claims if list(item.risk_flags_json or [])) - - diff --git a/server/src/app/services/expense_claim_rule_fingerprint.py b/server/src/app/services/expense_claim_rule_fingerprint.py new file mode 100644 index 0000000..e856f7d --- /dev/null +++ b/server/src/app/services/expense_claim_rule_fingerprint.py @@ -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}" diff --git a/server/src/app/services/expense_claim_standard_adjustment.py b/server/src/app/services/expense_claim_standard_adjustment.py new file mode 100644 index 0000000..a3f21a3 --- /dev/null +++ b/server/src/app/services/expense_claim_standard_adjustment.py @@ -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 diff --git a/server/src/app/services/expense_claim_tenant_scope.py b/server/src/app/services/expense_claim_tenant_scope.py new file mode 100644 index 0000000..faccb48 --- /dev/null +++ b/server/src/app/services/expense_claim_tenant_scope.py @@ -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)) diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 47d1f9c..f0aba10 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -1,10 +1,9 @@ from __future__ import annotations -import re import uuid from collections.abc import Callable from datetime import UTC, datetime -from decimal import Decimal, InvalidOperation +from decimal import Decimal from typing import Any from sqlalchemy import delete, select @@ -19,9 +18,7 @@ from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.schemas.reimbursement import ( ExpenseClaimItemCreate, ExpenseClaimItemUpdate, - ExpenseClaimStandardAdjustmentPayload, ExpenseClaimUpdate, - TravelReimbursementCalculatorRequest, ) from app.services.audit import AuditLogService 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_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin -from app.services.expense_claim_constants import ( - RETURN_REASON_OPTIONS, - STANDARD_ADJUSTMENT_RISK_SOURCE, -) +from app.services.expense_claim_constants import RETURN_REASON_OPTIONS from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin 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_pagination import ExpenseClaimPaginationMixin 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_review_preview import ExpenseClaimReviewPreviewMixin 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_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_repair import ExpenseClaimWorkflowRepairMixin 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: def update_claim_item( self, @@ -497,6 +235,8 @@ class ExpenseClaimItemActionMixin: current_user: CurrentUserContext, *, correlation_id: str | None = None, + pre_review_id: str = "", + pre_review_input_fingerprint: str = "", before_commit: Callable[[BusinessEvent], None] | None = None, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) @@ -516,6 +256,50 @@ class ExpenseClaimItemActionMixin: if 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: budget_flags = self._reserve_budget_for_submission( claim, @@ -526,7 +310,6 @@ class ExpenseClaimItemActionMixin: if is_application_claim: raise budget_flags = list(exc.flags or []) - before_json = self._serialize_claim(claim) if is_application_claim: submitted_at = datetime.now(UTC) preserved_flags = [ @@ -572,8 +355,6 @@ class ExpenseClaimItemActionMixin: budget_flags, 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.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE @@ -587,6 +368,7 @@ class ExpenseClaimItemActionMixin: actor_id=current_user.username, tenant_id=getattr(current_user, "tenant_id", None), correlation_id=correlation_id, + causation_id=pre_review_event.id if pre_review_event is not None else None, idempotency_key=( f"submit:{claim.id}:{claim.submitted_at.isoformat()}" 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: claim = self.get_claim(claim_id, current_user) if claim is None and current_user.is_admin: - candidate_claim = self.db.scalar( + candidate_stmt = ( select(ExpenseClaim) .options( selectinload(ExpenseClaim.items), @@ -626,6 +408,9 @@ class ExpenseClaimItemActionMixin: ) .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: claim = candidate_claim if claim is None: diff --git a/server/src/app/services/expense_receipt_association.py b/server/src/app/services/expense_receipt_association.py index 880366a..c843551 100644 --- a/server/src/app/services/expense_receipt_association.py +++ b/server/src/app/services/expense_receipt_association.py @@ -215,6 +215,13 @@ class ExpenseReceiptAssociationService: ) 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.refresh(claim) self._discard_attachment_backups(mutations) diff --git a/server/src/app/services/expense_rule_runtime.py b/server/src/app/services/expense_rule_runtime.py index 0bf950c..a00c9f9 100644 --- a/server/src/app/services/expense_rule_runtime.py +++ b/server/src/app/services/expense_rule_runtime.py @@ -4,8 +4,10 @@ import json import re from decimal import Decimal from typing import Any +from zipfile import BadZipFile from openpyxl import load_workbook +from openpyxl.utils.exceptions import InvalidFileException from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.orm import Session @@ -15,8 +17,8 @@ from app.models.agent_asset import AgentAsset, AgentAssetVersion from app.services.agent_asset_spreadsheet import ( COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE, - AgentAssetSpreadsheetManager, TRAVEL_SPREADSHEET_RULE_CODES, + AgentAssetSpreadsheetManager, ) from app.services.expense_rule_runtime_defaults import ( DEFAULT_SCENE_MATRIX_CONFIG, @@ -37,10 +39,6 @@ from app.services.expense_rule_runtime_models import ( build_default_expense_rule_catalog, 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 ( extract_hotel_season_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, 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 +__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: def __init__(self, db: Session) -> None: self.db = db @@ -225,7 +243,7 @@ class ExpenseRuleRuntimeService: read_only=True, data_only=True, ) - except (FileNotFoundError, OSError): + except (BadZipFile, FileNotFoundError, InvalidFileException, OSError): return try: diff --git a/server/src/app/services/linked_reimbursement_draft_jobs.py b/server/src/app/services/linked_reimbursement_draft_jobs.py index dd68be4..420533d 100644 --- a/server/src/app/services/linked_reimbursement_draft_jobs.py +++ b/server/src/app/services/linked_reimbursement_draft_jobs.py @@ -1,32 +1,34 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime from threading import Lock -from typing import Any, Callable +from typing import Any from uuid import uuid4 from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from app.api.deps import CurrentUserContext +from app.models.financial_record import ExpenseClaim from app.schemas.linked_reimbursement_draft_job import ( LinkedReimbursementDraftJobCreate, LinkedReimbursementDraftJobRead, ) from app.schemas.ontology import OntologyParseResult, OntologyPermission 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.orchestrator import OrchestratorService - TERMINAL_STATUSES = {"succeeded", "failed"} @dataclass(slots=True) class LinkedReimbursementDraftJobState: job_id: str + tenant_id: str owner_username: str owner_name: str message: str @@ -68,11 +70,15 @@ def create_linked_reimbursement_draft_job( current_user: CurrentUserContext, ) -> LinkedReimbursementDraftJobRead: 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["session_type"] = context_json.get("session_type") or "expense" job_id = f"linked-reimbursement-draft-{uuid4()}" state = LinkedReimbursementDraftJobState( job_id=job_id, + tenant_id=tenant_id, owner_username=str(current_user.username or "").strip(), owner_name=str(current_user.name 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="正在后台生成报销草稿...") try: 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( db=db, state=state, @@ -156,6 +166,10 @@ def _get_authorized_state( state = _jobs.get(normalized_job_id) if state is None: return None + if state.tenant_id != ExpenseClaimAccessPolicy.normalize_tenant_id( + current_user.tenant_id + ): + return None if current_user.is_admin: return state 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" -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() if review_action != "save_draft": 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() if not application_claim_no: return False - if application_claim_id: - return True - return _find_application_claim_by_no(db, application_claim_no) is not None - - -def _find_application_claim_by_no(db: Session, claim_no: str) -> ExpenseClaim | None: - 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) + return ( + _find_application_claim( + db, + claim_id=application_claim_id, + claim_no=application_claim_no, + tenant_id=tenant_id, + ) + 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): return claim 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.pop("tenantId", None) + direct_context["tenant_id"] = ExpenseClaimAccessPolicy.normalize_tenant_id( + tenant_id + ) review_values = dict(direct_context.get("review_form_values") 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_no = str(review_values.get("application_claim_no") or "").strip() - if not application_claim_id and application_claim_no: - application_claim = _find_application_claim_by_no(db, application_claim_no) - if application_claim is not None: - review_values["application_claim_id"] = application_claim.id - scene_selection["application_claim_id"] = application_claim.id - scene_selection["application_claim_no"] = str( - scene_selection.get("application_claim_no") - or application_claim.claim_no - or application_claim_no - ).strip() + application_claim = _find_application_claim( + db, + 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_no"] = application_claim.claim_no + scene_selection["application_claim_id"] = application_claim.id + scene_selection["application_claim_no"] = application_claim.claim_no direct_context["review_form_values"] = review_values if 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), message=state.message, 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_no = str(result.get("claim_no") or "").strip() diff --git a/server/src/app/services/ontology_detection.py b/server/src/app/services/ontology_detection.py index 93e52b7..901b0d9 100644 --- a/server/src/app/services/ontology_detection.py +++ b/server/src/app/services/ontology_detection.py @@ -72,6 +72,11 @@ EXPLICIT_ENTERTAINMENT_KEYWORDS = ( "商务宴请", "接待餐", ) +ENGLISH_FINANCE_BUSINESS_KEYWORDS = ( + "reimbursement", + "expenseclaim", + "travelapplication", +) class OntologyDetectionMixin: @@ -101,6 +106,8 @@ class OntologyDetectionMixin: if self._looks_like_expense_application(compact_query): return True + if any(keyword in compact_query for keyword in ENGLISH_FINANCE_BUSINESS_KEYWORDS): + return True domain_keywords = [ keyword diff --git a/server/src/app/services/steward_action_executor.py b/server/src/app/services/steward_action_executor.py index f4c7d0d..b7a258f 100644 --- a/server/src/app/services/steward_action_executor.py +++ b/server/src/app/services/steward_action_executor.py @@ -537,6 +537,7 @@ class StewardActionExecutor: "session_type": "expense", "entry_source": "steward_action_executor", "review_action": "save_draft", + "tenant_id": current_user.tenant_id, "review_form_values": review_form_values, "user_input_text": self._resolve_message(request), "role_codes": current_user.role_codes, diff --git a/server/src/app/services/user_agent_review_messages.py b/server/src/app/services/user_agent_review_messages.py index 82ddbeb..aecce1b 100644 --- a/server/src/app/services/user_agent_review_messages.py +++ b/server/src/app/services/user_agent_review_messages.py @@ -385,6 +385,12 @@ class UserAgentReviewMessageMixin: if str(item).strip() ], 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(), ) try: @@ -589,6 +595,12 @@ class UserAgentReviewMessageMixin: if str(item).strip() ], 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(), ) try: diff --git a/server/tests/test_attachment_association_jobs.py b/server/tests/test_attachment_association_jobs.py index f9be199..0f24f49 100644 --- a/server/tests/test_attachment_association_jobs.py +++ b/server/tests/test_attachment_association_jobs.py @@ -422,6 +422,13 @@ def test_attachment_association_job_links_receipts_after_conversation_exit( assert claim is not None attached_items = [item for item in claim.items if item.invoice_id] 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( db.scalars( select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt") @@ -438,6 +445,13 @@ def test_attachment_association_job_links_receipts_after_conversation_exit( "receipt_received", "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( current_user=current_user, status_filter="linked" diff --git a/server/tests/test_expense_case_endpoints.py b/server/tests/test_expense_case_endpoints.py index 4d07057..143994b 100644 --- a/server/tests/test_expense_case_endpoints.py +++ b/server/tests/test_expense_case_endpoints.py @@ -246,7 +246,7 @@ def test_expense_case_timeline_rejects_cross_tenant_lookup( ) 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( diff --git a/server/tests/test_expense_case_service.py b/server/tests/test_expense_case_service.py index e229c54..d5dd9af 100644 --- a/server/tests/test_expense_case_service.py +++ b/server/tests/test_expense_case_service.py @@ -11,13 +11,14 @@ from sqlalchemy.pool import StaticPool from app.api.deps import CurrentUserContext from app.db.base import Base 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.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.organization import OrganizationUnit from app.services.agent_foundation import AgentFoundationService from app.services.expense_cases import ExpenseCaseService +from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError from app.services.expense_claim_workflow_constants import ( APPLICATION_ARCHIVE_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( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -215,12 +512,24 @@ def test_submit_claim_creates_case_link_and_structured_event() -> None: assert submitted.status == "submitted" link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == submitted.id)) assert link is not None - event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == submitted.id)) - assert event is not None - assert event.event_type == "claim_submitted" - assert event.delivery_status == "pending" - assert event.payload_json["previous_status"] == "draft" - assert event.payload_json["next_status"] == "submitted" + pre_review_event = db.scalar( + select(BusinessEvent).where( + BusinessEvent.aggregate_id == submitted.id, + BusinessEvent.event_type == "claim_pre_review_completed", + ) + ) + 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( diff --git a/server/tests/test_expense_claim_approval_routing.py b/server/tests/test_expense_claim_approval_routing.py index a4e36bb..8ae5369 100644 --- a/server/tests/test_expense_claim_approval_routing.py +++ b/server/tests/test_expense_claim_approval_routing.py @@ -16,6 +16,7 @@ from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.models.organization import OrganizationUnit from app.models.role import Role +from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError from app.services.expense_claim_workflow_constants import ( APPLICATION_LINK_STATUS_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: department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP") _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.status == "approved" - assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE + assert approved.status == "submitted" + assert approved.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE route_flag = [ flag for flag in approved.risk_flags_json if isinstance(flag, dict) and flag.get("source") == "approval_routing" ][0] - assert route_flag["requires_budget_review"] is False - assert route_flag["route"] == "approval_done" + assert route_flag["requires_budget_review"] is True + assert route_flag["route"] == "budget_manager" 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: @@ -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: with build_session() as db: department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL") diff --git a/server/tests/test_expense_claim_pre_review_decision.py b/server/tests/test_expense_claim_pre_review_decision.py new file mode 100644 index 0000000..3d8e1ce --- /dev/null +++ b/server/tests/test_expense_claim_pre_review_decision.py @@ -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"], + ) diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 5c18d1a..d203ddb 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -31,6 +31,7 @@ from app.services.budget import BudgetService from app.services.document_preview import DocumentPreviewAssets from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage 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 ( APPLICATION_ARCHIVE_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) 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: @@ -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 -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( username="emp-1", name="张三", @@ -3271,10 +3272,10 @@ def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None: db.commit() claim_id = claim.id - with pytest.raises(ValueError, match="只有 admin 管理员可以删除单据"): - ExpenseClaimService(db).delete_claim(claim_id, current_user) + deleted = 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: @@ -3411,7 +3412,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None: assert submitted.approval_stage == "直属领导审批" 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( username="emp-submit@example.com", name="submitter", @@ -3419,10 +3420,15 @@ def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypa is_admin=False, ) - def fail_review(self, reviewed_claim): - raise AssertionError("submit should reuse upload-time pre-review") + original_review = ExpenseClaimService._run_ai_submission_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: 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.status == "submitted" - assert any(flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json) - assert any(flag.get("source") == "ai_pre_review" for flag in submitted.risk_flags_json) + assert review_calls == 1 + 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: @@ -3732,7 +3748,7 @@ def test_submit_claim_backfills_department_from_current_employee() -> None: 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, tmp_path, ) -> None: @@ -3798,19 +3814,22 @@ def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag( 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 - assert submitted.status == "submitted" - assert submitted.approval_stage == "直属领导审批" - assert submitted.submitted_at is not None + blocked = db.get(ExpenseClaim, claim.id) + assert blocked is not None + assert blocked.status == "draft" + assert blocked.submitted_at is None + assert error_info.value.review["decision"] == "needs_fix" assert any( - isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review" - for flag in list(submitted.risk_flags_json or []) + finding["severity"] == "high" + 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, tmp_path, ) -> None: @@ -3968,34 +3987,22 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag( 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 - assert submitted.status == "submitted" - assert submitted.approval_stage == "直属领导审批" - assert any( - isinstance(flag, dict) - and str(flag.get("source") or "").strip() == "submission_review" - and ( - "多城市" in str(flag.get("message") or "") - 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 {"行程终点异常", "多城市行程待说明"} + blocked = db.get(ExpenseClaim, claim.id) + assert blocked is not None + assert blocked.status == "draft" + assert error_info.value.review["decision"] == "needs_fix" + route_findings = [ + finding + for finding in error_info.value.review["findings"] + if "多城市" in finding["message"] or "终点" in finding["message"] ] - assert route_flags - assert all(flag.get("item_ids") for flag in route_flags) - assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags) - assert not any( - isinstance(flag, dict) - and str(flag.get("label") or "").strip() == "多城市行程缺少说明中风险" - for flag in list(submitted.risk_flags_json or []) + assert route_findings + assert any( + "travel-item-2" in finding["item_ids"] + for finding in route_findings ) @@ -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, tmp_path, ) -> None: @@ -4282,16 +4289,21 @@ def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_re 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 - assert submitted.status == "submitted" - assert submitted.approval_stage == "直属领导审批" + blocked = db.get(ExpenseClaim, claim.id) + assert blocked is not None + assert blocked.status == "draft" + assert error_info.value.review["decision"] == "needs_fix" assert any( - isinstance(flag, dict) - and str(flag.get("source") or "").strip() == "submission_review" - and "住宿标准" in str(flag.get("message") or "") - for flag in list(submitted.risk_flags_json or []) + finding.get("remediation", {}).get("alternative_action") + == "accept_standard_limit" + for finding in error_info.value.review["findings"] + ) + 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 ) - with pytest.raises(ValueError, match="只有高级财务人员可以删除"): + with pytest.raises(ValueError, match="申请人本人可以删除单据"): service.delete_claim(claim_id, current_user) 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( username="executive-delete@example.com", name="高管", @@ -4985,11 +4997,10 @@ def test_executive_can_delete_submitted_claim() -> None: db.commit() 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 deleted.claim_no == "EXP-DEL-EXEC-101" - assert db.get(ExpenseClaim, claim_id) is None + assert db.get(ExpenseClaim, claim_id) is not 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.status == "submitted" - assert approved.approval_stage == "预算管理者审批" + assert approved.status == "approved" + assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE assert any( isinstance(flag, dict) and flag.get("event_type") == "expense_application_approval" 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 ) - 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: diff --git a/server/tests/test_expense_claim_tenant_and_case_stage.py b/server/tests/test_expense_claim_tenant_and_case_stage.py new file mode 100644 index 0000000..142529a --- /dev/null +++ b/server/tests/test_expense_claim_tenant_and_case_stage.py @@ -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"} diff --git a/server/tests/test_expense_claim_tenant_scope.py b/server/tests/test_expense_claim_tenant_scope.py new file mode 100644 index 0000000..472bc16 --- /dev/null +++ b/server/tests/test_expense_claim_tenant_scope.py @@ -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 diff --git a/server/tests/test_expense_rule_runtime_resilience.py b/server/tests/test_expense_rule_runtime_resilience.py new file mode 100644 index 0000000..da6234c --- /dev/null +++ b/server/tests/test_expense_rule_runtime_resilience.py @@ -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) diff --git a/server/tests/test_linked_reimbursement_draft_jobs.py b/server/tests/test_linked_reimbursement_draft_jobs.py index 8788402..92dea55 100644 --- a/server/tests/test_linked_reimbursement_draft_jobs.py +++ b/server/tests/test_linked_reimbursement_draft_jobs.py @@ -13,7 +13,10 @@ from app.main import create_app from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary +from app.services.expense_cases import ExpenseCaseService from app.services.linked_reimbursement_draft_jobs import ( + _build_direct_context_json, + _find_application_claim, clear_linked_reimbursement_draft_jobs_for_tests, ) 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() +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: clear_linked_reimbursement_draft_jobs_for_tests() diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index ed6a4e8..6a4f5f3 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -30,6 +30,7 @@ from app.schemas.user_agent import UserAgentRequest from app.services.document_preview import DocumentPreviewAssets from app.services.expense_cases import ExpenseCaseService 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.user_agent import UserAgentService @@ -124,6 +125,161 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]: 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: client, session_factory = build_client() 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}", headers={ "x-auth-username": "zhangsan@example.com", - "x-auth-name": "张三", + "x-auth-name": "Zhang San", "x-auth-employee-no": "E10001", "x-auth-role-codes": "user", }, diff --git a/web/src/services/api.js b/web/src/services/api.js index 1e65ad0..40d0c19 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -187,6 +187,12 @@ function sanitizeHeaders(headers) { function buildApiResponseError(response, payload, { auth, handleUnauthorized }) { const error = new Error(resolveErrorMessage(payload)) 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()) { error.code = 'AUTH_SESSION_EXPIRED' notifyAuthSessionExpired() diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index e471871..bdd6b0f 100644 --- a/web/src/services/reimbursements.js +++ b/web/src/services/reimbursements.js @@ -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`, { method: 'POST', - body: JSON.stringify({}) + body: JSON.stringify(payload || {}) }) } diff --git a/web/src/utils/expenseCaseTimeline.js b/web/src/utils/expenseCaseTimeline.js index 8c6ee8d..0cae21e 100644 --- a/web/src/utils/expenseCaseTimeline.js +++ b/web/src/utils/expenseCaseTimeline.js @@ -35,6 +35,10 @@ const EVENT_PRESENTATION = { application_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' }, + 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' }, 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') { const reimbursementClaimNo = compactText(readField(payload, 'reimbursement_claim_no', 'reimbursementClaimNo')) if (reimbursementClaimNo) { @@ -175,13 +205,17 @@ function normalizeEvent(event, index) { const occurredAt = readField(event, 'occurred_at', 'occurredAt') const timestamp = new Date(occurredAt || '').getTime() 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 { id: compactText(event?.id) || `${eventType || 'event'}-${index}`, eventType, label: presentation.label, icon: presentation.icon, - tone: presentation.tone, + tone: isFailedPreReview ? 'warning' : presentation.tone, summary: buildEventSummary(eventType, payload), actorLabel: eventType === 'historical_claim_imported' ? '系统' : resolveActorLabel(event), occurredAt: compactText(occurredAt), diff --git a/web/src/views/scripts/travelRequestDetailPreReview.js b/web/src/views/scripts/travelRequestDetailPreReview.js new file mode 100644 index 0000000..0569e74 --- /dev/null +++ b/web/src/views/scripts/travelRequestDetailPreReview.js @@ -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 || '' + })) +} diff --git a/web/src/views/scripts/useTravelRequestDetailRiskSubmit.js b/web/src/views/scripts/useTravelRequestDetailRiskSubmit.js index 4c8453a..04f4cbc 100644 --- a/web/src/views/scripts/useTravelRequestDetailRiskSubmit.js +++ b/web/src/views/scripts/useTravelRequestDetailRiskSubmit.js @@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch } from 'vue' import { acceptExpenseClaimStandardAdjustment, calculateTravelReimbursement, + preReviewExpenseClaim, submitExpenseClaim, updateExpenseClaim } from '../../services/reimbursements.js' @@ -26,6 +27,7 @@ import { normalizeRiskTone, resolveRiskTags } from './travelRequestDetailInsights.js' +import { mapPreReviewFindingsToRiskFlags } from './travelRequestDetailPreReview.js' import { buildCurrentStandardAdjustmentMap, buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel, @@ -139,11 +141,16 @@ export function useTravelRequestDetailRiskSubmit({ } function applyClaimRiskFlagsPayload(payload) { - const flags = Array.isArray(payload?.claim_risk_flags) - ? payload.claim_risk_flags - : Array.isArray(payload?.claimRiskFlags) - ? payload.claimRiskFlags + const flags = [ + payload?.claim_risk_flags, + payload?.claimRiskFlags, + payload?.risk_flags_json, + payload?.riskFlags + ].find(Array.isArray) || ( + Array.isArray(payload?.pre_review?.findings) + ? mapPreReviewFindingsToRiskFlags(payload.pre_review) : null + ) if (!flags) { return } @@ -657,7 +664,24 @@ export function useTravelRequestDetailRiskSubmit({ async function runSubmitRequest(claimId, documentNo, isApplication, taskSeq) { 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) { return } @@ -678,6 +702,12 @@ export function useTravelRequestDetailRiskSubmit({ emit('request-updated', { claimId }) } catch (error) { 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 || '提交审批失败,请稍后重试。') } } finally { @@ -693,7 +723,6 @@ export function useTravelRequestDetailRiskSubmit({ submitTaskSeq += 1 submitBusy.value = false } - function disposeRiskSubmit() { resetSubmitWorkState() if (highlightedRiskCardTimer) { diff --git a/web/tests/api-request.test.mjs b/web/tests/api-request.test.mjs index 5780aab..1580805 100644 --- a/web/tests/api-request.test.mjs +++ b/web/tests/api-request.test.mjs @@ -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() { await testUsesCustomContentTypeHeader() await testSupportsBlobResponses() @@ -251,6 +289,7 @@ async function run() { await testRejectsCustomAuthorizationOverride() await testUnauthorizedResponsePublishesSessionExpiredEvent() await testFormatsValidationErrors() + await testPreservesStructuredPreReviewConflict() await testRejectsWithCustomTimeoutMessage() console.log('api-request tests passed') } diff --git a/web/tests/expense-case-timeline.test.mjs b/web/tests/expense-case-timeline.test.mjs index bc957d6..7d64b86 100644 --- a/web/tests/expense-case-timeline.test.mjs +++ b/web/tests/expense-case-timeline.test.mjs @@ -100,6 +100,48 @@ test('expense case timeline gives returned events an actionable reason', () => { 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', () => { const timeline = buildExpenseCaseTimelineViewModel({ events: [ diff --git a/web/tests/reimbursement-pre-review-service.test.mjs b/web/tests/reimbursement-pre-review-service.test.mjs new file mode 100644 index 0000000..7d007c5 --- /dev/null +++ b/web/tests/reimbursement-pre-review-service.test.mjs @@ -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' + }) +}) diff --git a/web/tests/travel-request-detail-pre-review.test.mjs b/web/tests/travel-request-detail-pre-review.test.mjs new file mode 100644 index 0000000..7073a1b --- /dev/null +++ b/web/tests/travel-request-detail-pre-review.test.mjs @@ -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') +}) diff --git a/web/tests/travel-request-detail-submit-confirm.test.mjs b/web/tests/travel-request-detail-submit-confirm.test.mjs index 0fdc9f4..100911d 100644 --- a/web/tests/travel-request-detail-submit-confirm.test.mjs +++ b/web/tests/travel-request-detail-submit-confirm.test.mjs @@ -59,9 +59,9 @@ const deleteDialogComponent = readFileSync( ) function extractFunction(source, name) { - let signatureIndex = source.indexOf(`function ${name}(`) + let signatureIndex = source.indexOf(`async function ${name}(`) if (signatureIndex === -1) { - signatureIndex = source.indexOf(`async function ${name}(`) + signatureIndex = source.indexOf(`function ${name}(`) } assert.notEqual(signatureIndex, -1, `${name} should exist`) @@ -84,6 +84,46 @@ function extractFunction(source, name) { 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', () => { assert.match(detailViewTemplate, / -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/) }) +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', () => { assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/) assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/)