fix(expenses): unify AI application submission transaction
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user