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()