feat(ai): unify verified expense application workflow

This commit is contained in:
caoxiaozhu
2026-07-14 16:03:05 +08:00
parent 5b24630710
commit 211f85d981
33 changed files with 2793 additions and 405 deletions

View File

@@ -198,6 +198,21 @@ def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
assert len(list(db.scalars(select(AIDecision)).all())) == 1
def test_same_snapshot_with_different_issue_request_reuses_active_decision() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
first = issue_preview(client, request_id="issue-preview-same-snapshot-1")
second = issue_preview(client, request_id="issue-preview-same-snapshot-2")
assert second["decision_id"] == first["decision_id"]
with session_factory() as db:
decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all())
assert len(decisions) == 1
assert decisions[0].status == "issued"
def test_server_preview_decision_detects_server_side_field_edit() -> None:
client, session_factory = build_client()
with session_factory() as db:

View File

@@ -0,0 +1,407 @@
from __future__ import annotations
from collections.abc import Generator
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
get_optional_current_user,
)
from app.db.base import Base
from app.main import create_app
from app.models.agent_conversation import AgentConversation
from app.schemas.orchestrator import (
OrchestratorRequest,
OrchestratorResponse,
OrchestratorTraceSummary,
)
from app.services.orchestrator import OrchestratorService
@pytest.fixture
def http_context() -> Generator[
tuple[TestClient, FastAPI, 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()
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, app, session_factory
finally:
client.close()
app.dependency_overrides.clear()
engine.dispose()
def authenticated_user() -> CurrentUserContext:
return CurrentUserContext(
username="signed-in@example.com",
name="登录用户",
role_codes=["user"],
is_admin=False,
tenant_id="tenant-signed-in",
department_name="财务共享部",
cost_center="CC-SIGNED-IN",
position="费用专员",
grade="P5",
employee_no="E-SIGNED-IN",
manager_name="直属经理",
employee_id="employee-signed-in",
auth_session_id="session-signed-in",
)
def install_authenticated_user(app: FastAPI) -> CurrentUserContext:
current_user = authenticated_user()
app.dependency_overrides[get_current_user] = lambda: current_user
app.dependency_overrides[get_optional_current_user] = lambda: current_user
return current_user
def install_admin_user(app: FastAPI) -> CurrentUserContext:
current_user = authenticated_user()
current_user.role_codes = ["admin"]
current_user.is_admin = True
app.dependency_overrides[get_current_user] = lambda: current_user
app.dependency_overrides[get_optional_current_user] = lambda: current_user
return current_user
@pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"])
def test_orchestrator_run_requires_authentication_for_every_external_source(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
source: str,
) -> None:
client, _, _ = http_context
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": source,
"user_id": "forged-user@example.com",
"message": "帮我申请差旅费用",
"context_json": {},
},
)
assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
assert response.json()["detail"] == "请先登录后再使用智能助手。"
@pytest.mark.parametrize("source", ["system_event", "schedule"])
def test_non_admin_cannot_trigger_privileged_orchestrator_sources(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
source: str,
) -> None:
client, app, _ = http_context
install_authenticated_user(app)
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": source,
"user_id": "forged-admin@example.com",
"message": "运行全局风险扫描",
"context_json": {
"role_codes": ["admin"],
"is_admin": True,
},
},
)
assert response.status_code == 403
assert response.json()["detail"] == "只有平台管理员可以从外部触发调度或系统事件。"
@pytest.mark.parametrize("source", ["system_event", "schedule"])
def test_admin_can_trigger_privileged_orchestrator_sources(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
monkeypatch: pytest.MonkeyPatch,
source: str,
) -> None:
client, app, _ = http_context
expected_user = install_admin_user(app)
captured: dict[str, object] = {}
def fake_run(
self: OrchestratorService,
payload: OrchestratorRequest,
*,
current_user: CurrentUserContext | None = None,
) -> OrchestratorResponse:
del self
captured["source"] = payload.source
captured["current_user"] = current_user
return OrchestratorResponse(
run_id="run-admin-source",
selected_agent="hermes",
route_reason="test_admin_source",
permission_level="read",
status="succeeded",
result={},
requires_confirmation=False,
trace_summary=OrchestratorTraceSummary(
scenario="system",
intent="risk_check",
),
)
monkeypatch.setattr(OrchestratorService, "run", fake_run)
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": source,
"message": "运行全局风险扫描",
"context_json": {},
},
)
assert response.status_code == 200, response.text
assert captured["source"] == source
assert captured["current_user"] == expected_user
@pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"])
def test_authenticated_payload_overrides_all_scheduler_identity_aliases(
source: str,
) -> None:
current_user = authenticated_user()
payload = OrchestratorRequest(
source=source,
user_id="forged-user@example.com",
message="测试身份覆盖",
context_json={
"username": "forged-user@example.com",
"user_id": "forged-user@example.com",
"tenant_id": "tenant-forged",
"role_codes": ["admin"],
"is_admin": True,
"requested_by_username": "forged-requester@example.com",
"requested_by_name": "伪造请求人",
"actor": "forged-actor@example.com",
"actor_id": "forged-actor-id",
"auth_session_id": "forged-session",
},
)
trusted = OrchestratorService._build_authenticated_user_payload(payload, current_user)
assert trusted.user_id == current_user.username
assert trusted.context_json["tenant_id"] == current_user.tenant_id
assert trusted.context_json["role_codes"] == current_user.role_codes
assert trusted.context_json["is_admin"] is current_user.is_admin
assert trusted.context_json["requested_by_username"] == current_user.username
assert trusted.context_json["requested_by_name"] == current_user.name
assert trusted.context_json["actor"] == current_user.username
assert trusted.context_json["actor_id"] == current_user.username
assert "auth_session_id" not in trusted.context_json
def test_authenticated_run_passes_server_identity_to_orchestrator(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, app, _ = http_context
expected_user = install_authenticated_user(app)
captured: dict[str, object] = {}
def fake_run(
self: OrchestratorService,
payload: OrchestratorRequest,
*,
current_user: CurrentUserContext | None = None,
) -> OrchestratorResponse:
del self
captured["payload"] = payload
captured["current_user"] = current_user
return OrchestratorResponse(
run_id="run-auth-binding",
conversation_id="conversation-auth-binding",
selected_agent="user_agent",
route_reason="test",
permission_level="user",
status="succeeded",
result={},
requires_confirmation=False,
trace_summary=OrchestratorTraceSummary(
scenario="expense",
intent="operate",
),
)
monkeypatch.setattr(OrchestratorService, "run", fake_run)
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "forged-user@example.com",
"message": "保存申请草稿",
"context_json": {
"username": "forged-user@example.com",
"tenant_id": "tenant-forged",
"role_codes": ["admin"],
"is_admin": True,
"employee_no": "E-FORGED",
"auth_session_id": "session-forged",
},
},
)
assert response.status_code == 200, response.text
assert captured["current_user"] == expected_user
assert captured["payload"].user_id == "forged-user@example.com"
assert captured["payload"].context_json["tenant_id"] == "tenant-forged"
def test_latest_conversation_uses_authenticated_username(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
) -> None:
client, app, session_factory = http_context
current_user = install_authenticated_user(app)
with session_factory() as db:
db.add_all(
[
AgentConversation(
conversation_id="conversation-signed-in",
user_id=current_user.username,
source="user_message",
title="登录用户会话",
state_json={
"tenant_id": current_user.tenant_id,
"session_type": "expense",
},
),
AgentConversation(
conversation_id="conversation-same-user-other-tenant",
user_id=current_user.username,
source="user_message",
title="其他租户同名用户会话",
state_json={
"tenant_id": "tenant-other",
"session_type": "expense",
"application_preview_decision": {
"decision_id": "decision-other-tenant",
"application_preview": {
"fields": {"reason": "其他租户敏感项目"}
},
},
},
),
AgentConversation(
conversation_id="conversation-forged-user",
user_id="forged-user@example.com",
source="user_message",
title="伪造用户会话",
state_json={
"tenant_id": "tenant-forged",
"session_type": "expense",
},
),
]
)
db.commit()
response = client.get(
"/api/v1/orchestrator/conversations/latest",
params={"user_id": "forged-user@example.com", "session_type": "expense"},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["found"] is True
assert payload["conversation"]["conversation_id"] == "conversation-signed-in"
assert payload["conversation"]["user_id"] == current_user.username
def test_delete_conversations_uses_authenticated_username(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
) -> None:
client, app, session_factory = http_context
current_user = install_authenticated_user(app)
with session_factory() as db:
db.add_all(
[
AgentConversation(
conversation_id="conversation-owner-single",
user_id=current_user.username,
source="user_message",
state_json={
"tenant_id": current_user.tenant_id,
"session_type": "expense",
},
),
AgentConversation(
conversation_id="conversation-owner-bulk",
user_id=current_user.username,
source="user_message",
state_json={
"tenant_id": current_user.tenant_id,
"session_type": "expense",
},
),
AgentConversation(
conversation_id="conversation-same-user-other-tenant",
user_id=current_user.username,
source="user_message",
state_json={
"tenant_id": "tenant-other",
"session_type": "expense",
},
),
AgentConversation(
conversation_id="conversation-attacker",
user_id="forged-user@example.com",
source="user_message",
state_json={
"tenant_id": "tenant-forged",
"session_type": "expense",
},
),
]
)
db.commit()
single_response = client.delete(
"/api/v1/orchestrator/conversations/conversation-owner-single",
params={"user_id": "forged-user@example.com"},
)
bulk_response = client.delete(
"/api/v1/orchestrator/conversations",
params={"user_id": "forged-user@example.com", "session_type": "expense"},
)
assert single_response.status_code == 200, single_response.text
assert single_response.json()["deleted_count"] == 1
assert bulk_response.status_code == 200, bulk_response.text
assert bulk_response.json()["deleted_count"] == 1
with session_factory() as db:
remaining = list(db.scalars(select(AgentConversation)).all())
assert {item.conversation_id for item in remaining} == {
"conversation-attacker",
"conversation-same-user-other-tenant",
}

View File

@@ -4,13 +4,16 @@ from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
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 CurrentUserContext
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.models.agent_run import AgentRun
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.ontology import OntologyParseResult, OntologyPermission
@@ -881,3 +884,135 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block(
assert "当前仅返回确认摘要" not in submitted.result["answer"]
assert "申请单据已生成,并已进入审批流程" in submitted.result["answer"]
assert submitted.result["draft_payload"]["status"] == "submitted"
def test_authenticated_orchestrator_application_draft_consumes_server_preview_decision(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
def parse_application_for_run(self, request, run_id): # noqa: ANN001
return OntologyParseResult(
scenario="expense",
intent="operate",
entities=[],
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="费用申请由可信预览工作流执行。",
),
confidence=0.99,
missing_slots=[],
ambiguity=[],
clarification_required=False,
clarification_question=None,
run_id=run_id,
)
monkeypatch.setattr(
"app.services.ontology.SemanticOntologyService.parse_for_run",
parse_application_for_run,
)
session_factory = build_session_factory()
current_user = CurrentUserContext(
username="trusted-application@example.com",
name="可信申请员工",
role_codes=["user"],
is_admin=False,
tenant_id="tenant-orchestrator-preview",
department_name="交付部",
position="实施顾问",
grade="P4",
employee_no="E-ORCH-001",
employee_id="employee-orchestrator-preview",
manager_name="陈硕",
auth_session_id="session-orchestrator-preview",
)
context_json = {
"session_type": "application",
"entry_source": "application",
"user_id": "forged@example.com",
"tenant_id": "forged-tenant",
"is_admin": True,
"manager_name": "伪造审批人",
}
with session_factory() as db:
service = OrchestratorService(db)
first = service.run(
OrchestratorRequest(
source="user_message",
user_id="forged@example.com",
message=(
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
),
context_json=context_json,
),
current_user=current_user,
)
preview = service.run(
OrchestratorRequest(
source="user_message",
user_id="forged@example.com",
conversation_id=first.conversation_id,
message="飞机",
context_json=context_json,
),
current_user=current_user,
)
decision_id = str(preview.result.get("decision_id") or "")
assert preview.status == "blocked"
assert preview.requires_confirmation is True
assert decision_id
assert preview.result["application_preview"]["decisionId"] == decision_id
decision = db.get(AIApplicationPreviewDecision, decision_id)
assert decision is not None
assert decision.status == "issued"
assert decision.actor_id == current_user.employee_id
assert decision.tenant_id == current_user.tenant_id
conversation = service.conversation_service.get_conversation(first.conversation_id or "")
assert conversation is not None
assert conversation.state_json["application_preview_decision"]["decision_id"] == decision_id
saved = service.run(
OrchestratorRequest(
source="user_message",
user_id="another-forged@example.com",
conversation_id=first.conversation_id,
message="保存草稿",
context_json={
**context_json,
"application_preview_decision": {
"decision_id": "forged-decision",
"status": "issued",
},
},
),
current_user=current_user,
)
assert saved.status == "succeeded", saved.result
assert saved.result["draft_payload"]["status"] == "draft"
assert saved.result["draft_payload"]["draft_type"] == "expense_application"
assert saved.result["draft_payload"]["claim_no"]
assert saved.result["decision_id"]
assert saved.result["decision_id"] != decision_id
db.refresh(decision)
assert decision.status == "consumed"
assert decision.consumed_action == "save_draft"
learning_decision = db.scalar(
select(AIDecision).where(AIDecision.preview_decision_id == decision_id)
)
assert learning_decision is not None
feedback = db.scalar(
select(AIDecisionFeedback).where(
AIDecisionFeedback.decision_id == learning_decision.id
)
)
assert feedback is not None
assert feedback.feedback_type == "accepted"

View File

@@ -13,6 +13,8 @@ from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.agent_conversation import AgentConversation
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecisionFeedback
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
@@ -72,7 +74,11 @@ def auth_headers() -> dict[str, str]:
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E90001",
"x-auth-employee-id": "steward-action-employee",
"x-auth-session-id": "session-steward-action",
"x-auth-tenant-id": "tenant-steward-action",
"x-auth-role-codes": "user",
"x-auth-department": "Delivery",
"x-auth-position": "Engineer",
"x-auth-grade": "P4",
"x-auth-manager-name": "Leader",
@@ -130,6 +136,33 @@ def claim_count(db: Session) -> int:
return len(db.scalars(select(ExpenseClaim)).all())
def issue_application_preview(
client: TestClient,
*,
conversation_id: str,
client_trace_id: str,
task: dict[str, object] | None = None,
) -> dict[str, object]:
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "build_application_preview",
"message": (
"2026-02-20 至 2026-02-23去上海出差"
"辅助国网仿生产服务器部署交通火车申请金额3000元"
),
"conversation_id": conversation_id,
"client_trace_id": client_trace_id,
"task": task or base_application_task(),
},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["status"] == "succeeded", payload
return payload["result_payload"]
def seed_approved_application(db: Session) -> None:
application = ExpenseClaim(
id="application-action-approved",
@@ -249,16 +282,108 @@ def test_steward_action_executor_records_pending_interrupt_in_conversation_state
assert checkpoint["actions"]["trace-submit-pending"]["status"] == "needs_confirmation"
def test_steward_action_executor_builds_canonical_application_preview_decision() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
first = issue_application_preview(
client,
conversation_id="conv-action-preview",
client_trace_id="trace-build-preview",
)
second = issue_application_preview(
client,
conversation_id="conv-action-preview",
client_trace_id="trace-build-preview",
)
assert first["decision_id"]
assert first["decision_id"] == second["decision_id"]
assert first["application_preview"]["decisionId"] == first["decision_id"]
assert first["application_preview"]["fields"]["location"] == "上海市"
assert second["idempotent_replay"] is True
def test_steward_action_checkpoint_does_not_replay_across_tenants() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
shared_conversation_id = "conv-action-cross-tenant"
shared_trace_id = "trace-build-cross-tenant"
tenant_a_headers = {
**auth_headers(),
"x-auth-tenant-id": "tenant-steward-a",
}
tenant_b_headers = {
**auth_headers(),
"x-auth-tenant-id": "tenant-steward-b",
}
request_payload = {
"action_type": "build_application_preview",
"message": (
"2026-02-20 至 2026-02-23去上海出差"
"辅助国网仿生产服务器部署交通火车申请金额3000元"
),
"conversation_id": shared_conversation_id,
"client_trace_id": shared_trace_id,
"task": base_application_task(),
}
tenant_a_response = client.post(
"/api/v1/steward/actions/execute",
headers=tenant_a_headers,
json=request_payload,
)
tenant_b_response = client.post(
"/api/v1/steward/actions/execute",
headers=tenant_b_headers,
json=request_payload,
)
assert tenant_a_response.status_code == 200, tenant_a_response.text
assert tenant_b_response.status_code == 200, tenant_b_response.text
tenant_a_payload = tenant_a_response.json()
tenant_b_payload = tenant_b_response.json()
assert tenant_a_payload["status"] == "succeeded"
assert tenant_b_payload["status"] == "succeeded"
assert tenant_b_payload["result_payload"].get("idempotent_replay") is not True
assert (
tenant_a_payload["result_payload"]["decision_id"]
!= tenant_b_payload["result_payload"]["decision_id"]
)
with session_factory() as db:
decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all())
assert {decision.tenant_id for decision in decisions} == {
"tenant-steward-a",
"tenant-steward-b",
}
conversations = list(db.scalars(select(AgentConversation)).all())
assert len(conversations) == 2
assert {conversation.state_json.get("tenant_id") for conversation in conversations} == {
"tenant-steward-a",
"tenant-steward-b",
}
def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_duplicate_draft() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_application_preview(
client,
conversation_id="conv-action-draft",
client_trace_id="trace-build-draft-preview",
)
request_payload = {
"action_type": "save_application_draft",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿",
"conversation_id": "conv-action-draft",
"client_trace_id": "trace-save-draft",
"decision_id": issued["decision_id"],
"task": base_application_task("save_draft"),
}
first_response = client.post(
@@ -325,12 +450,20 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N
with session_factory() as db:
seed_employee(db)
issued = issue_application_preview(
client,
conversation_id="conv-action-save",
client_trace_id="trace-build-save-preview",
)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "save_application_draft",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿",
"conversation_id": "conv-action-save",
"client_trace_id": "trace-save-application-action",
"decision_id": issued["decision_id"],
"task": base_application_task("save_draft"),
},
)
@@ -342,22 +475,116 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "draft"
assert draft_payload["claim_no"].startswith("A")
assert payload["result_payload"]["decision_id"]
assert payload["result_payload"]["decision_id"] != issued["decision_id"]
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "draft"
assert claim.reason == "辅助国网仿生产服务器部署"
consumed_decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert consumed_decision is not None
assert consumed_decision.status == "consumed"
feedback = db.scalars(select(AIDecisionFeedback)).one()
assert feedback.verification_status == "server_verified"
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"
assert event.correlation_id == "application-preview-action:conv-action-save"
link = db.scalars(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
).one()
assert link.relation_type == "application"
def test_steward_action_executor_blocks_save_without_decision_and_stable_trace() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "save_application_draft",
"message": "保存申请草稿",
"task": base_application_task("save_draft"),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "blocked"
assert payload["blocked_reasons"] == ["missing_decision_id"]
issued = issue_application_preview(
client,
conversation_id="conv-action-missing-trace",
client_trace_id="trace-build-missing-trace",
)
missing_trace_response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "save_application_draft",
"message": "保存申请草稿",
"conversation_id": "conv-action-missing-trace",
"decision_id": issued["decision_id"],
"task": base_application_task("save_draft"),
},
)
missing_trace_payload = missing_trace_response.json()
assert missing_trace_payload["status"] == "blocked"
assert missing_trace_payload["blocked_reasons"] == ["missing_client_trace_id"]
with session_factory() as db:
assert claim_count(db) == 0
def test_steward_action_executor_submits_verified_application_after_confirmation_and_precheck() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_application_preview(
client,
conversation_id="conv-action-verified-submit",
client_trace_id="trace-build-submit-preview",
task=base_application_task("submit"),
)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "submit_application",
"message": (
"2026-02-20 至 2026-02-23去上海出差"
"辅助国网仿生产服务器部署,交通火车,直接提交"
),
"conversation_id": "conv-action-verified-submit",
"client_trace_id": "trace-submit-verified-application",
"decision_id": issued["decision_id"],
"task": base_application_task("submit"),
"confirmed": True,
"context_json": {
"precheck_result": {
"status": "ok",
"blocking": False,
}
},
},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["draft_payload"]["status"] == "submitted"
assert payload["result_payload"]["decision_id"] is None
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "submitted"
def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> None:
client, session_factory = build_client()
with session_factory() as db: