feat(ai): unify verified expense application workflow
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user