fix(expenses): unify AI application submission transaction

This commit is contained in:
caoxiaozhu
2026-07-13 15:35:08 +08:00
parent 653eda0596
commit a616b30cb2
7 changed files with 138 additions and 21 deletions

View File

@@ -0,0 +1,9 @@
## 修复记录
- 15:33记录 bug 修复AI 新建费用申请直接提交绕过统一事务。
- Git 提交检查:`git fetch --all --prune` 后未发现 upstream 新提交;本地 ahead 2 个既有提交,分别为 `653eda05 feat(auth): add opaque bearer sessions`(不透明 Bearer 会话与认证收口)和 `661990b2 feat(expenses): add transactional expense case events`Expense Case 与事务业务事件基础)。
- 修改:`user_agent_application.py` 将 AI 新建并直接提交改为先创建草稿,再统一调用 `ExpenseClaimService.submit_claim`,删除入口内手写提交状态和平台风险评估;提交异常或未找到单据时主动回滚,避免外层工具日志提交失败草稿。
- 修改:`budget.py` 的预算就绪检查改为复用当前 Session 连接执行 metadata 检查,避免 Engine 连接隐式提交已经 `flush` 的申请和预算数据;`test_reimbursement_endpoints.py``test_user_agent_service.py` 补齐成功提交、事件失败整体回滚、保存草稿无副作用和必填部门上下文回归。
- 操作:所有后端验证均在 `x-financial-local-linux` 容器内执行,单条命令使用 `timeout 60s`;没有修改数据库结构,没有对持久化开发数据库执行迁移,也没有重启服务。
- 验证AI 直接提交/失败回滚/保存草稿 3 项、申请提交主链路 5 项、预算与 Expense Case 13 项,共 21 项通过;相关 Python 文件 `ruff --select F,I` 通过。整份 `test_reimbursement_endpoints.py` 运行结果为 13 项通过、3 项未通过,失败分别位于既有附件风险等级断言、申请审批路由断言和中文测试请求头编码,不属于本次 AI 提交事务断言。
- 影响AI 一键新建申请现在与编辑重提共用同一提交语义预算预占、提交校验、申请风险标记、Expense Case 事件和审批状态保持一致;业务事件写入失败时,申请、预算额度、预算流水、预算预占和 Case 关联不会残留部分成功数据。

View File

@@ -488,3 +488,6 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 2026-07-13管理面收口移除生产代码中的 `X-Auth-*` 授权来源,保护 Settings、模型连通性、缓存、员工、分析、Agent 运行/轨迹、风险观测、审计及系统日志;已初始化 Bootstrap 返回脱敏状态并拒绝匿名重配置Vite Setup 桥同步锁定。
- 2026-07-13前端会话Web 请求、流式响应和页面关闭收尾统一使用 Bearertoken 与过期时间只保存在 `sessionStorage`,集中处理 `401`、空闲过期和服务端过期;业务经理不再被前端视为平台管理员。
- 2026-07-13认证验证容器内认证/Bootstrap/费用事件/OpenAPI 定向测试 21 项通过,受保护业务端点回归 13 项通过,全量测试收集 805 项成功前端会话、请求、Setup 锁和权限测试 17 项通过,生产构建通过。迁移仅生成并检查 upgrade/downgrade 离线 SQL未写入持久化开发数据库。
- 2026-07-13P0 提交一致性补缝AI 新建费用申请并直接提交时,先持久化为草稿,再统一委托 `ExpenseClaimService.submit_claim`;提交校验、预算预占、申请提交风险标记、`application_submitted` 事件和审批状态不再由 AI 入口分别维护。
- 2026-07-13事务边界修复预算表运行时就绪检查改为复用当前 Session 连接,避免通过 Engine 执行 metadata 检查时隐式提交已 `flush` 的申请;即使预算预占后 Expense Case 事件写入失败,申请、预算额度、预算流水、预占和 Case 数据也会整体回滚。
- 2026-07-13提交一致性验证容器内 AI 申请直接提交、失败回滚、保存草稿副作用、申请提交主链路及预算/Expense Case 回归共 21 项通过,`ruff --select F,I` 通过;未修改数据库结构,未对持久化开发数据库执行迁移。

View File

@@ -64,6 +64,8 @@
- [ ] [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: 业务事件与 AI 决策] 收口 AI 新建申请直接提交入口,统一复用申请提交事务,并消除预算 metadata 检查对外层事务的隐式提交。
证据:`user_agent_application.py``budget.py``test_reimbursement_endpoints.py``test_user_agent_service.py`;容器测试覆盖有效预算预占、`application_submitted` 事件、提交风险标记、事件失败整体回滚及保存草稿无提交副作用。
- [x] [CONCEPT: 兼容策略] 建立迁移桥接:服务启动先执行 Alembic旧 metadata bootstrap 排除 migration-owned 表。
证据:`server_start.sh``agent_foundation.py``20260713_0001_expense_case_business_events.py`;容器 `sh -n`、离线 upgrade/downgrade SQL 和 legacy bootstrap 排除测试通过。
- [ ] [CONCEPT: 兼容策略] 正式切换前以 shadow 事件校验现有 `ExpenseClaim` 映射;切换后禁止关键事件可丢弃写入。
@@ -136,6 +138,8 @@
- [x] [CONCEPT: 测试方案] 完成 Expense Case/Link、业务事件幂等、租户边界、同事务回滚、申请转报销同 Case 和付款归档事件首批测试。
证据:容器内 `pytest -q server/tests/test_expense_case_service.py` 7 项通过;联合差旅主链路定向回归共 24 项通过。
- [x] [CONCEPT: 测试方案] 为 AI 新建申请直接提交补充统一事务回归,覆盖提交成功、事件失败回滚和仅保存草稿三条边界。
证据:容器内直接提交定向测试 3 项、申请提交回归 5 项、预算与 Expense Case 回归 13 项通过;相关 Python 文件 `ruff --select F,I` 通过。
- [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。
- [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。
证据:`test_auth_session_endpoints.py``test_auth_service.py``test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。

View File

@@ -15,8 +15,8 @@ from app.schemas.budget import (
BudgetAllocationRead,
BudgetCheckRead,
BudgetCheckRequest,
BudgetOperationRequest,
BudgetOperationRead,
BudgetOperationRequest,
BudgetSummaryRead,
BudgetTransactionRead,
)
@@ -31,7 +31,8 @@ class BudgetService(BudgetPaginationMixin, BudgetSupportMixin):
self.db = db
def ensure_budget_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
# 复用当前 Session 连接,避免在业务事务中通过 Engine 隐式提交已 flush 的单据。
Base.metadata.create_all(bind=self.db.connection())
exists = self.db.scalar(select(BudgetAllocation.id).limit(1))
if exists:
return

View File

@@ -781,26 +781,30 @@ class UserAgentApplicationPersistenceMixin:
currency="CNY",
invoice_count=0,
occurred_at=self._parse_application_occurred_at(facts.get("time", "")),
submitted_at=datetime.now(UTC) if submit else None,
status="submitted" if submit else "draft",
approval_stage="直属领导审批" if submit else "待提交",
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[self._build_application_detail_flag(facts)],
)
self.db.add(claim)
self.db.flush()
if submit:
from app.services.expense_claims import ExpenseClaimService
if not submit:
self.db.commit()
self.db.refresh(claim)
return claim
platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
claim,
business_stage="expense_application",
)
platform_flags = list(platform_review.get("flags") or [])
if platform_flags:
claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags]
self.db.commit()
self.db.refresh(claim)
return claim
from app.services.expense_claims import ExpenseClaimService
try:
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
except Exception:
# 外层编排会记录失败工具调用并提交事务,必须先回滚本次已 flush 的草稿。
self.db.rollback()
raise
if submitted is None:
self.db.rollback()
raise ValueError("未找到可提交的申请单。")
return submitted
def _find_duplicate_expense_application_record(
self,

View File

@@ -6,24 +6,31 @@ from collections.abc import Generator
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.ontology import OntologyParseResult
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.ocr import OcrService
from app.services.user_agent import UserAgentService
def build_session_factory() -> sessionmaker[Session]:
@@ -891,6 +898,7 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
"x-auth-department": "Marketing",
},
json={
"source": "user_message",
@@ -948,6 +956,83 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
assert claim is not None
assert claim.status == "submitted"
assert claim.employee_name == "张三"
reservation = db.scalar(
select(BudgetReservation).where(
BudgetReservation.source_type == "application",
BudgetReservation.source_id == claim.id,
BudgetReservation.source_status == "active",
)
)
assert reservation is not None
assert reservation.amount == Decimal("1000.00")
case_link = db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == claim.id,
)
)
assert case_link is not None
event = db.scalar(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == "application_submitted",
)
)
assert event is not None
assert event.expense_case_id == case_link.expense_case_id
assert event.payload_json["previous_status"] == "draft"
assert event.payload_json["next_status"] == "submitted"
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "expense_application_submission"
for flag in list(claim.risk_flags_json or [])
)
def test_application_direct_submit_rolls_back_budget_when_case_event_fails(
monkeypatch,
) -> None:
session_factory = build_session_factory()
def fail_event(*_args, **_kwargs):
raise RuntimeError("模拟费用事件写入失败")
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
request = UserAgentRequest(
run_id="application-submit-rollback",
user_id="rollback@example.com",
message="直接提交",
ontology=OntologyParseResult(run_id="application-submit-rollback"),
context_json={
"session_type": "application",
"name": "回滚测试用户",
"department_name": "Test Department",
},
)
facts = {
"application_type": "差旅费用申请",
"time": "2026-07-01 至 2026-07-03",
"location": "北京",
"reason": "验证提交失败回滚",
"amount": "1000元",
}
with session_factory() as db:
with pytest.raises(RuntimeError, match="模拟费用事件写入失败"):
UserAgentService(db)._create_expense_application_record(
request,
facts,
submit=True,
)
# 模拟外层捕获异常后继续记录工具日志并提交,失败草稿仍不能被顺带落库。
db.commit()
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(BudgetAllocation)).all()) == []
assert list(db.scalars(select(BudgetReservation)).all()) == []
assert list(db.scalars(select(BudgetTransaction)).all()) == []
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
assert list(db.scalars(select(BusinessEvent)).all()) == []
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
@@ -1026,3 +1111,12 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
assert claim.approval_stage == "待提交"
assert claim.submitted_at is None
assert claim.employee_name == "张三"
assert db.scalar(
select(BudgetReservation).where(BudgetReservation.source_id == claim.id)
) is None
assert db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
) is None
assert db.scalar(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
) is None

View File

@@ -627,7 +627,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
context_overrides={"manager_name": "陈硕", "department_name": "技术部"},
history=[
{"role": "user", "content": initial_message},
{"role": "user", "content": "飞机"},
@@ -683,7 +683,7 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
first_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
context_overrides={"manager_name": "陈硕", "department_name": "技术部"},
history=history,
)
first_claim = application_claim_query(db).one()
@@ -691,7 +691,7 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
second_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
context_overrides={"manager_name": "陈硕", "department_name": "技术部"},
history=history,
)
@@ -742,6 +742,7 @@ def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None
"确认提交",
context_overrides={
"manager_name": "向万红",
"department_name": "技术部",
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
@@ -772,6 +773,7 @@ def test_user_agent_application_submit_normalizes_location_mixed_with_business_c
"确认提交",
context_overrides={
"manager_name": "向万红",
"department_name": "技术部",
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",