feat(expenses): add authoritative pre-review workflow

This commit is contained in:
caoxiaozhu
2026-07-16 11:42:08 +08:00
parent ae3f02c35a
commit 6bdf65bc24
52 changed files with 4300 additions and 1069 deletions

View File

@@ -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-16P0 提交前预审握手):新增稳定预审决策契约、输入/规则/动态 findings 指纹和结构化整改动作;申请与报销提交前均由服务端重新计算完整风险上下文,可整改重大风险在预算占用前返回 409握手期间风险变化返回 `PRE_REVIEW_CHANGED`,预算治理和人工判断风险继续进入领导与 P8。
- 2026-07-16P0 费用事件续接):新增 `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-13P0 基础切片):新增 `ExpenseCase``ExpenseCaseLink``BusinessEvent` 模型与 `ExpenseCaseService`,提供 `/api/v1/expense-cases/by-claim/{claim_id}` 时间线查询接口。

View File

@@ -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/causationAI 草稿和票据归集已透传租户与稳定幂等键;申请、审批、付款和外部连接器仍需继续统一。
- [ ] [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 申请预填 → 用户接受/显式修改 → 草稿或提交结果同事务落账。

View File

@@ -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` 通过。

View File

@@ -0,0 +1,116 @@
from __future__ import annotations
from typing import Annotated
from fastapi import Header, HTTPException, status
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import (
ExpenseClaimActionResponse,
ExpenseClaimPreReviewRead,
ExpenseClaimRead,
ExpenseClaimSubmitPayload,
)
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
from app.services.expense_claim_pre_review_decision import (
find_pre_review_flag,
pre_review_public_payload,
)
from app.services.expense_claims import ExpenseClaimService
RequestIdHeader = Annotated[
str | None,
Header(
alias="X-Request-ID",
description="客户端生成的请求 ID相同 ID 的预审重试复用同一费用事件。",
),
]
def pre_review_expense_claim_or_http_error(
service: ExpenseClaimService,
claim_id: str,
current_user: CurrentUserContext,
request_id: str | None,
) -> ExpenseClaimRead:
try:
claim = service.pre_review_claim(
claim_id,
current_user,
correlation_id=request_id,
idempotency_key=request_id,
)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
) from error
return _claim_response_or_not_found(claim)
def submit_expense_claim_or_http_error(
service: ExpenseClaimService,
claim_id: str,
current_user: CurrentUserContext,
payload: ExpenseClaimSubmitPayload | None,
) -> ExpenseClaimRead:
try:
claim = service.submit_claim(
claim_id,
current_user,
pre_review_id=str(getattr(payload, "pre_review_id", "") or ""),
pre_review_input_fingerprint=str(
getattr(payload, "input_fingerprint", "") or ""
),
)
except ExpenseClaimPreReviewBlockedError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": error.code,
"message": str(error),
"review": error.review,
},
) from error
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
) from error
return _claim_response_or_not_found(claim)
def _claim_response_or_not_found(claim: ExpenseClaim | None) -> ExpenseClaimRead:
if claim is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Claim not found",
)
response = ExpenseClaimRead.model_validate(claim)
payload = pre_review_public_payload(find_pre_review_flag(claim))
pre_review = (
ExpenseClaimPreReviewRead.model_validate(payload)
if payload is not None
else None
)
return response.model_copy(update={"pre_review": pre_review})
def expense_claim_deletion_response(claim: ExpenseClaim) -> ExpenseClaimActionResponse:
claim_no = str(claim.claim_no or "").strip()
expense_type = str(claim.expense_type or "").strip().lower()
document_label = (
"申请单"
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
else "报销单"
)
return ExpenseClaimActionResponse(
message=f"{claim.claim_no} {document_label}已删除。",
claim_id=claim.id,
status="deleted",
)

View File

@@ -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(

View File

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

View File

@@ -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

View File

@@ -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,

View File

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

View File

@@ -0,0 +1,543 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from sqlalchemy import select
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.expense_claim_constants import (
DOCUMENT_FACT_ITEM_TYPES,
SYSTEM_GENERATED_ITEM_TYPES,
)
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {
"cancelled",
"canceled",
"deleted",
}
class ExpenseClaimApplicationLinkMixin:
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
def _clear_application_link_placeholder_items(
self,
claim: ExpenseClaim,
*,
context_json: dict[str, Any],
) -> None:
application_amounts = self._resolve_application_amount_candidates(context_json)
for item in list(claim.items or []):
if not self._is_application_link_placeholder_item(
item,
claim=claim,
context_json=context_json,
application_amounts=application_amounts,
):
continue
claim.items.remove(item)
self.db.delete(item)
def _is_application_link_placeholder_item(
self,
item: ExpenseClaimItem,
*,
claim: ExpenseClaim,
context_json: dict[str, Any],
application_amounts: set[Decimal],
) -> bool:
if str(item.invoice_id or "").strip():
return False
item_type = str(item.item_type or "").strip().lower()
if item_type in DOCUMENT_FACT_ITEM_TYPES:
return False
if item_type in SYSTEM_GENERATED_ITEM_TYPES:
return True
claim_type = str(claim.expense_type or "").strip().lower()
if item_type and claim_type and item_type != claim_type:
return False
amount = self._parse_context_money_amount(item.item_amount)
if (
application_amounts
and amount is not None
and amount > Decimal("0.00")
and amount not in application_amounts
):
return False
reason = str(item.item_reason or "").strip()
if not reason or reason == "待补充":
return True
review_values = self._normalize_context_object(
context_json.get("review_form_values")
)
linked_reasons = {
str(review_values.get(key) or "").strip()
for key in ("application_reason", "reason", "business_reason")
}
linked_reasons.add(str(claim.reason or "").strip())
return reason in {value for value in linked_reasons if value}
def _should_skip_application_link_placeholder_item(
self,
*,
claim: ExpenseClaim | None,
context_json: dict[str, Any],
document_specs: list[dict[str, Any]],
attachment_count: int,
amount: Decimal | None,
) -> bool:
if document_specs or attachment_count > 0:
return False
if self._build_application_link_flag(context_json) is None:
return False
return True
@classmethod
def _resolve_application_amount_candidates(
cls,
context_json: dict[str, Any],
) -> set[Decimal]:
review_values = cls._normalize_context_object(
context_json.get("review_form_values")
)
scene_selection = cls._normalize_context_object(
context_json.get("expense_scene_selection")
)
candidates: set[Decimal] = set()
for source in (review_values, scene_selection, context_json):
for key in (
"application_amount",
"application_amount_label",
"applicationAmount",
"applicationAmountLabel",
):
parsed = cls._parse_context_money_amount(source.get(key))
if parsed is not None:
candidates.add(parsed)
return candidates
@staticmethod
def _parse_context_money_amount(value: Any) -> Decimal | None:
raw_value = str(value or "").strip()
if not raw_value:
return None
compact = re.sub(r"[^\d.\-]", "", raw_value.replace(",", ""))
if not compact or compact in {"-", ".", "-."}:
return None
try:
return Decimal(compact).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
@staticmethod
def _merge_application_link_flag(
risk_flags: list[Any],
*,
context_json: dict[str, Any],
) -> list[Any]:
link_flag = ExpenseClaimApplicationLinkMixin._build_application_link_flag(
context_json
)
if link_flag is None:
return list(risk_flags or [])
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
for flag in list(risk_flags or []):
if not isinstance(flag, dict):
continue
existing_no = str(
flag.get("application_claim_no")
or flag.get("applicationClaimNo")
or ""
).strip()
if existing_no and existing_no == application_claim_no:
return list(risk_flags or [])
return [*list(risk_flags or []), link_flag]
def _build_application_link_block_result(
self,
*,
context_json: dict[str, Any],
target_claim: ExpenseClaim | None,
) -> dict[str, Any] | None:
link_flag = self._build_application_link_flag(context_json)
if link_flag is None:
return None
application_claim = self._find_application_claim_for_link(link_flag)
application_claim_no = str(
link_flag.get("application_claim_no") or ""
).strip()
display_no = application_claim_no or "未编号申请单"
if application_claim is None or not self._is_expense_application_claim(
application_claim
):
return self._build_application_link_rejected_result(
f"未找到可关联的申请单 {display_no}。请先选择已审批通过的申请单。",
)
normalized_status = str(application_claim.status or "").strip().lower()
if normalized_status not in APPROVED_APPLICATION_LINK_STATUSES:
return self._build_application_link_rejected_result(
f"申请单 {application_claim.claim_no} 当前不是已审批通过状态,不能用于快速报销关联。",
application_claim=application_claim,
)
existing_reimbursement = self._find_existing_reimbursement_for_application_link(
application_claim=application_claim,
link_flag=link_flag,
target_claim=target_claim,
)
if existing_reimbursement is not None:
return self._build_application_link_rejected_result(
(
f"申请单 {application_claim.claim_no} 已经关联报销单 "
f"{existing_reimbursement.claim_no}"
"请进入该草稿或单据继续补充,不能重复生成。"
),
application_claim=application_claim,
existing_claim=existing_reimbursement,
)
return None
def _find_application_claim_for_link(
self,
link_flag: dict[str, Any],
) -> ExpenseClaim | None:
application_claim_id = str(
link_flag.get("application_claim_id") or ""
).strip()
application_claim_no = str(
link_flag.get("application_claim_no") or ""
).strip()
if application_claim_id:
claim = self.db.get(ExpenseClaim, application_claim_id)
if claim is not None and self._is_expense_application_claim(claim):
return claim
if application_claim_no:
return self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == application_claim_no)
.limit(1)
)
return None
def _find_existing_reimbursement_for_application_link(
self,
*,
application_claim: ExpenseClaim,
link_flag: dict[str, Any],
target_claim: ExpenseClaim | None,
) -> ExpenseClaim | None:
generated_draft = self._find_generated_reimbursement_from_application(
application_claim=application_claim,
target_claim=target_claim,
)
if generated_draft is not None:
return generated_draft
linked_ids, linked_nos = self._collect_application_link_reference_values(
link_flag
)
linked_ids.add(str(application_claim.id or "").strip())
linked_nos.add(str(application_claim.claim_no or "").strip().upper())
linked_ids.discard("")
linked_nos.discard("")
for claim in list(self.db.scalars(select(ExpenseClaim)).all()):
if self._is_same_target_claim(claim, target_claim):
continue
if self._is_expense_application_claim(claim):
continue
if self._is_inactive_application_link_reimbursement(claim):
continue
if self._claim_references_application(
claim,
linked_ids=linked_ids,
linked_nos=linked_nos,
):
return claim
return None
def _find_generated_reimbursement_from_application(
self,
*,
application_claim: ExpenseClaim,
target_claim: ExpenseClaim | None,
) -> ExpenseClaim | None:
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
generated_draft_id = str(
flag.get("generated_draft_claim_id")
or flag.get("generatedDraftClaimId")
or ""
).strip()
generated_draft_no = str(
flag.get("generated_draft_claim_no")
or flag.get("generatedDraftClaimNo")
or ""
).strip()
claim = (
self.db.get(ExpenseClaim, generated_draft_id)
if generated_draft_id
else None
)
if claim is None and generated_draft_no:
claim = self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == generated_draft_no)
.limit(1)
)
if claim is None:
continue
if self._is_same_target_claim(claim, target_claim):
continue
if self._is_expense_application_claim(claim):
continue
if self._is_inactive_application_link_reimbursement(claim):
continue
return claim
return None
@staticmethod
def _is_same_target_claim(
claim: ExpenseClaim,
target_claim: ExpenseClaim | None,
) -> bool:
return bool(target_claim is not None and claim.id == target_claim.id)
@staticmethod
def _is_inactive_application_link_reimbursement(claim: ExpenseClaim) -> bool:
status = str(claim.status or "").strip().lower()
return status in INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES
@classmethod
def _claim_references_application(
cls,
claim: ExpenseClaim,
*,
linked_ids: set[str],
linked_nos: set[str],
) -> bool:
for flag in list(claim.risk_flags_json or []):
flag_ids, flag_nos = cls._collect_application_link_reference_values(flag)
if flag_ids.intersection(linked_ids) or flag_nos.intersection(linked_nos):
return True
return False
@classmethod
def _collect_application_link_reference_values(
cls,
payload: Any,
) -> tuple[set[str], set[str]]:
ids: set[str] = set()
claim_nos: set[str] = set()
if not isinstance(payload, dict):
return ids, claim_nos
cls._add_application_link_reference(ids, claim_nos, payload)
for key in (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
):
nested_ids, nested_nos = cls._collect_application_link_reference_values(
payload.get(key)
)
ids.update(nested_ids)
claim_nos.update(nested_nos)
ids.discard("")
claim_nos.discard("")
return ids, claim_nos
@staticmethod
def _add_application_link_reference(
ids: set[str],
claim_nos: set[str],
payload: dict[str, Any],
) -> None:
for key in ("application_claim_id", "applicationClaimId"):
ids.add(str(payload.get(key) or "").strip())
for key in ("application_claim_no", "applicationClaimNo"):
claim_nos.add(str(payload.get(key) or "").strip().upper())
@staticmethod
def _build_application_link_rejected_result(
message: str,
*,
application_claim: ExpenseClaim | None = None,
existing_claim: ExpenseClaim | None = None,
) -> dict[str, Any]:
result: dict[str, Any] = {
"message": message,
"draft_only": False,
"status": "blocked",
"application_link_blocked": True,
"submission_blocked": True,
"submission_blocked_reasons": [message],
"missing_fields": [message],
"risk_flags": ["application_link_blocked"],
}
if application_claim is not None:
result["application_claim_id"] = application_claim.id
result["application_claim_no"] = application_claim.claim_no
result["application_status"] = application_claim.status
if existing_claim is not None:
result["existing_claim_id"] = existing_claim.id
result["existing_claim_no"] = existing_claim.claim_no
result["existing_claim_status"] = existing_claim.status
return result
@staticmethod
def _build_application_link_flag(
context_json: dict[str, Any],
) -> dict[str, Any] | None:
review_values = ExpenseClaimApplicationLinkMixin._normalize_context_object(
context_json.get("review_form_values")
)
scene_selection = ExpenseClaimApplicationLinkMixin._normalize_context_object(
context_json.get("expense_scene_selection")
)
def pick(*keys: str) -> str:
for source in (review_values, scene_selection, context_json):
for key in keys:
value = str(source.get(key) or "").strip()
if value:
return value
return ""
application_claim_no = pick("application_claim_no", "applicationClaimNo")
if not application_claim_no:
return None
application_claim_id = pick("application_claim_id", "applicationClaimId")
application_amount = pick("application_amount", "applicationAmount")
application_amount_label = pick(
"application_amount_label", "applicationAmountLabel"
)
application_reason = pick(
"application_reason", "applicationReason", "reason"
)
application_location = pick(
"application_location", "applicationLocation", "location"
)
application_time = pick(
"application_business_time",
"applicationBusinessTime",
"application_time",
"applicationTime",
"business_time",
"businessTime",
"time_range",
"timeRange",
"time",
)
application_date = pick("application_date", "applicationDate")
application_days = pick("application_days", "applicationDays", "days")
application_transport_mode = pick(
"application_transport_mode",
"applicationTransportMode",
"transport_mode",
"transportMode",
)
application_lodging_daily_cap = pick(
"application_lodging_daily_cap",
"applicationLodgingDailyCap",
"lodging_daily_cap",
"lodgingDailyCap",
)
application_subsidy_daily_cap = pick(
"application_subsidy_daily_cap",
"applicationSubsidyDailyCap",
"subsidy_daily_cap",
"subsidyDailyCap",
)
application_transport_policy = pick(
"application_transport_policy",
"applicationTransportPolicy",
"transport_policy",
"transportPolicy",
)
application_policy_estimate = pick(
"application_policy_estimate",
"applicationPolicyEstimate",
"policy_estimate",
"policyEstimate",
)
application_rule_name = pick(
"application_rule_name",
"applicationRuleName",
"rule_name",
"ruleName",
)
application_rule_version = pick(
"application_rule_version",
"applicationRuleVersion",
"rule_version",
"ruleVersion",
)
application_status = pick("application_status", "applicationStatus")
application_status_label = pick(
"application_status_label", "applicationStatusLabel"
)
return {
"source": "application_link",
"event_type": "expense_reimbursement_application_linked",
"severity": "info",
"label": "关联申请单",
"message": f"报销草稿已关联申请单 {application_claim_no}",
"application_claim_id": application_claim_id,
"application_claim_no": application_claim_no,
"application_amount_label": application_amount_label,
"application_status": application_status,
"application_status_label": application_status_label,
"application_detail": {
"application_reason": application_reason,
"application_location": application_location,
"application_amount": application_amount,
"application_amount_label": application_amount_label,
"application_time": application_time or application_date,
"application_business_time": application_time,
"application_date": application_date,
"application_days": application_days,
"application_transport_mode": application_transport_mode,
"application_lodging_daily_cap": application_lodging_daily_cap,
"application_subsidy_daily_cap": application_subsidy_daily_cap,
"application_transport_policy": application_transport_policy,
"application_policy_estimate": application_policy_estimate,
"application_rule_name": application_rule_name,
"application_rule_version": application_rule_version,
},
"review_form_values": review_values,
"expense_scene_selection": scene_selection,
"created_at": datetime.now(UTC).isoformat(),
}
@staticmethod
def _normalize_context_object(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}

View File

@@ -296,6 +296,7 @@ class ExpenseClaimApprovalFlowMixin:
},
expense_case=expense_case,
relation_type="generated_reimbursement",
update_case_state=False,
)
self.db.commit()

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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 ""),

View File

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

View File

@@ -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,

View File

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

View File

@@ -0,0 +1,394 @@
from __future__ import annotations
import hashlib
import json
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
PRE_REVIEW_PIPELINE_VERSION = "2026-07-16.1"
PRE_REVIEW_DECISIONS = {"ready", "needs_fix", "ready_with_review"}
_DERIVED_RISK_SOURCES = {
"ai_pre_review",
"application_submission",
"approval",
"approval_log",
"approval_routing",
"budget_approval",
"expense_claim_approval",
"expense_claim_finance_approval",
"finance_approval",
"manual_approval",
"payment",
"submission_review",
}
def build_pre_review_input_fingerprint(claim: ExpenseClaim) -> str:
payload = {
"claim": {
"id": _text(claim.id),
"employee_id": _text(claim.employee_id),
"employee_name": _text(claim.employee_name),
"department_id": _text(claim.department_id),
"department_name": _text(claim.department_name),
"project_code": _text(claim.project_code),
"expense_type": _text(claim.expense_type),
"reason": _text(claim.reason),
"location": _text(claim.location),
"amount": _money(claim.amount),
"currency": _text(claim.currency),
"occurred_at": _date_time(claim.occurred_at),
},
"items": [
{
"id": _text(item.id),
"item_date": _date_time(item.item_date),
"item_type": _text(item.item_type),
"item_reason": _text(item.item_reason),
"item_location": _text(item.item_location),
"item_note": _text(item.item_note),
"item_amount": _money(item.item_amount),
"invoice_id": _text(item.invoice_id),
}
for item in sorted(list(claim.items or []), key=lambda entry: _text(entry.id))
],
"risk_inputs": sorted(
[
_strip_volatile_fields(flag)
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict)
and _text(flag.get("source")).lower() not in _DERIVED_RISK_SOURCES
],
key=_canonical_json,
),
}
return _fingerprint(payload)
def build_pre_review_rule_set_fingerprint(platform_rule_set_fingerprint: str) -> str:
return _fingerprint(
{
"pipeline_version": PRE_REVIEW_PIPELINE_VERSION,
"platform_rule_set_fingerprint": _text(platform_rule_set_fingerprint),
}
)
def build_pre_review_decision(
claim: ExpenseClaim,
*,
risk_flags: list[Any],
business_stage: str,
platform_rule_set_fingerprint: str,
reviewed_at: datetime,
) -> dict[str, Any]:
input_fingerprint = build_pre_review_input_fingerprint(claim)
rule_set_fingerprint = build_pre_review_rule_set_fingerprint(
platform_rule_set_fingerprint
)
findings = _build_findings(risk_flags, business_stage=business_stage)
review_context_fingerprint = _fingerprint(findings)
blocking_findings = [
finding
for finding in findings
if finding["severity"] in {"critical", "high"}
and finding["disposition"] == "fix"
and finding["resolution_status"] == "unresolved"
]
if blocking_findings:
decision = "needs_fix"
message = (
f"自动检测发现 {len(blocking_findings)} 条需先整改的重大风险,"
"请按建议处理后重新预审。"
)
elif findings:
decision = "ready_with_review"
message = "自动检测已完成,当前风险可随单进入审批并由对应角色复核。"
else:
decision = "ready"
message = "自动检测通过,费用明细和附件可提交审批。"
review_id = str(
uuid.uuid5(
uuid.NAMESPACE_URL,
":".join(
[
"expense-claim-pre-review",
_text(claim.id),
input_fingerprint,
rule_set_fingerprint,
review_context_fingerprint,
]
),
)
)
return {
"review_id": review_id,
"input_fingerprint": input_fingerprint,
"rule_set_fingerprint": rule_set_fingerprint,
"review_context_fingerprint": review_context_fingerprint,
"pipeline_version": PRE_REVIEW_PIPELINE_VERSION,
"reviewed_at": reviewed_at.isoformat(),
"decision": decision,
"passed": decision != "needs_fix",
"blocking_count": len(blocking_findings),
"findings": findings,
"message": message,
}
def find_pre_review_flag(claim: ExpenseClaim) -> dict[str, Any] | None:
for flag in reversed(list(claim.risk_flags_json or [])):
if isinstance(flag, dict) and _text(flag.get("source")) == "ai_pre_review":
return flag
return None
def is_pre_review_current(
claim: ExpenseClaim,
flag: dict[str, Any] | None,
*,
platform_rule_set_fingerprint: str,
) -> bool:
if not isinstance(flag, dict):
return False
return bool(
_text(flag.get("review_id"))
and _text(flag.get("input_fingerprint"))
== build_pre_review_input_fingerprint(claim)
and _text(flag.get("rule_set_fingerprint"))
== build_pre_review_rule_set_fingerprint(platform_rule_set_fingerprint)
and _text(flag.get("decision")) in PRE_REVIEW_DECISIONS
)
def pre_review_identity_matches(
flag: dict[str, Any] | None,
*,
review_id: str,
input_fingerprint: str,
) -> bool:
if not isinstance(flag, dict):
return False
normalized_review_id = _text(review_id)
normalized_input_fingerprint = _text(input_fingerprint)
if normalized_review_id and _text(flag.get("review_id")) != normalized_review_id:
return False
if (
normalized_input_fingerprint
and _text(flag.get("input_fingerprint")) != normalized_input_fingerprint
):
return False
return True
def pre_review_public_payload(flag: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(flag, dict) or not _text(flag.get("review_id")):
return None
return {
"review_id": _text(flag.get("review_id")),
"input_fingerprint": _text(flag.get("input_fingerprint")),
"rule_set_fingerprint": _text(flag.get("rule_set_fingerprint")),
"review_context_fingerprint": _text(
flag.get("review_context_fingerprint")
),
"pipeline_version": _text(flag.get("pipeline_version")),
"reviewed_at": _text(flag.get("reviewed_at") or flag.get("created_at")),
"decision": _text(flag.get("decision")) or "ready_with_review",
"passed": bool(flag.get("passed")),
"blocking_count": int(
flag.get("blocking_count") or flag.get("blocking_risk_count") or 0
),
"message": _text(flag.get("message")),
"findings": [
dict(item)
for item in list(flag.get("findings") or [])
if isinstance(item, dict)
],
}
def _build_findings(
risk_flags: list[Any],
*,
business_stage: str,
) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
for flag in list(risk_flags or []):
if not isinstance(flag, dict) or _text(flag.get("source")) == "ai_pre_review":
continue
enriched = enrich_risk_flag_semantics(flag, business_stage=business_stage)
severity = _text(
enriched.get("severity") or enriched.get("tone") or enriched.get("level")
).lower()
if severity not in {"medium", "high", "critical"}:
continue
actionability = _text(enriched.get("actionability")).lower()
if actionability == "system_trace":
continue
item_ids = _item_ids(enriched)
resolution_status = _resolution_status(enriched)
disposition = "fix" if actionability == "fixable_by_submitter" else "review"
message = _text(
enriched.get("message")
or enriched.get("summary")
or enriched.get("reason")
or enriched.get("label")
)
risk_key = {
"source": _text(enriched.get("source")),
"rule_code": _text(enriched.get("rule_code")),
"severity": severity,
"item_ids": item_ids,
"message": message,
}
findings.append(
{
"risk_id": _text(enriched.get("risk_id"))
or f"risk:{_fingerprint(risk_key).removeprefix('sha256:')[:24]}",
"rule_code": _text(enriched.get("rule_code")),
"rule_version": _text(enriched.get("rule_version")),
"severity": severity,
"disposition": disposition,
"resolution_status": resolution_status,
"actionability": actionability,
"source": _text(enriched.get("source")) or "pre_review_finding",
"business_stage": _text(enriched.get("business_stage"))
or business_stage,
"risk_domain": _text(
enriched.get("risk_domain") or enriched.get("riskDomain")
),
"visibility_scope": _text(
enriched.get("visibility_scope")
or enriched.get("visibilityScope")
),
"item_ids": item_ids,
"message": message,
"remediation": _build_remediation(
disposition=disposition,
item_ids=item_ids,
flag=enriched,
),
}
)
return sorted(
findings,
key=lambda finding: (
{"critical": 0, "high": 1, "medium": 2}.get(finding["severity"], 9),
finding["risk_id"],
),
)[:50]
def _build_remediation(
*,
disposition: str,
item_ids: list[str],
flag: dict[str, Any],
) -> dict[str, Any]:
if disposition != "fix":
return {
"action": "manual_review",
"target_item_ids": item_ids,
"required_fields": [],
}
corpus = " ".join(
_text(flag.get(key))
for key in ("label", "message", "summary", "rule_code")
)
remediation = {
"action": "edit_item_note" if item_ids else "edit_claim",
"target_item_ids": item_ids,
"required_fields": ["item_note"] if item_ids else [],
}
if any(token in corpus for token in ("超标", "标准", "住宿", "金额")):
remediation["alternative_action"] = "accept_standard_limit"
return remediation
def _resolution_status(flag: dict[str, Any]) -> str:
explicit = _text(flag.get("resolution_status") or flag.get("resolutionStatus")).lower()
if explicit in {"resolved", "accepted", "waived"}:
return "resolved"
if explicit in {"unresolved", "open", "pending"}:
return "unresolved"
return "resolved" if bool(flag.get("resolved")) else "unresolved"
def _item_ids(flag: dict[str, Any]) -> list[str]:
raw_values = [
flag.get("item_id"),
flag.get("itemId"),
*_as_list(flag.get("item_ids")),
*_as_list(flag.get("itemIds")),
]
return sorted(
dict.fromkeys(_text(value) for value in raw_values if _text(value))
)
def _as_list(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
if isinstance(value, (tuple, set)):
return list(value)
return [value]
def _strip_volatile_fields(value: Any) -> Any:
if isinstance(value, list):
return sorted(
[_strip_volatile_fields(item) for item in value],
key=_canonical_json,
)
if not isinstance(value, dict):
return value
volatile_keys = {
"created_at",
"updated_at",
"review_id",
"input_fingerprint",
"rule_set_fingerprint",
"reviewed_at",
}
return {
str(key): _strip_volatile_fields(item)
for key, item in value.items()
if str(key) not in volatile_keys
}
def _fingerprint(value: Any) -> str:
canonical = _canonical_json(value)
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
def _canonical_json(value: Any) -> str:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
def _date_time(value: Any) -> str:
if hasattr(value, "isoformat"):
return str(value.isoformat())
return _text(value)
def _money(value: Any) -> str:
return f"{Decimal(value or Decimal('0.00')).quantize(Decimal('0.01')):.2f}"
def _text(value: Any) -> str:
return str(value or "").strip()

View File

@@ -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:

View File

@@ -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 []))

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import hashlib
import json
from typing import Any
def build_risk_manifest_fingerprint(manifests: list[dict[str, Any]]) -> str:
ordered_manifests = sorted(
manifests,
key=lambda manifest: json.dumps(
manifest,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
),
)
normalized = json.dumps(
ordered_manifests,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
return f"sha256:{digest}"

View File

@@ -0,0 +1,297 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.reimbursement import (
ExpenseClaimStandardAdjustmentPayload,
TravelReimbursementCalculatorRequest,
)
from app.services.expense_claim_constants import STANDARD_ADJUSTMENT_RISK_SOURCE
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_stage import with_risk_business_stage
class ExpenseClaimStandardAdjustmentMixin:
@staticmethod
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
@staticmethod
def _format_adjustment_money(value: Decimal) -> str:
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
return f"{normalized:.2f}"
@staticmethod
def _normalize_standard_adjustment_days(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value if 1 <= value <= 365 else None
text = str(value or "").strip()
if not text:
return None
match = re.search(r"\d{1,3}", text)
if not match:
return None
days = int(match.group(0))
return days if 1 <= days <= 365 else None
@staticmethod
def _normalize_standard_adjustment_text(value: Any) -> str:
text = str(value or "").strip()
if not text or text in {"-", "N/A", "n/a"}:
return ""
if text in {"待补充", "未知", "暂无", "非必填"}:
return ""
return text
def _iter_standard_adjustment_application_details(
self,
claim: ExpenseClaim,
) -> list[dict[str, Any]]:
details: list[dict[str, Any]] = []
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
detail = flag.get("application_detail") or flag.get("applicationDetail")
if isinstance(detail, dict):
details.append(detail)
related = flag.get("related_application") or flag.get("relatedApplication")
if isinstance(related, dict):
details.append(related)
return details
def _resolve_standard_adjustment_days(
self,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
) -> int:
direct_days = self._normalize_standard_adjustment_days(
getattr(entry, "application_days", None)
)
if direct_days is not None:
return direct_days
for detail in self._iter_standard_adjustment_application_details(claim):
for key in ("application_days", "applicationDays", "days"):
detail_days = self._normalize_standard_adjustment_days(detail.get(key))
if detail_days is not None:
return detail_days
candidates = [
getattr(entry, "risk", None),
getattr(entry, "title", None),
item.item_reason,
claim.reason,
]
for text in candidates:
match = re.search(r"(\d{1,3})\s*(?:天|晚|夜)", str(text or ""))
if match:
days = self._normalize_standard_adjustment_days(match.group(1))
if days is not None:
return days
return 1
def _resolve_standard_adjustment_location(
self,
claim: ExpenseClaim,
item: ExpenseClaimItem,
) -> str:
for value in (item.item_location, claim.location):
text = self._normalize_standard_adjustment_text(value)
if text:
return text
for detail in self._iter_standard_adjustment_application_details(claim):
for key in ("application_location", "applicationLocation", "location", "city"):
text = self._normalize_standard_adjustment_text(detail.get(key))
if text:
return text
return ""
def _resolve_policy_standard_reimbursable_amount(
self,
*,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
current_user: CurrentUserContext,
) -> Decimal | None:
item_type = str(item.item_type or "").strip().lower()
if item_type not in {"hotel", "hotel_ticket"}:
return None
location = self._resolve_standard_adjustment_location(claim, item)
grade = str(claim.employee_grade or current_user.grade or "").strip()
if not location or not grade:
return None
try:
from app.services.travel_reimbursement_calculator import (
TravelReimbursementCalculatorService,
)
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
days=self._resolve_standard_adjustment_days(claim, item, entry),
location=location,
grade=grade,
),
current_user,
)
except Exception:
return None
return self._normalize_standard_adjustment_amount(result.hotel_amount)
def _resolve_standard_adjustment_reimbursable_amount(
self,
*,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
original_amount: Decimal,
current_user: CurrentUserContext,
) -> Decimal:
policy_amount = self._resolve_policy_standard_reimbursable_amount(
claim=claim,
item=item,
entry=entry,
current_user=current_user,
)
if policy_amount is not None:
return min(max(policy_amount, Decimal("0.00")), original_amount)
entry_amount = self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
if entry_amount is not None:
return min(max(entry_amount, Decimal("0.00")), original_amount)
return original_amount
def accept_standard_adjustment(
self,
*,
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
if self._is_expense_application_claim(claim):
raise ValueError("费用申请单不支持按报销标准重算。")
risk_entries = list(payload.risks or [])
if not risk_entries:
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
before_json = self._serialize_claim(claim)
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
now_text = datetime.now(UTC).isoformat()
adjustment_flags: list[dict[str, Any]] = []
for index, entry in enumerate(risk_entries, start=1):
item_id = str(entry.item_id or "").strip()
item = item_map.get(item_id)
if item is None:
continue
original_amount = (
self._normalize_standard_adjustment_amount(entry.original_amount)
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
)
reimbursable_amount = self._resolve_standard_adjustment_reimbursable_amount(
claim=claim,
item=item,
entry=entry,
original_amount=original_amount,
current_user=current_user,
)
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(
Decimal("0.01")
)
item_label = (
str(item.item_reason or "").strip()
or str(entry.title or "").strip()
or f"费用明细第 {index}"
)
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
message = (
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
)
adjustment_flags.append(
with_risk_business_stage(
{
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
"event_type": "standard_adjustment_accepted",
"severity": "medium",
"label": "接受职级标准审核",
"title": "提交人接受职级最高报销标准",
"message": message,
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
"risk_id": str(entry.risk_id or "").strip(),
"source_risk": source_risk,
"item_id": item_id,
"original_amount": self._format_adjustment_money(original_amount),
"reimbursable_amount": self._format_adjustment_money(
reimbursable_amount
),
"employee_absorbed_amount": self._format_adjustment_money(
employee_absorbed_amount
),
"risk_domain": "amount",
"actionability": "review_decision",
"visibility_scope": "leader",
"created_at": now_text,
},
"reimbursement",
)
)
if not adjustment_flags:
raise ValueError("未找到可按职级标准重算的费用明细。")
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip()
== STANDARD_ADJUSTMENT_RISK_SOURCE
)
]
claim.risk_flags_json = dedupe_claim_risk_flags(
[*preserved_flags, *adjustment_flags]
)
self._sync_claim_from_items(claim)
self.refresh_claim_pre_review_state(claim, is_application_claim=False)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.standard_adjustment_accept",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import or_, select
from app.api.deps import CurrentUserContext
from app.models.expense_case import ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
DEFAULT_TENANT_ID = "default"
class ExpenseClaimTenantScopeMixin:
@staticmethod
def normalize_tenant_id(value: str | None) -> str:
return str(value or DEFAULT_TENANT_ID).strip() or DEFAULT_TENANT_ID
@classmethod
def normalize_context_tenant_id(cls, context_json: dict[str, Any] | None) -> str:
context = context_json or {}
return cls.normalize_tenant_id(
context.get("tenant_id") or context.get("tenantId")
)
@classmethod
def build_claim_tenant_condition(cls, tenant_id: str | None) -> Any:
"""按 Expense Case Link 隔离 Claim默认租户兼容尚未回填的旧单。"""
normalized_tenant = cls.normalize_tenant_id(tenant_id)
same_tenant_link = (
select(ExpenseCaseLink.id)
.where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == ExpenseClaim.id,
ExpenseCaseLink.tenant_id == normalized_tenant,
)
.exists()
)
if normalized_tenant != DEFAULT_TENANT_ID:
return same_tenant_link
any_tenant_link = (
select(ExpenseCaseLink.id)
.where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == ExpenseClaim.id,
)
.exists()
)
return or_(same_tenant_link, ~any_tenant_link)
@classmethod
def resolve_claim_tenant_id(cls, db: Any, claim_id: str | None) -> str:
"""从 Case Link 解析 Claim 租户;无 Link 的历史单仍归 default。"""
normalized_claim_id = str(claim_id or "").strip()
if not normalized_claim_id:
return DEFAULT_TENANT_ID
tenant_id = db.scalar(
select(ExpenseCaseLink.tenant_id).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == normalized_claim_id,
)
)
return cls.normalize_tenant_id(tenant_id)
def apply_tenant_scope(
self,
stmt: Any,
current_user: CurrentUserContext,
) -> Any:
return stmt.where(self.build_claim_tenant_condition(current_user.tenant_id))

View File

@@ -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:

View File

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

View File

@@ -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:

View File

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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"

View File

@@ -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(

View File

@@ -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(

View File

@@ -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")

View File

@@ -0,0 +1,196 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.expense_claim_pre_review_decision import (
build_pre_review_decision,
pre_review_identity_matches,
)
def build_claim(*, risk_flags: list[dict] | None = None) -> ExpenseClaim:
claim = ExpenseClaim(
id="claim-pre-review-decision",
claim_no="RE-PRE-REVIEW-DECISION",
employee_id="employee-pre-review",
employee_name="张三",
department_id="department-pre-review",
department_name="市场部",
project_code="PRJ-PRE-REVIEW",
expense_type="travel",
reason="客户现场差旅",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=list(risk_flags or []),
)
claim.items = [
ExpenseClaimItem(
id="item-pre-review-decision",
claim_id=claim.id,
item_date=date(2026, 7, 16),
item_type="travel",
item_reason="客户现场交通",
item_location="上海",
item_note="",
item_amount=Decimal("88.00"),
invoice_id="receipt.png",
)
]
return claim
def test_pre_review_identity_is_stable_when_risk_input_order_changes() -> None:
risk_flags = [
{"source": "manual_a", "severity": "low", "message": "A"},
{"source": "manual_b", "severity": "medium", "message": "B"},
]
first = build_pre_review_decision(
build_claim(risk_flags=risk_flags),
risk_flags=risk_flags,
business_stage="reimbursement",
platform_rule_set_fingerprint="rules-v1",
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
)
repeated = build_pre_review_decision(
build_claim(risk_flags=list(reversed(risk_flags))),
risk_flags=list(reversed(risk_flags)),
business_stage="reimbursement",
platform_rule_set_fingerprint="rules-v1",
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
)
assert repeated["review_id"] == first["review_id"]
assert repeated["input_fingerprint"] == first["input_fingerprint"]
def test_pre_review_identity_is_stable_when_nested_risk_ids_change_order() -> None:
first_flags = [
{
"source": "manual_risk",
"severity": "medium",
"message": "多条明细需复核。",
"item_ids": ["item-b", "item-a"],
"basic_rule_refs": ["rule-b", "rule-a"],
}
]
repeated_flags = [
{
**first_flags[0],
"item_ids": ["item-a", "item-b"],
"basic_rule_refs": ["rule-a", "rule-b"],
}
]
first = build_pre_review_decision(
build_claim(risk_flags=first_flags),
risk_flags=first_flags,
business_stage="reimbursement",
platform_rule_set_fingerprint="rules-v1",
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
)
repeated = build_pre_review_decision(
build_claim(risk_flags=repeated_flags),
risk_flags=repeated_flags,
business_stage="reimbursement",
platform_rule_set_fingerprint="rules-v1",
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
)
assert repeated["review_id"] == first["review_id"]
assert repeated["review_context_fingerprint"] == first[
"review_context_fingerprint"
]
def test_pre_review_identity_changes_when_dynamic_findings_change() -> None:
claim = build_claim()
ready = build_pre_review_decision(
claim,
risk_flags=[],
business_stage="reimbursement",
platform_rule_set_fingerprint="rules-v1",
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
)
changed = build_pre_review_decision(
claim,
risk_flags=[
{
"source": "submission_review",
"severity": "high",
"actionability": "fixable_by_submitter",
"message": "预审后发现同一发票已被其他单据使用。",
}
],
business_stage="reimbursement",
platform_rule_set_fingerprint="rules-v1",
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
)
assert changed["input_fingerprint"] == ready["input_fingerprint"]
assert changed["review_context_fingerprint"] != ready[
"review_context_fingerprint"
]
assert changed["review_id"] != ready["review_id"]
assert changed["decision"] == "needs_fix"
def test_pre_review_blocks_high_fixable_risk_and_normalizes_string_item_id() -> None:
claim = build_claim()
decision = build_pre_review_decision(
claim,
risk_flags=[
{
"source": "manual_risk",
"severity": "high",
"actionability": "fixable_by_submitter",
"message": "住宿金额超过标准,请修正。",
"item_ids": "item-pre-review-decision",
}
],
business_stage="reimbursement",
platform_rule_set_fingerprint="rules-v1",
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
)
assert decision["decision"] == "needs_fix"
assert decision["blocking_count"] == 1
assert decision["findings"][0]["item_ids"] == ["item-pre-review-decision"]
assert (
decision["findings"][0]["remediation"]["alternative_action"]
== "accept_standard_limit"
)
def test_pre_review_routes_budget_governance_risk_to_approval_instead_of_blocking() -> None:
claim = build_claim()
decision = build_pre_review_decision(
claim,
risk_flags=[
{
"source": "budget_control",
"severity": "critical",
"risk_domain": "budget",
"actionability": "budget_governance",
"message": "预算使用率超过治理阈值。",
}
],
business_stage="reimbursement",
platform_rule_set_fingerprint="rules-v1",
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
)
assert decision["decision"] == "ready_with_review"
assert decision["blocking_count"] == 0
assert decision["findings"][0]["disposition"] == "review"
assert pre_review_identity_matches(
{**decision},
review_id=decision["review_id"],
input_fingerprint=decision["input_fingerprint"],
)

View File

@@ -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:

View File

@@ -0,0 +1,318 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from types import SimpleNamespace
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.budget import BudgetAllocation
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.schemas.ontology import OntologyParseResult
from app.schemas.steward import StewardActionExecuteRequest, StewardTask
from app.schemas.user_agent import UserAgentRequest
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.steward_action_executor import StewardActionExecutor
from app.services.travel_reimbursement_calculator import (
TravelReimbursementCalculatorService,
)
from app.services.user_agent import UserAgentService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
def test_ontology_draft_event_uses_context_tenant() -> None:
with build_session() as db:
employee = Employee(
employee_no="TENANT-DRAFT-EMPLOYEE",
name="租户草稿员工",
email="tenant-draft@example.com",
)
db.add(employee)
db.commit()
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id="tenant-draft-run",
user_id=employee.email,
message="2026-07-16 在上海拜访客户,交通费 32 元,保存草稿",
ontology=OntologyParseResult(run_id="tenant-draft-run"),
context_json={
"tenant_id": "tenant-expense-a",
"name": employee.name,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "交通费",
"occurred_date": "2026-07-16",
"location": "上海",
"reason": "客户拜访",
"amount": "32元",
},
},
)
event = db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == result["claim_id"])
)
link = db.scalar(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == result["claim_id"])
)
assert event is not None
assert event.tenant_id == "tenant-expense-a"
assert link is not None
assert link.tenant_id == "tenant-expense-a"
class ReviewSubmitTenantProbe(ExpenseClaimReviewPreviewMixin):
def __init__(self) -> None:
self.submission_user: CurrentUserContext | None = None
def upsert_draft_from_ontology(self, **_kwargs):
return {"claim_id": "tenant-review-claim", "draft_only": True}
def submit_claim(self, _claim_id: str, current_user: CurrentUserContext):
self.submission_user = current_user
return SimpleNamespace(
id="tenant-review-claim",
claim_no="BX-TENANT-REVIEW",
status="submitted",
approval_stage="直属领导审批",
amount=Decimal("32.00"),
invoice_count=1,
risk_flags_json=[],
)
def test_review_next_step_uses_context_tenant() -> None:
probe = ReviewSubmitTenantProbe()
result = probe.save_or_submit_from_ontology(
run_id="tenant-review-run",
user_id="tenant-review@example.com",
message="确认提交",
ontology=OntologyParseResult(run_id="tenant-review-run"),
context_json={
"review_action": "next_step",
"tenant_id": "tenant-expense-b",
"name": "租户预览员工",
},
)
assert result["status"] == "submitted"
assert probe.submission_user is not None
assert probe.submission_user.tenant_id == "tenant-expense-b"
def test_review_calculators_use_context_tenant(monkeypatch) -> None:
captured_tenants: list[str] = []
def fake_calculate(_self, _payload, current_user):
captured_tenants.append(current_user.tenant_id)
return SimpleNamespace(
grade="P4",
days=2,
matched_city="上海",
hotel_rate=Decimal("300.00"),
hotel_amount=Decimal("600.00"),
total_allowance_rate=Decimal("100.00"),
allowance_amount=Decimal("200.00"),
)
monkeypatch.setattr(TravelReimbursementCalculatorService, "calculate", fake_calculate)
with build_session() as db:
expense_service = ExpenseClaimService(db)
expense_service._build_expense_review_preview_calculation_copy(
user_id="tenant-calculator@example.com",
message="2026-07-16 至 2026-07-17 去上海出差,交通费 32 元",
ontology=OntologyParseResult(run_id="tenant-calculator-preview"),
context_json={
"tenant_id": "tenant-calculator",
"employee_grade": "P4",
"location": "上海",
"occurred_date": "2026-07-16",
"amount": "32元",
},
)
payload = UserAgentRequest(
run_id="tenant-calculator-message",
user_id="tenant-calculator@example.com",
message="上海出差",
ontology=OntologyParseResult(run_id="tenant-calculator-message"),
context_json={
"tenant_id": "tenant-calculator",
"grade": "P4",
"name": "租户测算员工",
},
)
UserAgentService(db)._build_travel_receipt_estimate_copy(
payload,
travel_receipt_state={
"destination": "上海",
"days": 2,
"ticket_type_label": "火车票",
"ticket_amount": "32",
},
)
assert captured_tenants == ["tenant-calculator", "tenant-calculator"]
def test_steward_reimbursement_context_uses_current_tenant() -> None:
task = StewardTask(
task_id="tenant-steward-task",
task_type="reimbursement",
assigned_agent="reimbursement_assistant",
title="租户报销",
summary="上海客户拜访交通费 32 元",
requested_action="save_draft",
ontology_fields={
"expense_type": "transport",
"time_range": "2026-07-16",
"location": "上海",
"reason": "客户拜访",
"amount": "32元",
},
missing_fields=[],
confirmation_required=False,
)
request = StewardActionExecuteRequest(
action_type="create_reimbursement_draft",
message="保存报销草稿",
task=task,
)
current_user = CurrentUserContext(
username="tenant-steward@example.com",
name="租户小财管家用户",
role_codes=["user"],
is_admin=False,
tenant_id="tenant-steward",
)
with build_session() as db:
context = StewardActionExecutor(db)._build_reimbursement_context_json(
request,
current_user,
)
assert context["tenant_id"] == "tenant-steward"
def test_application_approval_keeps_case_at_approved_to_spend() -> None:
with build_session() as db:
department = OrganizationUnit(
unit_code="TENANT-CASE-TRAVEL",
name="租户差旅部",
unit_type="department",
)
manager = Employee(
employee_no="TENANT-CASE-MANAGER",
name="租户差旅经理",
email="tenant-case-manager@example.com",
organization_unit=department,
)
employee = Employee(
employee_no="TENANT-CASE-EMPLOYEE",
name="租户差旅员工",
email="tenant-case-employee@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
db.add(
BudgetAllocation(
budget_no="BUD-TENANT-CASE-TRAVEL",
fiscal_year=2026,
period_type="year",
period_key="2026",
department_id=department.id,
department_name=department.name,
cost_center=None,
project_code=None,
subject_code="travel",
subject_name="差旅费",
original_amount=Decimal("50000.00"),
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="block",
)
)
application = ExpenseClaim(
claim_no="AP-TENANT-CASE-GENERATE",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-TENANT-CASE",
expense_type="travel_application",
reason="客户现场差旅",
location="上海",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[],
)
db.add(application)
db.flush()
ExpenseCaseService(db).ensure_case_for_claim(
application,
tenant_id="tenant-case-stage",
)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
application.id,
CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
tenant_id="tenant-case-stage",
),
opinion="业务必要,同意申请",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
expense_case = db.scalar(select(ExpenseCase))
assert expense_case is not None
assert expense_case.tenant_id == "tenant-case-stage"
assert expense_case.current_stage == "approved_to_spend"
events = list(
db.scalars(
select(BusinessEvent)
.where(BusinessEvent.expense_case_id == expense_case.id)
.order_by(BusinessEvent.occurred_at)
).all()
)
assert [event.event_type for event in events] == [
"application_approved",
"reimbursement_draft_generated",
]
assert {event.tenant_id for event in events} == {"tenant-case-stage"}

View File

@@ -0,0 +1,289 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from app.api.deps import CurrentUserContext
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import ExpenseClaimUpdate
from app.services.budget import BudgetService
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claims import ExpenseClaimService
from app.test_helpers.db import build_in_memory_session_factory
def _build_claim(*, claim_id: str, claim_no: str, employee: Employee) -> ExpenseClaim:
return ExpenseClaim(
id=claim_id,
claim_no=claim_no,
employee_id=employee.id,
employee_name=employee.name,
department_id="tenant-scope-department",
department_name="租户隔离部",
project_code=None,
expense_type="office",
reason=f"{claim_no} 原始事由",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
def _current_user(tenant_id: str) -> CurrentUserContext:
return CurrentUserContext(
username="same-owner@example.com",
name="同名员工",
role_codes=["user"],
is_admin=False,
tenant_id=tenant_id,
)
def test_same_identity_claims_are_isolated_by_server_tenant() -> None:
session_factory = build_in_memory_session_factory()
with session_factory() as db:
employee = Employee(
id="tenant-scope-employee",
employee_no="TENANT-SCOPE-001",
name="同名员工",
email="same-owner@example.com",
)
tenant_a_claim = _build_claim(
claim_id="tenant-a-claim",
claim_no="RE-TENANT-A",
employee=employee,
)
tenant_b_claim = _build_claim(
claim_id="tenant-b-claim",
claim_no="RE-TENANT-B",
employee=employee,
)
legacy_default_claim = _build_claim(
claim_id="legacy-default-claim",
claim_no="RE-LEGACY-DEFAULT",
employee=employee,
)
db.add_all([employee, tenant_a_claim, tenant_b_claim, legacy_default_claim])
db.flush()
case_service = ExpenseCaseService(db)
case_service.ensure_case_for_claim(tenant_a_claim, tenant_id="tenant-a")
case_service.ensure_case_for_claim(tenant_b_claim, tenant_id="tenant-b")
db.commit()
service = ExpenseClaimService(db)
tenant_a_user = _current_user("tenant-a")
tenant_b_user = _current_user("tenant-b")
default_user = _current_user("default")
assert {claim.id for claim in service.list_claims(tenant_a_user)} == {
tenant_a_claim.id
}
assert {claim.id for claim in service.list_claims(tenant_b_user)} == {
tenant_b_claim.id
}
assert {claim.id for claim in service.list_claims(default_user)} == {
legacy_default_claim.id
}
assert service.get_claim(tenant_b_claim.id, tenant_a_user) is None
assert service.get_claim(tenant_a_claim.id, tenant_b_user) is None
assert (
service.update_claim(
claim_id=tenant_b_claim.id,
payload=ExpenseClaimUpdate(reason="跨租户篡改"),
current_user=tenant_a_user,
)
is None
)
db.expire_all()
assert db.get(ExpenseClaim, tenant_b_claim.id).reason == "RE-TENANT-B 原始事由"
def _seed_cross_tenant_risk_history(db):
employee = Employee(
id="tenant-history-employee",
employee_no="TENANT-HISTORY-001",
name="同名风险员工",
email="same-risk-owner@example.com",
)
clean_claim = _build_claim(
claim_id="tenant-a-clean-claim",
claim_no="RE-TENANT-A-CLEAN",
employee=employee,
)
risky_claim = _build_claim(
claim_id="tenant-b-risky-claim",
claim_no="RE-TENANT-B-RISKY",
employee=employee,
)
risky_claim.risk_flags_json = [
{
"source": "submission_review",
"severity": "high",
"label": "跨租户风险",
"message": "该风险只属于 tenant-b。",
}
]
db.add_all([employee, clean_claim, risky_claim])
db.flush()
case_service = ExpenseCaseService(db)
case_service.ensure_case_for_claim(clean_claim, tenant_id="tenant-a")
case_service.ensure_case_for_claim(risky_claim, tenant_id="tenant-b")
db.commit()
return clean_claim
def test_cross_tenant_risk_history_does_not_pollute_pre_review(monkeypatch) -> None:
with build_in_memory_session_factory()() as db:
clean_claim = _seed_cross_tenant_risk_history(db)
service = ExpenseClaimService(db)
monkeypatch.setattr(service, "_resolve_claim_manager_name", lambda _claim: "直属领导")
monkeypatch.setattr(
service,
"_run_travel_policy_review",
lambda _claim: {"flags": [], "blocking_reasons": []},
)
monkeypatch.setattr(
service,
"_run_scene_policy_review",
lambda _claim: {"flags": [], "blocking_reasons": []},
)
monkeypatch.setattr(
service,
"evaluate_platform_risk_rules",
lambda _claim, **_kwargs: {"flags": [], "rule_set_fingerprint": ""},
)
assert service._count_recent_risky_claims(clean_claim) == 0
pre_review = service.refresh_claim_pre_review_state(
clean_claim,
is_application_claim=False,
)
assert pre_review is not None
assert pre_review["decision"] == "ready"
assert not any(
isinstance(flag, dict) and str(flag.get("label") or "").startswith("历史风险")
for flag in clean_claim.risk_flags_json
)
def test_cross_tenant_risk_history_does_not_route_to_p8(monkeypatch) -> None:
with build_in_memory_session_factory()() as db:
clean_claim = _seed_cross_tenant_risk_history(db)
monkeypatch.setattr(
BudgetService,
"analyze_claim_budget",
lambda _self, _claim: {
"score": 100,
"rating": "pass",
"risk_level": "low",
"summary": "预算正常",
"metrics": {},
"budget_context": {"budget_applicable": False},
},
)
service = ExpenseClaimService(db)
assert service._count_recent_substantive_risky_claims(clean_claim) == 0
route = service._build_approval_route_decision(
clean_claim,
is_application_claim=False,
)
assert route["historical_risk_count"] == 0
assert route["requires_budget_review"] is False
assert route["route"] == "finance"
def test_draft_lookup_never_returns_cross_tenant_claim_and_keeps_default_legacy() -> None:
session_factory = build_in_memory_session_factory()
with session_factory() as db:
employee = Employee(
id="tenant-draft-employee",
employee_no="TENANT-DRAFT-001",
name="同名草稿员工",
email="same-draft-owner@example.com",
)
tenant_a_claim = _build_claim(
claim_id="tenant-a-draft",
claim_no="RE-TENANT-A-DRAFT",
employee=employee,
)
tenant_b_claim = _build_claim(
claim_id="tenant-b-draft",
claim_no="RE-TENANT-B-DRAFT",
employee=employee,
)
legacy_default_claim = _build_claim(
claim_id="legacy-default-draft",
claim_no="RE-LEGACY-DEFAULT-DRAFT",
employee=employee,
)
db.add_all([employee, tenant_a_claim, tenant_b_claim, legacy_default_claim])
db.flush()
case_service = ExpenseCaseService(db)
case_service.ensure_case_for_claim(tenant_a_claim, tenant_id="tenant-a")
case_service.ensure_case_for_claim(tenant_b_claim, tenant_id="tenant-b")
db.commit()
service = ExpenseClaimService(db)
tenant_b_ontology = OntologyParseResult(
run_id="tenant-b-draft-lookup",
entities=[
OntologyEntity(
type="expense_claim",
value=tenant_b_claim.claim_no,
normalized_value=tenant_b_claim.claim_no,
confidence=1.0,
)
],
)
empty_ontology = OntologyParseResult(run_id="tenant-draft-candidate")
assert (
service._find_target_claim(
ontology=empty_ontology,
context_json={
"tenant_id": "tenant-a",
"draft_claim_id": tenant_b_claim.id,
},
)
is None
)
assert (
service._find_target_claim(
ontology=tenant_b_ontology,
context_json={"tenantId": "tenant-a"},
)
is None
)
association_candidate = service._find_association_candidate(
ontology=empty_ontology,
context_json={
"tenant_id": "tenant-a",
"draft_claim_id": tenant_b_claim.id,
},
user_id=employee.email,
employee=employee,
)
assert association_candidate is not None
assert association_candidate.id == tenant_a_claim.id
legacy_by_id = service._find_target_claim(
ontology=empty_ontology,
context_json={
"tenant_id": "default",
"draft_claim_id": legacy_default_claim.id,
},
)
assert legacy_by_id is not None
assert legacy_by_id.id == legacy_default_claim.id

View File

@@ -0,0 +1,78 @@
from copy import deepcopy
from pathlib import Path
from types import SimpleNamespace
import pytest
from openpyxl.utils.exceptions import InvalidFileException
from app.services.agent_asset_spreadsheet import (
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
AgentAssetSpreadsheetManager,
)
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
from app.services.expense_rule_runtime_models import build_default_expense_rule_catalog
def _spreadsheet_asset() -> SimpleNamespace:
return SimpleNamespace(
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
config_json={
"detail_mode": "spreadsheet",
"rule_document": {"storage_key": "corrupted.xlsx"},
},
current_version="v1.0.0",
name="临时损坏规则",
)
def _spreadsheet_version() -> SimpleNamespace:
return SimpleNamespace(content="", version="v1.0.0")
def _assert_unreadable_workbook_is_skipped(
monkeypatch: pytest.MonkeyPatch,
workbook_path: Path,
) -> None:
monkeypatch.setattr(
AgentAssetSpreadsheetManager,
"resolve_storage_path",
lambda _manager, _storage_key: workbook_path,
)
catalog = build_default_expense_rule_catalog()
default_catalog = deepcopy(catalog)
ExpenseRuleRuntimeService(db=None)._apply_spreadsheet_runtime_payload(
catalog,
asset=_spreadsheet_asset(),
version=_spreadsheet_version(),
)
assert catalog == default_catalog
assert catalog.travel_policy is not None
assert catalog.scene_policies
def test_corrupted_spreadsheet_is_skipped_without_breaking_default_catalog(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
corrupted_workbook = tmp_path / "corrupted.xlsx"
corrupted_workbook.write_bytes(b"not-an-xlsx-archive")
_assert_unreadable_workbook_is_skipped(monkeypatch, corrupted_workbook)
def test_invalid_spreadsheet_format_is_skipped_without_breaking_catalog(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
invalid_workbook = tmp_path / "invalid.xlsx"
invalid_workbook.touch()
monkeypatch.setattr(
"app.services.expense_rule_runtime.load_workbook",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
InvalidFileException("unsupported workbook format")
),
)
_assert_unreadable_workbook_is_skipped(monkeypatch, invalid_workbook)

View File

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

View File

@@ -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",
},

View File

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

View File

@@ -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 || {})
})
}

View File

@@ -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),

View File

@@ -0,0 +1,13 @@
export function mapPreReviewFindingsToRiskFlags(preReview = {}) {
const findings = Array.isArray(preReview?.findings) ? preReview.findings : []
return findings.map((finding) => ({
...finding,
source: String(finding?.source || 'pre_review_finding').trim() || 'pre_review_finding',
label: finding?.message || '提交前预审风险',
business_stage: String(
finding?.business_stage || finding?.businessStage || 'reimbursement'
).trim() || 'reimbursement',
risk_domain: finding?.risk_domain || finding?.riskDomain || '',
visibility_scope: finding?.visibility_scope || finding?.visibilityScope || ''
}))
}

View File

@@ -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) {

View File

@@ -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')
}

View File

@@ -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: [

View File

@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
preReviewExpenseClaim,
submitExpenseClaim
} from '../src/services/reimbursements.js'
test('reimbursement service sends pre-review request identity and submit handshake payload', async () => {
const requests = []
const originalFetch = globalThis.fetch
globalThis.fetch = async (url, options = {}) => {
requests.push({ url: String(url), options })
return {
ok: true,
status: 200,
async json() {
return { ok: true }
}
}
}
try {
await preReviewExpenseClaim('claim / 001', 'pre-review:test-request-001')
await submitExpenseClaim('claim / 001', {
pre_review_id: 'review-ready-001',
input_fingerprint: 'sha256:ready-input'
})
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requests.length, 2)
assert.equal(
requests[0].url,
'/api/v1/reimbursements/claims/claim%20%2F%20001/pre-review'
)
assert.equal(requests[0].options.method, 'POST')
assert.equal(requests[0].options.headers['X-Request-ID'], 'pre-review:test-request-001')
assert.equal(requests[0].options.body, '{}')
assert.equal(
requests[1].url,
'/api/v1/reimbursements/claims/claim%20%2F%20001/submit'
)
assert.equal(requests[1].options.method, 'POST')
assert.deepEqual(JSON.parse(requests[1].options.body), {
pre_review_id: 'review-ready-001',
input_fingerprint: 'sha256:ready-input'
})
})

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildAttachmentRiskCards } from '../src/views/scripts/travelRequestDetailInsights.js'
import { mapPreReviewFindingsToRiskFlags } from '../src/views/scripts/travelRequestDetailPreReview.js'
test('structured pre-review findings remain visible as actionable risk cards', () => {
const flags = mapPreReviewFindingsToRiskFlags({
findings: [
{
risk_id: 'risk-visible-001',
source: 'platform_risk',
severity: 'high',
actionability: 'fixable_by_submitter',
business_stage: 'reimbursement',
message: '发票抬头不匹配,请重新上传。',
item_ids: ['item-001']
}
]
})
assert.equal(flags[0].source, 'platform_risk')
assert.notEqual(flags[0].source, 'ai_pre_review')
const cards = buildAttachmentRiskCards({
expenseItems: [{ id: 'item-001', itemNote: '' }],
claimRiskFlags: flags,
businessStage: 'reimbursement'
})
assert.equal(cards.length, 1)
assert.equal(cards[0].risk, '发票抬头不匹配,请重新上传。')
assert.equal(cards[0].actionability, 'fixable_by_submitter')
})

View File

@@ -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, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
assert.match(detailViewTemplate, /:secondary-text="submitConfirmSecondaryText"/)
@@ -94,7 +134,7 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/)
assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
assert.match(detailViewScript, /preReviewExpenseClaim/)
assert.match(detailViewScript, /const submitActionLabel = computed/)
assert.match(detailViewScript, /const submitConfirmSecondaryText = computed/)
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
@@ -111,10 +151,120 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.doesNotMatch(confirmSubmitRequest, /await /)
assert.doesNotMatch(confirmSubmitRequest, /submitExpenseClaim/)
const runSubmitRequest = extractFunction(detailViewScript, 'runSubmitRequest')
assert.match(runSubmitRequest, /submitExpenseClaim\(claimId\)/)
const preReviewIndex = runSubmitRequest.indexOf('preReviewExpenseClaim(claimId)')
const submitIndex = runSubmitRequest.indexOf('submitExpenseClaim(claimId,')
assert.ok(preReviewIndex > -1)
assert.ok(submitIndex > preReviewIndex)
assert.match(runSubmitRequest, /pre_review_id:[\s\S]*preReview\?\.review_id/)
assert.match(runSubmitRequest, /input_fingerprint:[\s\S]*preReview\?\.input_fingerprint/)
assert.match(runSubmitRequest, /taskSeq !== submitTaskSeq/)
})
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"/)