feat(expenses): secure timeline and draft events
This commit is contained in:
@@ -104,15 +104,21 @@ def _build_application_preview_action_context(
|
||||
context_json.setdefault("entry_source", "workbench_ai_inline")
|
||||
context_json.setdefault("document_type", "expense_application")
|
||||
context_json.setdefault("application_stage", "expense_application")
|
||||
context_json.setdefault("role_codes", current_user.role_codes)
|
||||
context_json.setdefault("is_admin", current_user.is_admin)
|
||||
context_json.setdefault("username", current_user.username)
|
||||
context_json.setdefault("name", current_user.name)
|
||||
context_json.setdefault("department_name", current_user.department_name)
|
||||
context_json.setdefault("position", current_user.position)
|
||||
context_json.setdefault("grade", current_user.grade)
|
||||
context_json.setdefault("employee_no", current_user.employee_no)
|
||||
context_json.setdefault("manager_name", current_user.manager_name)
|
||||
# 身份与权限字段只能来自服务端会话,不能保留请求体中的同名值。
|
||||
context_json.update(
|
||||
{
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"role_codes": current_user.role_codes,
|
||||
"is_admin": current_user.is_admin,
|
||||
"username": current_user.username,
|
||||
"name": current_user.name,
|
||||
"department_name": current_user.department_name,
|
||||
"position": current_user.position,
|
||||
"grade": current_user.grade,
|
||||
"employee_no": current_user.employee_no,
|
||||
"manager_name": current_user.manager_name,
|
||||
}
|
||||
)
|
||||
return context_json
|
||||
|
||||
|
||||
@@ -134,7 +140,7 @@ def run_application_preview_action(
|
||||
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
|
||||
request = UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id or current_user.username or current_user.name,
|
||||
user_id=current_user.username or current_user.name,
|
||||
message=payload.message,
|
||||
ontology=OntologyParseResult(
|
||||
scenario="expense",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -9,11 +8,30 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
class ExpenseCaseLinkRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
relation_type: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ArchivedApplicationRead(BaseModel):
|
||||
"""付款事件中允许用户看到的关联申请摘要。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
application_claim_no: str = ""
|
||||
|
||||
|
||||
class BusinessEventPayloadRead(BaseModel):
|
||||
"""费用时间线允许面向用户展示的事件载荷。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
previous_status: str = ""
|
||||
next_status: str = ""
|
||||
next_approval_stage: str = ""
|
||||
reason: str = ""
|
||||
opinion: str = ""
|
||||
application_claim_no: str = ""
|
||||
reimbursement_claim_no: str = ""
|
||||
archived_applications: list[ArchivedApplicationRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BusinessEventRead(BaseModel):
|
||||
@@ -21,18 +39,10 @@ class BusinessEventRead(BaseModel):
|
||||
|
||||
id: str
|
||||
event_type: str
|
||||
event_version: int
|
||||
idempotency_key: str
|
||||
aggregate_type: str
|
||||
aggregate_id: str
|
||||
correlation_id: str
|
||||
causation_id: str | None
|
||||
actor_id: str
|
||||
actor_type: str
|
||||
payload_json: dict[str, Any] = Field(default_factory=dict)
|
||||
delivery_status: str
|
||||
payload_json: BusinessEventPayloadRead = Field(default_factory=BusinessEventPayloadRead)
|
||||
occurred_at: datetime
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
class ExpenseCaseTimelineRead(BaseModel):
|
||||
@@ -40,12 +50,7 @@ class ExpenseCaseTimelineRead(BaseModel):
|
||||
|
||||
id: str
|
||||
case_no: str
|
||||
scene_code: str
|
||||
title: str
|
||||
owner_employee_id: str | None
|
||||
current_stage: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
links: list[ExpenseCaseLinkRead] = Field(default_factory=list)
|
||||
events: list[BusinessEventRead] = Field(default_factory=list)
|
||||
|
||||
124
server/src/app/services/expense_application_draft_events.py
Normal file
124
server/src/app/services/expense_application_draft_events.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
|
||||
|
||||
class ExpenseApplicationDraftEventService:
|
||||
"""为 AI 申请草稿生成稳定、租户隔离的费用事件。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def record(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
event_type: str,
|
||||
previous_status: str = "",
|
||||
previous_approval_stage: str = "",
|
||||
) -> None:
|
||||
ExpenseCaseService(self.db).record_claim_event(
|
||||
claim,
|
||||
event_type=event_type,
|
||||
actor_id=current_user.username,
|
||||
tenant_id=current_user.tenant_id,
|
||||
correlation_id=payload.run_id,
|
||||
idempotency_key=self._build_idempotency_key(
|
||||
payload,
|
||||
claim,
|
||||
current_user,
|
||||
event_type=event_type,
|
||||
),
|
||||
previous_status=previous_status,
|
||||
previous_approval_stage=previous_approval_stage,
|
||||
)
|
||||
|
||||
def prepare_created_draft(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
) -> tuple[str, ExpenseClaim | None]:
|
||||
"""生成数据库级稳定聚合 ID,并查找同一动作已创建的草稿。"""
|
||||
|
||||
idempotency_key = self._build_idempotency_key(
|
||||
payload,
|
||||
claim,
|
||||
current_user,
|
||||
event_type="claim_draft_created",
|
||||
)
|
||||
claim.id = str(uuid.uuid5(uuid.NAMESPACE_URL, idempotency_key))
|
||||
existing = self.find_created_draft(current_user, idempotency_key=idempotency_key)
|
||||
if existing is not None:
|
||||
return idempotency_key, existing
|
||||
|
||||
if self.db.get(ExpenseClaim, claim.id) is not None:
|
||||
raise RuntimeError("申请草稿幂等状态不完整,请重试。")
|
||||
return idempotency_key, None
|
||||
|
||||
def find_created_draft(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
) -> ExpenseClaim | None:
|
||||
event = self.db.scalar(
|
||||
select(BusinessEvent)
|
||||
.where(
|
||||
BusinessEvent.tenant_id == current_user.tenant_id,
|
||||
BusinessEvent.aggregate_type == "expense_claim",
|
||||
BusinessEvent.event_type == "claim_draft_created",
|
||||
BusinessEvent.idempotency_key == idempotency_key,
|
||||
BusinessEvent.actor_id == current_user.username,
|
||||
)
|
||||
.order_by(BusinessEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if event is None:
|
||||
return None
|
||||
return self.db.get(ExpenseClaim, event.aggregate_id)
|
||||
|
||||
@staticmethod
|
||||
def _build_idempotency_key(
|
||||
payload: UserAgentRequest,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
event_type: str,
|
||||
) -> str:
|
||||
snapshot = {
|
||||
"tenant_id": str(current_user.tenant_id or "default"),
|
||||
"actor_id": str(current_user.username or "anonymous"),
|
||||
"run_id": str(payload.run_id or "application-draft"),
|
||||
"expense_type": str(claim.expense_type or ""),
|
||||
"reason": str(claim.reason or ""),
|
||||
"location": str(claim.location or ""),
|
||||
"amount": str(claim.amount or Decimal("0.00")),
|
||||
"currency": str(claim.currency or "CNY"),
|
||||
"status": str(claim.status or ""),
|
||||
"approval_stage": str(claim.approval_stage or ""),
|
||||
"risk_flags": claim.risk_flags_json or [],
|
||||
}
|
||||
serialized = json.dumps(
|
||||
snapshot,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
digest = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
return f"application-draft:{event_type}:{digest}"
|
||||
@@ -422,6 +422,7 @@ class StewardActionExecutor:
|
||||
"entry_source": "steward_action_executor",
|
||||
"document_type": "expense_application",
|
||||
"application_stage": "expense_application",
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"role_codes": current_user.role_codes,
|
||||
"is_admin": current_user.is_admin,
|
||||
"username": current_user.username,
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
@@ -24,6 +25,9 @@ from app.services.document_numbering import (
|
||||
build_document_number,
|
||||
generate_unique_expense_claim_no,
|
||||
)
|
||||
from app.services.expense_application_draft_events import (
|
||||
ExpenseApplicationDraftEventService,
|
||||
)
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
@@ -701,6 +705,8 @@ class UserAgentApplicationPersistenceMixin:
|
||||
submit: bool,
|
||||
) -> ExpenseClaim:
|
||||
current_user = self._build_application_current_user(payload)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
previous_approval_stage = str(claim.approval_stage or "").strip()
|
||||
flags = claim.risk_flags_json
|
||||
if isinstance(flags, dict):
|
||||
flags = [flags]
|
||||
@@ -725,9 +731,21 @@ class UserAgentApplicationPersistenceMixin:
|
||||
claim.status = "draft"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.submitted_at = None
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
try:
|
||||
ExpenseApplicationDraftEventService(self.db).record(
|
||||
payload,
|
||||
claim,
|
||||
current_user,
|
||||
event_type="claim_draft_updated",
|
||||
previous_status=previous_status,
|
||||
previous_approval_stage=previous_approval_stage,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
@@ -743,15 +761,6 @@ class UserAgentApplicationPersistenceMixin:
|
||||
*,
|
||||
submit: bool,
|
||||
) -> ExpenseClaim:
|
||||
claim_no = self._build_application_claim_no(payload, facts)
|
||||
existing = self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no == claim_no)
|
||||
.limit(1)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
current_user = self._build_application_current_user(payload)
|
||||
access_policy = ExpenseClaimAccessPolicy(self.db)
|
||||
employee = access_policy.resolve_current_employee(current_user)
|
||||
@@ -768,7 +777,7 @@ class UserAgentApplicationPersistenceMixin:
|
||||
department_name = str(employee.organization_unit.name).strip()
|
||||
|
||||
claim = ExpenseClaim(
|
||||
claim_no=claim_no,
|
||||
claim_no=self._build_application_claim_no(payload, facts),
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_name,
|
||||
department_id=department_id,
|
||||
@@ -786,25 +795,52 @@ class UserAgentApplicationPersistenceMixin:
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[self._build_application_detail_flag(facts)],
|
||||
)
|
||||
self.db.add(claim)
|
||||
self.db.flush()
|
||||
draft_event_service = ExpenseApplicationDraftEventService(self.db)
|
||||
draft_idempotency_key = ""
|
||||
if not submit:
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
draft_idempotency_key, existing = draft_event_service.prepare_created_draft(
|
||||
payload,
|
||||
claim,
|
||||
current_user,
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
self.db.add(claim)
|
||||
try:
|
||||
try:
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
if draft_idempotency_key:
|
||||
existing = draft_event_service.find_created_draft(
|
||||
current_user,
|
||||
idempotency_key=draft_idempotency_key,
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
if not submit:
|
||||
draft_event_service.record(
|
||||
payload,
|
||||
claim,
|
||||
current_user,
|
||||
event_type="claim_draft_created",
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
|
||||
if submitted is None:
|
||||
raise ValueError("未找到可提交的申请单。")
|
||||
return submitted
|
||||
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,
|
||||
@@ -974,6 +1010,12 @@ class UserAgentApplicationPersistenceMixin:
|
||||
name=name or username or "anonymous",
|
||||
role_codes=role_codes,
|
||||
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")
|
||||
|
||||
@@ -23,6 +23,7 @@ def _read_test_user_headers(
|
||||
grade: Annotated[str | None, Header(alias="X-Auth-Grade")] = None,
|
||||
employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = None,
|
||||
manager_name: Annotated[str | None, Header(alias="X-Auth-Manager-Name")] = None,
|
||||
tenant_id: Annotated[str | None, Header(alias="X-Auth-Tenant-Id")] = None,
|
||||
) -> CurrentUserContext:
|
||||
normalized_username = str(username or "").strip()
|
||||
normalized_name = str(name or normalized_username).strip()
|
||||
@@ -44,6 +45,7 @@ def _read_test_user_headers(
|
||||
name=normalized_name or normalized_username,
|
||||
role_codes=normalized_roles,
|
||||
is_admin=admin_flag,
|
||||
tenant_id=str(tenant_id or "default").strip() or "default",
|
||||
department_name=str(department or "").strip(),
|
||||
cost_center=str(cost_center or "").strip(),
|
||||
position=str(position or "").strip(),
|
||||
|
||||
339
server/tests/test_expense_case_endpoints.py
Normal file
339
server/tests/test_expense_case_endpoints.py
Normal file
@@ -0,0 +1,339 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, 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.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.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_context() -> Generator[tuple[TestClient, sessionmaker[Session]], None, None]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
app = create_app()
|
||||
install_legacy_header_auth_override(app)
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
try:
|
||||
yield client, session_factory
|
||||
finally:
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def seed_timeline(
|
||||
db: Session,
|
||||
*,
|
||||
claim_no: str = "RE-TIMELINE-001",
|
||||
expense_type: str = "travel",
|
||||
relation_type: str | None = None,
|
||||
approval_stage: str = DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
with_case: bool = True,
|
||||
tenant_id: str = "default",
|
||||
) -> ExpenseClaim:
|
||||
manager = Employee(
|
||||
id="manager-1",
|
||||
employee_no="M001",
|
||||
name="李经理",
|
||||
email="manager@example.com",
|
||||
)
|
||||
owner = Employee(
|
||||
id="owner-1",
|
||||
employee_no="E001",
|
||||
name="张三",
|
||||
email="owner@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-1",
|
||||
claim_no=claim_no,
|
||||
employee_id=owner.id,
|
||||
employee_name=owner.name,
|
||||
department_name="市场部",
|
||||
expense_type=expense_type,
|
||||
reason="客户现场差旅",
|
||||
location="上海",
|
||||
amount=Decimal("880.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 7, 13, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 7, 13, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=approval_stage,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([manager, owner, claim])
|
||||
db.flush()
|
||||
|
||||
if with_case:
|
||||
ExpenseCaseService(db).record_claim_event(
|
||||
claim,
|
||||
event_type="claim_submitted",
|
||||
actor_id=owner.email,
|
||||
tenant_id=tenant_id,
|
||||
correlation_id="timeline-http-permission",
|
||||
idempotency_key="timeline-http-permission",
|
||||
previous_status="draft",
|
||||
previous_approval_stage="待提交",
|
||||
relation_type=relation_type,
|
||||
extra_payload={
|
||||
"opinion": "同意,按计划执行。",
|
||||
"internal_route": "finance-secret-route",
|
||||
"archived_applications": [
|
||||
{
|
||||
"application_claim_id": "internal-application-id",
|
||||
"application_claim_no": "AP-TIMELINE-001",
|
||||
"archive_event_id": "internal-archive-event-id",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return claim
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("approval_stage", "headers"),
|
||||
[
|
||||
(
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
{
|
||||
"x-auth-username": "owner@example.com",
|
||||
"x-auth-name": "Owner",
|
||||
"x-auth-employee-no": "E001",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
),
|
||||
(
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
{
|
||||
"x-auth-username": "manager@example.com",
|
||||
"x-auth-name": "Manager",
|
||||
"x-auth-employee-no": "M001",
|
||||
"x-auth-role-codes": "approver",
|
||||
},
|
||||
),
|
||||
(
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
{
|
||||
"x-auth-username": "finance@example.com",
|
||||
"x-auth-name": "Finance",
|
||||
"x-auth-role-codes": "finance",
|
||||
},
|
||||
),
|
||||
(
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
{
|
||||
"x-auth-username": "admin",
|
||||
"x-auth-name": "Admin",
|
||||
"x-auth-is-admin": "true",
|
||||
},
|
||||
),
|
||||
],
|
||||
ids=["owner", "current-manager", "finance", "admin"],
|
||||
)
|
||||
def test_expense_case_timeline_allows_supported_viewers(
|
||||
http_context: tuple[TestClient, sessionmaker[Session]],
|
||||
approval_stage: str,
|
||||
headers: dict[str, str],
|
||||
) -> None:
|
||||
client, session_factory = http_context
|
||||
with session_factory() as db:
|
||||
claim = seed_timeline(db, approval_stage=approval_stage)
|
||||
claim_id = claim.id
|
||||
|
||||
response = client.get(f"/api/v1/expense-cases/by-claim/{claim_id}", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert set(payload) == {"id", "case_no", "current_stage", "status", "links", "events"}
|
||||
assert payload["case_no"] == "CASE-RE-TIMELINE-001"
|
||||
assert payload["links"] == [{"relation_type": "claim"}]
|
||||
assert len(payload["events"]) == 1
|
||||
event = payload["events"][0]
|
||||
assert set(event) == {
|
||||
"id",
|
||||
"event_type",
|
||||
"actor_id",
|
||||
"actor_type",
|
||||
"payload_json",
|
||||
"occurred_at",
|
||||
}
|
||||
assert event["actor_id"] == "owner@example.com"
|
||||
assert event["payload_json"]["opinion"] == "同意,按计划执行。"
|
||||
assert event["payload_json"]["archived_applications"] == [
|
||||
{"application_claim_no": "AP-TIMELINE-001"}
|
||||
]
|
||||
serialized = response.text
|
||||
for internal_value in (
|
||||
"idempotency_key",
|
||||
"correlation_id",
|
||||
"causation_id",
|
||||
"delivery_status",
|
||||
"resource_id",
|
||||
"internal_route",
|
||||
"internal-application-id",
|
||||
"internal-archive-event-id",
|
||||
):
|
||||
assert internal_value not in serialized
|
||||
|
||||
|
||||
def test_expense_case_timeline_hides_claim_existence_from_unrelated_user(
|
||||
http_context: tuple[TestClient, sessionmaker[Session]],
|
||||
) -> None:
|
||||
client, session_factory = http_context
|
||||
with session_factory() as db:
|
||||
claim_id = seed_timeline(db).id
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/expense-cases/by-claim/{claim_id}",
|
||||
headers={
|
||||
"x-auth-username": "unrelated@example.com",
|
||||
"x-auth-name": "Unrelated User",
|
||||
"x-auth-employee-no": "E999",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "费用单据不存在。"
|
||||
|
||||
|
||||
def test_expense_case_timeline_rejects_cross_tenant_lookup(
|
||||
http_context: tuple[TestClient, sessionmaker[Session]],
|
||||
) -> None:
|
||||
client, session_factory = http_context
|
||||
with session_factory() as db:
|
||||
claim_id = seed_timeline(db, tenant_id="tenant-a").id
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/expense-cases/by-claim/{claim_id}",
|
||||
headers={
|
||||
"x-auth-username": "owner@example.com",
|
||||
"x-auth-name": "Owner",
|
||||
"x-auth-employee-no": "E001",
|
||||
"x-auth-role-codes": "user",
|
||||
"x-auth-tenant-id": "tenant-b",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "该单据尚未纳入统一费用事件。"
|
||||
|
||||
|
||||
def test_expense_case_timeline_returns_not_covered_for_claim_without_case(
|
||||
http_context: tuple[TestClient, sessionmaker[Session]],
|
||||
) -> None:
|
||||
client, session_factory = http_context
|
||||
with session_factory() as db:
|
||||
claim_id = seed_timeline(db, with_case=False).id
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/expense-cases/by-claim/{claim_id}",
|
||||
headers={
|
||||
"x-auth-username": "owner@example.com",
|
||||
"x-auth-name": "Owner",
|
||||
"x-auth-employee-no": "E001",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "该单据尚未纳入统一费用事件。"
|
||||
|
||||
|
||||
def test_expense_case_timeline_returns_safe_summary_for_all_linked_claims(
|
||||
http_context: tuple[TestClient, sessionmaker[Session]],
|
||||
) -> None:
|
||||
client, session_factory = http_context
|
||||
with session_factory() as db:
|
||||
application = seed_timeline(
|
||||
db,
|
||||
claim_no="AP-TIMELINE-001",
|
||||
expense_type="travel_application",
|
||||
relation_type="application",
|
||||
)
|
||||
service = ExpenseCaseService(db)
|
||||
expense_case = service.get_timeline_for_claim(application.id)
|
||||
assert expense_case is not None
|
||||
reimbursement = ExpenseClaim(
|
||||
id="claim-2",
|
||||
claim_no="RE-TIMELINE-002",
|
||||
employee_id=application.employee_id,
|
||||
employee_name=application.employee_name,
|
||||
department_name=application.department_name,
|
||||
expense_type="travel",
|
||||
reason="客户现场差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("880.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=application.occurred_at,
|
||||
submitted_at=application.submitted_at,
|
||||
status="submitted",
|
||||
approval_stage=FINANCE_APPROVAL_STAGE,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(reimbursement)
|
||||
db.flush()
|
||||
service.record_claim_event(
|
||||
reimbursement,
|
||||
event_type="claim_submitted",
|
||||
actor_id="owner@example.com",
|
||||
expense_case=expense_case,
|
||||
relation_type="generated_reimbursement",
|
||||
correlation_id="linked-reimbursement",
|
||||
idempotency_key="linked-reimbursement",
|
||||
previous_status="draft",
|
||||
previous_approval_stage="待提交",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/expense-cases/by-claim/claim-1",
|
||||
headers={
|
||||
"x-auth-username": "owner@example.com",
|
||||
"x-auth-name": "Owner",
|
||||
"x-auth-employee-no": "E001",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert {item["relation_type"] for item in payload["links"]} == {
|
||||
"application",
|
||||
"generated_reimbursement",
|
||||
}
|
||||
assert [event["event_type"] for event in payload["events"]] == [
|
||||
"claim_submitted",
|
||||
"claim_submitted",
|
||||
]
|
||||
@@ -1114,9 +1114,147 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
|
||||
assert db.scalar(
|
||||
select(BudgetReservation).where(BudgetReservation.source_id == claim.id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
event = db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
)
|
||||
assert event is not None
|
||||
assert event.event_type == "claim_draft_created"
|
||||
assert event.payload_json["previous_status"] == ""
|
||||
assert event.payload_json["next_status"] == "draft"
|
||||
link = db.scalar(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
|
||||
)
|
||||
assert link is not None
|
||||
assert link.relation_type == "application"
|
||||
|
||||
|
||||
def test_application_preview_action_rejects_forged_identity_when_editing_other_claim() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_claim(db)
|
||||
outsider = Employee(
|
||||
id="emp-outsider",
|
||||
employee_no="E90001",
|
||||
name="其他员工",
|
||||
email="outsider@example.com",
|
||||
)
|
||||
outsider_claim = ExpenseClaim(
|
||||
id="application-outsider-1",
|
||||
claim_no="AP-OUTSIDER-001",
|
||||
employee_id=outsider.id,
|
||||
employee_name=outsider.name,
|
||||
department_name="交付部",
|
||||
expense_type="travel_application",
|
||||
reason="其他员工原申请",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="returned",
|
||||
approval_stage="退回补充",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([outsider, outsider_claim])
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers={
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-name": "Zhang San",
|
||||
"x-auth-employee-no": "E10001",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
json={
|
||||
"source": "user_message",
|
||||
"user_id": "outsider@example.com",
|
||||
"conversation_id": "conversation-forged-identity",
|
||||
"message": "费用申请保存草稿\n申请时间:2026-07-13 至 2026-07-14\n地点:上海\n事由:恶意修改\n申请金额:880元\n保存草稿",
|
||||
"context_json": {
|
||||
"session_type": "application",
|
||||
"application_action": "save_draft",
|
||||
"application_save_mode": True,
|
||||
"application_edit_mode": True,
|
||||
"application_edit_claim_id": "application-outsider-1",
|
||||
"username": "outsider@example.com",
|
||||
"name": "其他员工",
|
||||
"employee_no": "E90001",
|
||||
"role_codes": ["admin"],
|
||||
"is_admin": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "只能修改本人被退回的申请单。"
|
||||
with session_factory() as db:
|
||||
persisted = db.get(ExpenseClaim, "application-outsider-1")
|
||||
assert persisted is not None
|
||||
assert persisted.reason == "其他员工原申请"
|
||||
assert persisted.status == "returned"
|
||||
assert persisted.approval_stage == "退回补充"
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id)
|
||||
) is None
|
||||
|
||||
|
||||
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_claim(db)
|
||||
|
||||
request_payload = {
|
||||
"source": "user_message",
|
||||
"user_id": "zhangsan@example.com",
|
||||
"conversation_id": "conversation-fast-save-retry",
|
||||
"message": "费用申请保存草稿\n地点:上海\n事由:项目验收\n申请金额:880元\n保存草稿",
|
||||
"context_json": {
|
||||
"session_type": "application",
|
||||
"application_action": "save_draft",
|
||||
"application_save_mode": True,
|
||||
},
|
||||
}
|
||||
headers = {
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-name": "Zhang San",
|
||||
"x-auth-employee-no": "E10001",
|
||||
"x-auth-role-codes": "user",
|
||||
}
|
||||
|
||||
first_response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=headers,
|
||||
json=request_payload,
|
||||
)
|
||||
second_response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=headers,
|
||||
json=request_payload,
|
||||
)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
assert second_response.status_code == 200
|
||||
first_draft = first_response.json()["result"]["draft_payload"]
|
||||
second_draft = second_response.json()["result"]["draft_payload"]
|
||||
assert second_draft["claim_id"] == first_draft["claim_id"]
|
||||
assert second_draft["claim_no"] == first_draft["claim_no"]
|
||||
with session_factory() as db:
|
||||
application_claims = list(
|
||||
db.scalars(
|
||||
select(ExpenseClaim).where(
|
||||
ExpenseClaim.expense_type == "travel_application"
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(application_claims) == 1
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == application_claims[0].id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "claim_draft_created"
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.agent_conversation import AgentConversation
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services import attachment_association_jobs as attachment_jobs_module
|
||||
|
||||
@@ -284,6 +285,9 @@ def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_d
|
||||
assert second_payload["result_payload"]["idempotent_replay"] is True
|
||||
with session_factory() as db:
|
||||
assert claim_count(db) == 1
|
||||
events = list(db.scalars(select(BusinessEvent)).all())
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "claim_draft_created"
|
||||
|
||||
|
||||
def test_steward_action_executor_requires_confirmation_before_submit_side_effect() -> None:
|
||||
@@ -342,6 +346,16 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N
|
||||
claim = db.scalars(select(ExpenseClaim)).one()
|
||||
assert claim.status == "draft"
|
||||
assert claim.reason == "辅助国网仿生产服务器部署"
|
||||
event = db.scalars(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
).one()
|
||||
assert event.event_type == "claim_draft_created"
|
||||
assert event.actor_id == "zhangsan@example.com"
|
||||
assert event.correlation_id == "steward-action:save_application_draft:task_app_001"
|
||||
link = db.scalars(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
|
||||
).one()
|
||||
assert link.relation_type == "application"
|
||||
|
||||
|
||||
def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> None:
|
||||
|
||||
285
server/tests/test_user_agent_application_draft_events.py
Normal file
285
server/tests/test_user_agent_application_draft_events.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.ontology import OntologyParseResult
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[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 build_request(*, run_id: str, tenant_id: str = "tenant-a") -> UserAgentRequest:
|
||||
return UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id="owner@example.com",
|
||||
message="保存申请草稿",
|
||||
ontology=OntologyParseResult(run_id=run_id),
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"tenant_id": tenant_id,
|
||||
"name": "张三",
|
||||
"employee_no": "E001",
|
||||
"department_name": "市场部",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_facts(*, reason: str = "客户现场差旅") -> dict[str, str]:
|
||||
return {
|
||||
"application_type": "差旅费用申请",
|
||||
"time": "2026-07-13 至 2026-07-14",
|
||||
"location": "上海",
|
||||
"reason": reason,
|
||||
"amount": "880元",
|
||||
}
|
||||
|
||||
|
||||
def test_ai_application_draft_update_writes_tenant_scoped_event() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
owner = Employee(
|
||||
id="owner-1",
|
||||
employee_no="E001",
|
||||
name="张三",
|
||||
email="owner@example.com",
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="application-1",
|
||||
claim_no="AP-DRAFT-001",
|
||||
employee_id=owner.id,
|
||||
employee_name=owner.name,
|
||||
department_name="市场部",
|
||||
expense_type="travel_application",
|
||||
reason="原申请事由",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="returned",
|
||||
approval_stage="退回补充",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([owner, claim])
|
||||
db.commit()
|
||||
|
||||
updated = UserAgentService(db)._update_expense_application_record(
|
||||
build_request(run_id="application-draft-update"),
|
||||
build_facts(reason="更新后的申请事由"),
|
||||
claim,
|
||||
submit=False,
|
||||
)
|
||||
|
||||
assert updated.status == "draft"
|
||||
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id))
|
||||
assert event is not None
|
||||
assert event.event_type == "claim_draft_updated"
|
||||
assert event.tenant_id == "tenant-a"
|
||||
assert event.actor_id == "owner@example.com"
|
||||
assert event.correlation_id == "application-draft-update"
|
||||
assert event.payload_json["previous_status"] == "returned"
|
||||
assert event.payload_json["next_status"] == "draft"
|
||||
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
||||
assert link is not None
|
||||
assert link.tenant_id == "tenant-a"
|
||||
|
||||
|
||||
def test_ai_application_draft_creation_rolls_back_when_event_write_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
|
||||
def fail_event(*_args, **_kwargs):
|
||||
raise RuntimeError("模拟草稿事件写入失败")
|
||||
|
||||
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
|
||||
with session_factory() as db:
|
||||
with pytest.raises(RuntimeError, match="模拟草稿事件写入失败"):
|
||||
UserAgentService(db)._create_expense_application_record(
|
||||
build_request(run_id="application-draft-rollback"),
|
||||
build_facts(),
|
||||
submit=False,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
assert list(db.scalars(select(ExpenseClaim)).all()) == []
|
||||
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
|
||||
assert list(db.scalars(select(BusinessEvent)).all()) == []
|
||||
|
||||
|
||||
def test_ai_application_draft_update_rolls_back_when_event_write_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
owner = Employee(
|
||||
id="owner-1",
|
||||
employee_no="E001",
|
||||
name="张三",
|
||||
email="owner@example.com",
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="application-1",
|
||||
claim_no="AP-DRAFT-ROLLBACK-001",
|
||||
employee_id=owner.id,
|
||||
employee_name=owner.name,
|
||||
department_name="市场部",
|
||||
expense_type="travel_application",
|
||||
reason="原申请事由",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="returned",
|
||||
approval_stage="退回补充",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([owner, claim])
|
||||
db.commit()
|
||||
|
||||
def fail_event(*_args, **_kwargs):
|
||||
raise RuntimeError("模拟草稿事件写入失败")
|
||||
|
||||
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
|
||||
with pytest.raises(RuntimeError, match="模拟草稿事件写入失败"):
|
||||
UserAgentService(db)._update_expense_application_record(
|
||||
build_request(run_id="application-draft-update-rollback"),
|
||||
build_facts(reason="不应落库的新事由"),
|
||||
claim,
|
||||
submit=False,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
persisted = db.get(ExpenseClaim, claim.id)
|
||||
assert persisted is not None
|
||||
assert persisted.reason == "原申请事由"
|
||||
assert persisted.location == "北京"
|
||||
assert persisted.amount == Decimal("500.00")
|
||||
assert persisted.status == "returned"
|
||||
assert persisted.approval_stage == "退回补充"
|
||||
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
|
||||
assert list(db.scalars(select(BusinessEvent)).all()) == []
|
||||
|
||||
|
||||
def test_ai_application_draft_update_deduplicates_identical_snapshot() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
owner = Employee(
|
||||
id="owner-1",
|
||||
employee_no="E001",
|
||||
name="张三",
|
||||
email="owner@example.com",
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="application-1",
|
||||
claim_no="AP-DRAFT-IDEMPOTENT-001",
|
||||
employee_id=owner.id,
|
||||
employee_name=owner.name,
|
||||
department_name="市场部",
|
||||
expense_type="travel_application",
|
||||
reason="原申请事由",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="returned",
|
||||
approval_stage="退回补充",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([owner, claim])
|
||||
db.commit()
|
||||
|
||||
request = build_request(run_id="application-draft-idempotent")
|
||||
facts = build_facts(reason="同一份草稿")
|
||||
service = UserAgentService(db)
|
||||
service._update_expense_application_record(request, facts, claim, submit=False)
|
||||
service._update_expense_application_record(request, facts, claim, submit=False)
|
||||
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
).all()
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "claim_draft_updated"
|
||||
assert events[0].payload_json["previous_status"] == "returned"
|
||||
|
||||
|
||||
def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
owner = Employee(
|
||||
id="owner-1",
|
||||
employee_no="E001",
|
||||
name="张三",
|
||||
email="owner@example.com",
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="application-1",
|
||||
claim_no="AP-DRAFT-VERSIONS-001",
|
||||
employee_id=owner.id,
|
||||
employee_name=owner.name,
|
||||
department_name="市场部",
|
||||
expense_type="travel_application",
|
||||
reason="原申请事由",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="returned",
|
||||
approval_stage="退回补充",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([owner, claim])
|
||||
db.commit()
|
||||
|
||||
request = build_request(run_id="application-draft-multi-version")
|
||||
service = UserAgentService(db)
|
||||
service._update_expense_application_record(
|
||||
request,
|
||||
build_facts(reason="第一版草稿"),
|
||||
claim,
|
||||
submit=False,
|
||||
)
|
||||
service._update_expense_application_record(
|
||||
request,
|
||||
build_facts(reason="第二版草稿"),
|
||||
claim,
|
||||
submit=False,
|
||||
)
|
||||
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
).all()
|
||||
)
|
||||
assert len(events) == 2
|
||||
assert {event.event_type for event in events} == {"claim_draft_updated"}
|
||||
assert len({event.idempotency_key for event in events}) == 2
|
||||
Reference in New Issue
Block a user