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

@@ -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: