448 lines
17 KiB
Python
448 lines
17 KiB
Python
|
|
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()
|