Files
X-Financial/server/tests/test_steward_action_executor.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

716 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from auth_helpers import install_legacy_header_auth_override
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 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
from app.services import attachment_association_jobs as attachment_jobs_module
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_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = build_session_factory()
app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def seed_employee(db: Session) -> None:
manager = Employee(
id="steward-action-manager",
tenant_id="tenant-steward-action",
employee_no="E90000",
name="李总",
email="leader@example.com",
position="部门负责人",
grade="P7",
)
employee = Employee(
id="steward-action-employee",
tenant_id="tenant-steward-action",
employee_no="E90001",
name="张三",
email="zhangsan@example.com",
position="实施工程师",
grade="P4",
manager=manager,
)
db.add_all([manager, employee])
db.commit()
def auth_headers() -> dict[str, str]:
return {
"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",
}
def base_application_task(requested_action: str = "save_draft") -> dict[str, object]:
return {
"task_id": "task_app_001",
"task_type": "expense_application",
"assigned_agent": "application_assistant",
"title": "上海出差申请",
"summary": "2026-02-20 至 2026-02-23 去上海出差,辅助国网仿生产服务器部署,火车出行。",
"status": "needs_confirmation",
"confidence": 0.96,
"requested_action": requested_action,
"ontology_fields": {
"expense_type": "travel",
"time_range": "2026-02-20 至 2026-02-23",
"location": "上海",
"reason": "辅助国网仿生产服务器部署",
"transport_mode": "train",
},
"missing_fields": [],
"confirmation_required": requested_action == "submit",
"action_steps": [],
}
def base_reimbursement_task() -> dict[str, object]:
return {
"task_id": "task_reim_001",
"task_type": "reimbursement",
"assigned_agent": "reimbursement_assistant",
"title": "客户现场交通费报销",
"summary": "2026-03-04 打车去客户现场,交通费 32 元。",
"status": "needs_confirmation",
"confidence": 0.9,
"requested_action": "save_draft",
"ontology_fields": {
"expense_type": "transport",
"time_range": "2026-03-04",
"location": "客户现场",
"reason": "客户现场沟通",
"amount": "32元",
"transport_mode": "taxi",
},
"missing_fields": [],
"confirmation_required": False,
"action_steps": [],
}
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",
tenant_id="tenant-steward-action",
claim_no="AAPPROVED1",
employee_id="steward-action-employee",
employee_name="张三",
department_id="dept-delivery",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="辅助国网仿生产服务器部署",
location="上海",
amount=3000,
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=None,
status="approved",
approval_stage="已完成",
risk_flags_json=[],
)
db.add(application)
db.commit()
def test_steward_action_executor_rejects_unknown_action_without_creating_claim() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
before_count = claim_count(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "delete_all_claims",
"message": "请执行未知动作",
"task": base_application_task(),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "blocked"
assert payload["action_type"] == "delete_all_claims"
assert "不支持" in payload["message"]
with session_factory() as db:
assert claim_count(db) == before_count
def test_steward_action_executor_blocks_attachment_action_without_receipts() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
before_count = claim_count(db)
task = base_reimbursement_task()
task["ontology_fields"] = {
**task["ontology_fields"],
"attachments": "taxi.png",
}
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "associate_attachments",
"message": "关联附件 taxi.png",
"task": task,
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "blocked"
assert "receipt_id" in payload["message"] or "票据" in payload["message"]
with session_factory() as db:
assert claim_count(db) == before_count
def test_steward_action_executor_records_pending_interrupt_in_conversation_state() -> 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": "submit_application",
"message": (
"2026-02-20 至 2026-02-23去上海出差"
"辅助国网仿生产服务器部署,交通火车,直接提交"
),
"conversation_id": "conv-action-submit",
"client_trace_id": "trace-submit-pending",
"task": base_application_task("submit"),
"confirmed": False,
"context_json": {
"precheck_result": {
"status": "ok",
"blocking": False,
}
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "needs_confirmation"
with session_factory() as db:
conversation = db.scalar(
select(AgentConversation).where(
AgentConversation.conversation_id == "conv-action-submit"
)
)
assert conversation is not None
checkpoint = conversation.state_json["steward_action_checkpoint"]
assert checkpoint["pending_interrupt"]["client_trace_id"] == "trace-submit-pending"
assert checkpoint["pending_interrupt"]["action_type"] == "submit_application"
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(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json=request_payload,
)
second_response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json=request_payload,
)
assert first_response.status_code == 200
assert second_response.status_code == 200
first_payload = first_response.json()
second_payload = second_response.json()
assert first_payload["status"] == "succeeded"
assert second_payload["status"] == "succeeded"
assert (
first_payload["result_payload"]["draft_payload"]["claim_id"]
== second_payload["result_payload"]["draft_payload"]["claim_id"]
)
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:
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": "submit_application",
"message": (
"2026-02-20 至 2026-02-23去上海出差"
"辅助国网仿生产服务器部署,交通火车,直接提交"
),
"task": base_application_task("submit"),
"confirmed": False,
"context_json": {
"precheck_result": {
"status": "ok",
"blocking": False,
}
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "needs_confirmation"
assert payload["requires_confirmation"] is True
with session_factory() as db:
assert claim_count(db) == 0
def test_steward_action_executor_saves_application_draft_from_action_step() -> None:
client, session_factory = build_client()
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"),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
draft_payload = payload["result_payload"]["draft_payload"]
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 == "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:
seed_employee(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "create_reimbursement_draft",
"message": "2026-03-04打车去客户现场交通费32元保存草稿",
"task": base_reimbursement_task(),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["status"] == "draft"
assert payload["result_payload"]["claim_id"]
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "draft"
assert claim.expense_type == "transport"
assert claim.reason == "客户现场沟通"
def test_steward_action_executor_links_application_when_creating_reimbursement_draft() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
seed_approved_application(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "link_existing_application",
"message": "关联申请单 AAPPROVED1并保存报销草稿",
"task": base_reimbursement_task(),
"context_json": {
"application_claim_id": "application-action-approved",
"application_claim_no": "AAPPROVED1",
"application_reason": "辅助国网仿生产服务器部署",
"application_location": "上海",
"application_business_time": "2026-02-20 至 2026-02-23",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["status"] == "draft"
with session_factory() as db:
claims = db.scalars(select(ExpenseClaim)).all()
reimbursement = next(claim for claim in claims if claim.id != "application-action-approved")
assert reimbursement.status == "draft"
link_flags = [
flag
for flag in list(reimbursement.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "application_link"
]
assert link_flags
assert link_flags[0]["application_claim_no"] == "AAPPROVED1"
def test_steward_action_executor_associates_receipt_attachments(monkeypatch) -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
calls: list[dict[str, object]] = []
def fake_run(self, *, receipt_ids, current_user):
calls.append({
"receipt_ids": list(receipt_ids),
"username": current_user.username,
})
return {
"claim_id": "claim-associated",
"claim_no": "BX-20260220-001",
"uploaded_count": 2,
"skipped_count": 0,
}
monkeypatch.setattr(attachment_jobs_module.AttachmentAssociationJobRunner, "run", fake_run)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "associate_attachments",
"message": "把两张火车票关联到报销草稿",
"task": base_reimbursement_task(),
"context_json": {
"receipt_ids": ["receipt-001", "receipt-002"],
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["claim_no"] == "BX-20260220-001"
assert calls == [
{
"receipt_ids": ["receipt-001", "receipt-002"],
"username": "zhangsan@example.com",
}
]