Files
X-Financial/server/tests/test_expense_workflow_learning.py

448 lines
17 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_workflow_learning import ExpenseWorkflowLearningService
from app.services.risk_dispositions import RiskDispositionService
def test_workflow_events_create_idempotent_verified_learning_evidence() -> None:
with _build_session() as db:
claim, decision, pre_review = _seed_submitted_decision(db, tenant_id="tenant-a")
service = ExpenseWorkflowLearningService(db)
service.record_prior_events_for_decision(decision)
service.record_prior_events_for_decision(decision)
audit_outcome = db.scalar(
select(WorkflowOutcome).where(WorkflowOutcome.business_event_id == pre_review.id)
)
audit_feedback = db.scalar(
select(AIDecisionFeedback).where(
AIDecisionFeedback.correlation_id == pre_review.correlation_id,
AIDecisionFeedback.action_type == "audit_cleared",
)
)
assert audit_outcome is not None
assert audit_outcome.outcome_type == "audit_cleared"
assert audit_outcome.outcome_status == "verified"
assert audit_feedback is not None
assert audit_feedback.feedback_type == "accepted"
assert audit_feedback.verification_status == "server_verified"
assert audit_feedback.training_eligible is True
assert decision.training_eligible is True
assert "客户端伪造审计备注" not in str(audit_outcome.result_json)
assert "客户端伪造审计备注" not in str(audit_feedback.final_value_json)
claim.status = "returned"
claim.approval_stage = "待提交"
_, returned = ExpenseCaseService(db).record_claim_event(
claim,
event_type="application_returned",
actor_id="manager-a",
tenant_id="tenant-a",
correlation_id="return-flow-1",
idempotency_key="return-flow-1",
extra_payload={"reason": "不要把自由文本变成高置信记忆"},
)
_, replayed = ExpenseCaseService(db).record_claim_event(
claim,
event_type="application_returned",
actor_id="manager-a",
tenant_id="tenant-a",
correlation_id="return-flow-1",
idempotency_key="return-flow-1",
extra_payload={"reason": "重放时也不得进入学习证据"},
)
assert replayed.id == returned.id
return_outcome = db.scalar(
select(WorkflowOutcome).where(WorkflowOutcome.business_event_id == returned.id)
)
return_feedback = db.scalar(
select(AIDecisionFeedback).where(AIDecisionFeedback.action_type == "workflow_returned")
)
assert return_outcome is not None
assert return_outcome.outcome_type == "workflow_returned"
assert "自由文本" not in str(return_outcome.result_json)
assert return_feedback is not None
assert return_feedback.feedback_type == "rejected"
assert return_feedback.verification_status == "human_verified"
assert return_feedback.changed_fields_json == []
assert db.scalar(select(func.count()).select_from(WorkflowOutcome)) == 2
assert db.scalar(select(func.count()).select_from(AIDecisionFeedback)) == 2
def test_approval_pass_override_and_payment_are_separate_outcomes() -> None:
with _build_session() as db:
claim, decision, _ = _seed_submitted_decision(db, tenant_id="tenant-a")
case_service = ExpenseCaseService(db)
claim.approval_stage = "财务审批"
_, passed = case_service.record_claim_event(
claim,
event_type="approval_stage_completed",
actor_id="manager-a",
tenant_id="tenant-a",
correlation_id="approval-pass-1",
idempotency_key="approval-pass-1",
extra_payload={"opinion": "自由审批意见不进入反馈"},
)
_, overridden = case_service.record_claim_event(
claim,
event_type="approval_stage_completed",
actor_id="finance-a",
tenant_id="tenant-a",
correlation_id="approval-override-1",
idempotency_key="approval-override-1",
extra_payload={
"ai_decision_overridden": True,
"opinion": "覆盖原因也不进入高置信证据",
},
)
claim.status = "paid"
claim.approval_stage = "已付款"
_, paid = case_service.record_claim_event(
claim,
event_type="payment_completed",
actor_id="finance-a",
tenant_id="tenant-a",
correlation_id="payment-1",
idempotency_key="payment-1",
extra_payload={"payment_note": "付款备注不得被学习"},
)
outcomes = list(
db.scalars(
select(WorkflowOutcome).where(
WorkflowOutcome.business_event_id.in_([passed.id, overridden.id, paid.id])
)
).all()
)
assert {item.outcome_type for item in outcomes} == {
"approval_passed",
"approval_overridden",
"payment_completed",
}
assert all(item.tenant_id == "tenant-a" for item in outcomes)
assert all(item.decision_id == decision.id for item in outcomes)
assert "审批意见" not in str([item.result_json for item in outcomes])
assert "付款备注" not in str([item.result_json for item in outcomes])
feedback = list(
db.scalars(
select(AIDecisionFeedback).where(
AIDecisionFeedback.correlation_id.in_(
["approval-pass-1", "approval-override-1", "payment-1"]
)
)
).all()
)
assert {(item.action_type, item.feedback_type) for item in feedback} == {
("approval_passed", "accepted"),
("approval_overridden", "rejected"),
}
def test_trusted_outcome_does_not_promote_unverified_client_decision_for_training() -> None:
with _build_session() as db:
_, decision, pre_review = _seed_submitted_decision(
db,
tenant_id="tenant-client-observed",
)
decision.evidence_json = {"trust_level": "behavioral_analytics_only"}
db.flush()
ExpenseWorkflowLearningService(db).record_prior_events_for_decision(decision)
outcome = db.scalar(
select(WorkflowOutcome).where(WorkflowOutcome.business_event_id == pre_review.id)
)
feedback = db.scalar(
select(AIDecisionFeedback).where(
AIDecisionFeedback.correlation_id == pre_review.correlation_id
)
)
assert outcome is not None
assert outcome.outcome_status == "verified"
assert feedback is not None
assert feedback.verification_status == "server_verified"
assert feedback.training_eligible is False
assert decision.training_eligible is False
def test_later_workflow_event_only_updates_latest_submitted_ai_decision() -> None:
with _build_session() as db:
claim, first_decision, _ = _seed_submitted_decision(db, tenant_id="tenant-latest")
case_service = ExpenseCaseService(db)
_, second_submission = case_service.record_claim_event(
claim,
event_type="application_submitted",
actor_id="owner-a",
tenant_id="tenant-latest",
correlation_id="submit-flow-2",
idempotency_key="application-submit-2",
)
second_decision = AIDecision(
id="decision-tenant-latest-2",
tenant_id="tenant-latest",
expense_case_id=first_decision.expense_case_id,
business_event_id=second_submission.id,
expense_claim_id=claim.id,
correlation_id=second_submission.correlation_id,
subject_type="expense_claim",
subject_id=claim.id,
decision_type="expense_application_prefill",
decision_source="hybrid",
status="executed",
automation_mode="prefill",
confidence=Decimal("0.9500"),
suggestion_json={"value_fingerprint": "hmac-sha256:second"},
evidence_json={"trust_level": "server_snapshot_verified"},
version_json={"schema_version": 1},
schema_version=1,
training_eligible=False,
idempotency_key="decision-tenant-latest-2-key",
content_fingerprint="sha256:" + "2" * 64,
created_at=datetime.now(UTC),
)
db.add(second_decision)
db.flush()
claim.status = "returned"
_, returned = case_service.record_claim_event(
claim,
event_type="application_returned",
actor_id="manager-a",
tenant_id="tenant-latest",
correlation_id="return-latest",
idempotency_key="return-latest",
)
outcomes = list(
db.scalars(
select(WorkflowOutcome).where(WorkflowOutcome.business_event_id == returned.id)
).all()
)
assert [item.decision_id for item in outcomes] == [second_decision.id]
def test_learning_bridge_rejects_forged_cross_tenant_event() -> None:
with _build_session() as db:
claim, _, _ = _seed_submitted_decision(db, tenant_id="tenant-a")
claim.status = "returned"
_, trusted = ExpenseCaseService(db).record_claim_event(
claim,
event_type="application_returned",
actor_id="manager-a",
tenant_id="tenant-a",
correlation_id="trusted-return",
idempotency_key="trusted-return",
)
forged = BusinessEvent(
id=trusted.id,
tenant_id="tenant-b",
expense_case_id=trusted.expense_case_id,
aggregate_type=trusted.aggregate_type,
aggregate_id=trusted.aggregate_id,
event_type=trusted.event_type,
event_version=trusted.event_version,
idempotency_key=trusted.idempotency_key,
correlation_id=trusted.correlation_id,
actor_id=trusted.actor_id,
actor_type=trusted.actor_type,
payload_json=trusted.payload_json,
delivery_status="pending",
occurred_at=trusted.occurred_at,
)
with pytest.raises(PermissionError, match="不存在或不属于"):
ExpenseWorkflowLearningService(db).record_event(forged)
assert all(
outcome.tenant_id == "tenant-a" for outcome in db.scalars(select(WorkflowOutcome)).all()
)
def test_typed_risk_disposition_emits_audit_outcome_without_comment() -> None:
with _build_session() as db:
claim, decision, _ = _seed_submitted_decision(db, tenant_id="tenant-a")
observation = RiskObservation(
id="risk-observation-1",
tenant_id="tenant-a",
observation_key="risk:tenant-a:claim-1",
subject_type="expense_claim",
subject_key=f"claim:{claim.id}",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
title="重复票据",
description="风险观察描述",
risk_score=88,
risk_level="high",
confidence_score=0.91,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="test",
algorithm_version="risk.v1",
status="pending_review",
feedback_status="unreviewed",
)
db.add(observation)
db.commit()
service = RiskDispositionService(db)
payload = RiskDispositionActionCreate(
action="confirm",
expected_version=0,
request_id="risk-confirm-1",
comment="该人工评论不得进入 AI 学习账本",
)
first = service.execute_action(
observation.id,
payload,
tenant_id="tenant-a",
actor_id="finance-a",
actor_name="财务甲",
)
replay = service.execute_action(
observation.id,
payload,
tenant_id="tenant-a",
actor_id="finance-a",
actor_name="财务甲",
)
assert replay.event.id == first.event.id
audit_events = list(
db.scalars(
select(BusinessEvent).where(
BusinessEvent.tenant_id == "tenant-a",
BusinessEvent.event_type == "audit_conclusion_recorded",
)
).all()
)
assert len(audit_events) == 1
assert audit_events[0].payload_json["audit_decision"] == "confirm"
assert "人工评论" not in str(audit_events[0].payload_json)
outcome = db.scalar(
select(WorkflowOutcome).where(WorkflowOutcome.business_event_id == audit_events[0].id)
)
assert outcome is not None
assert outcome.decision_id == decision.id
assert outcome.outcome_type == "audit_confirmed"
assert "人工评论" not in str(outcome.result_json)
assert (
db.scalar(
select(func.count())
.select_from(WorkflowOutcome)
.where(WorkflowOutcome.business_event_id == audit_events[0].id)
)
== 1
)
def _seed_submitted_decision(
db: Session,
*,
tenant_id: str,
) -> tuple[ExpenseClaim, AIDecision, BusinessEvent]:
now = datetime(2026, 7, 16, 9, 0, tzinfo=UTC)
claim = ExpenseClaim(
id=f"claim-{tenant_id}",
tenant_id=tenant_id,
claim_no=f"AP-{tenant_id.upper()}-001",
employee_name="测试员工",
department_name="研发部",
expense_type="travel_application",
reason="客户拜访",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=now,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.flush()
case_service = ExpenseCaseService(db)
_, pre_review = case_service.record_claim_event(
claim,
event_type="application_pre_review_completed",
actor_id="owner-a",
tenant_id=tenant_id,
correlation_id="submit-flow-1",
idempotency_key="pre-review-1",
update_case_state=False,
extra_payload={
"decision": "ready",
"comment": "客户端伪造审计备注",
},
)
claim.status = "submitted"
claim.approval_stage = "直属领导审批"
claim.submitted_at = now
expense_case, submitted = case_service.record_claim_event(
claim,
event_type="application_submitted",
actor_id="owner-a",
tenant_id=tenant_id,
correlation_id="submit-flow-1",
idempotency_key="application-submit-1",
)
decision = AIDecision(
id=f"decision-{tenant_id}",
tenant_id=tenant_id,
expense_case_id=expense_case.id,
business_event_id=submitted.id,
expense_claim_id=claim.id,
correlation_id=submitted.correlation_id,
subject_type="expense_claim",
subject_id=claim.id,
decision_type="expense_application_prefill",
decision_source="hybrid",
status="executed",
automation_mode="prefill",
confidence=Decimal("0.9000"),
suggestion_json={"value_fingerprint": "hmac-sha256:test"},
evidence_json={"trust_level": "server_snapshot_verified"},
version_json={"schema_version": 1},
schema_version=1,
training_eligible=False,
idempotency_key=f"decision-{tenant_id}-idempotency",
content_fingerprint="sha256:" + "1" * 64,
created_at=now,
)
db.add(decision)
db.flush()
return claim, decision, pre_review
def _build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return factory()