486 lines
17 KiB
Python
486 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, date, datetime, timedelta
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from app.db.base import Base
|
|
from app.models.approval_task import ApprovalTask
|
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
|
from app.models.risk_disposition import RiskDisposition
|
|
from app.models.risk_observation import RiskObservation
|
|
from app.services.approval_task_projection import ApprovalTaskProjectionService
|
|
from app.services.approval_task_projection_refresh import (
|
|
ApprovalTaskProjectionRefreshService,
|
|
)
|
|
from app.services.risk_observations import RiskObservationService
|
|
|
|
|
|
def _claim(
|
|
*,
|
|
claim_id: str = "claim-projection-1",
|
|
claim_no: str = "RE-PROJECTION-1",
|
|
amount: str = "888.00",
|
|
invoice_count: int = 1,
|
|
risk_flags: list[dict] | None = None,
|
|
) -> ExpenseClaim:
|
|
occurred_at = datetime(2026, 7, 15, 9, 0, tzinfo=UTC)
|
|
claim = ExpenseClaim(
|
|
id=claim_id,
|
|
claim_no=claim_no,
|
|
employee_name="张三",
|
|
department_name="市场部",
|
|
project_code="PRJ-PROJECTION",
|
|
expense_type="application" if claim_no.startswith("AP-") else "travel",
|
|
reason="客户现场差旅",
|
|
location="上海",
|
|
amount=Decimal(amount),
|
|
currency="CNY",
|
|
invoice_count=invoice_count,
|
|
occurred_at=occurred_at,
|
|
submitted_at=occurred_at + timedelta(hours=1),
|
|
status="submitted",
|
|
approval_stage="直属领导审批",
|
|
risk_flags_json=list(risk_flags or []),
|
|
created_at=occurred_at,
|
|
updated_at=occurred_at + timedelta(hours=1),
|
|
)
|
|
claim.items = [
|
|
ExpenseClaimItem(
|
|
id=f"item-{claim_id}",
|
|
claim_id=claim_id,
|
|
item_date=date(2026, 7, 15),
|
|
item_type="hotel",
|
|
item_reason="住宿",
|
|
item_location="上海",
|
|
item_note="",
|
|
item_amount=Decimal(amount),
|
|
invoice_id="INV-PROJECTION" if invoice_count else None,
|
|
created_at=occurred_at,
|
|
updated_at=occurred_at,
|
|
)
|
|
]
|
|
return claim
|
|
|
|
|
|
def test_projection_materializes_explainable_priority_and_safe_batch_blocks() -> None:
|
|
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
|
claim = _claim(
|
|
amount="60000.00",
|
|
risk_flags=[
|
|
{
|
|
"source": "ai_pre_review",
|
|
"severity": "high",
|
|
"disposition": "review",
|
|
"route_decision": {"budget_result": {"metrics": {"after_usage_rate": "0.96"}}},
|
|
}
|
|
],
|
|
)
|
|
|
|
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
|
|
claim,
|
|
tenant_id="tenant-a",
|
|
entered_at=now - timedelta(hours=26),
|
|
now=now,
|
|
observation_rows=[],
|
|
)
|
|
|
|
reason_codes = {item["code"] for item in projection.priority_reasons_json}
|
|
assert projection.priority_score >= 85
|
|
assert projection.priority_tier == "urgent"
|
|
assert projection.risk_level == "high"
|
|
assert projection.open_risk_count == 1
|
|
assert projection.due_at == now - timedelta(hours=2)
|
|
assert projection.next_escalation_at == projection.due_at
|
|
assert reason_codes >= {
|
|
"open_risk",
|
|
"sla_overdue",
|
|
"budget_pressure",
|
|
"large_amount",
|
|
}
|
|
assert projection.batch_eligible is False
|
|
assert set(projection.batch_block_reasons_json) >= {
|
|
"open_risk",
|
|
"amount_requires_individual_review",
|
|
"budget_pressure",
|
|
"sla_overdue",
|
|
}
|
|
|
|
|
|
def test_resolved_materialized_risk_suppresses_stale_raw_flag_and_application_invoice() -> None:
|
|
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
|
claim = _claim(
|
|
claim_id="claim-projection-resolved",
|
|
claim_no="AP-PROJECTION-1",
|
|
invoice_count=0,
|
|
risk_flags=[
|
|
{
|
|
"severity": "critical",
|
|
"triggered": True,
|
|
"observation_key": "risk:projection:resolved",
|
|
}
|
|
],
|
|
)
|
|
observation = RiskObservation(
|
|
id="observation-projection-resolved",
|
|
tenant_id="tenant-a",
|
|
observation_key="risk:projection:resolved",
|
|
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=98,
|
|
risk_level="critical",
|
|
confidence_score=0.99,
|
|
control_stage="expense_application",
|
|
control_mode="risk_observation",
|
|
automation_mode="manual",
|
|
source="financial_risk_graph",
|
|
algorithm_version="v1",
|
|
status="resolved",
|
|
feedback_status="confirmed",
|
|
)
|
|
disposition = RiskDisposition(
|
|
id="disposition-projection-resolved",
|
|
tenant_id="tenant-a",
|
|
observation_id=observation.id,
|
|
adjudication="confirmed",
|
|
lifecycle_status="resolved",
|
|
version=2,
|
|
)
|
|
|
|
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
|
|
claim,
|
|
tenant_id="tenant-a",
|
|
entered_at=now - timedelta(hours=1),
|
|
now=now,
|
|
observation_rows=[(observation, disposition)],
|
|
)
|
|
|
|
assert projection.risk_level == "low"
|
|
assert projection.open_risk_count == 0
|
|
assert projection.evidence_completeness == Decimal("1.0000")
|
|
assert projection.priority_score == 0
|
|
assert projection.priority_reasons_json[0]["code"] == "routine"
|
|
assert projection.batch_eligible is True
|
|
assert projection.batch_block_reasons_json == ()
|
|
|
|
|
|
def test_projection_marks_missing_evidence_and_apply_updates_task_without_persistence() -> None:
|
|
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
|
claim = _claim(invoice_count=0)
|
|
claim.location = ""
|
|
claim.items = []
|
|
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
|
|
claim,
|
|
tenant_id="tenant-a",
|
|
entered_at=now - timedelta(hours=1),
|
|
now=now,
|
|
observation_rows=[],
|
|
)
|
|
task = ApprovalTask()
|
|
|
|
returned = ApprovalTaskProjectionService.apply(task, projection)
|
|
|
|
assert returned is task
|
|
assert task.evidence_completeness == Decimal("0.2500")
|
|
assert task.batch_eligible is False
|
|
assert "evidence_incomplete" in task.batch_block_reasons_json
|
|
assert {item["code"] for item in task.priority_reasons_json} >= {"evidence_gap"}
|
|
|
|
|
|
def test_projection_rejects_ambiguous_time_and_invalid_sla() -> None:
|
|
claim = _claim()
|
|
service = ApprovalTaskProjectionService(None) # type: ignore[arg-type]
|
|
with pytest.raises(ValueError, match="entered_at.*timezone"):
|
|
service.build(
|
|
claim,
|
|
tenant_id="tenant-a",
|
|
entered_at=datetime(2026, 7, 16, 10, 0),
|
|
observation_rows=[],
|
|
)
|
|
with pytest.raises(ValueError, match="sla_hours"):
|
|
service.build(
|
|
claim,
|
|
tenant_id="tenant-a",
|
|
entered_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
|
sla_hours=0,
|
|
observation_rows=[],
|
|
)
|
|
|
|
|
|
def test_apply_convenience_form_uses_claim_session_for_tenant_risks() -> None:
|
|
engine = create_engine(
|
|
"sqlite+pysqlite:///:memory:",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(bind=engine)
|
|
db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
|
with db_factory() as db:
|
|
claim = _claim(claim_id="claim-projection-session")
|
|
observation = RiskObservation(
|
|
id="observation-projection-session",
|
|
tenant_id="tenant-a",
|
|
observation_key="risk:projection:session",
|
|
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="policy_violation",
|
|
risk_signal="policy_violation",
|
|
title="政策风险",
|
|
description="等待人工处置。",
|
|
risk_score=80,
|
|
risk_level="high",
|
|
confidence_score=0.9,
|
|
control_stage="reimbursement",
|
|
control_mode="risk_observation",
|
|
automation_mode="manual",
|
|
source="financial_risk_graph",
|
|
algorithm_version="v1",
|
|
status="pending_review",
|
|
feedback_status="unreviewed",
|
|
)
|
|
db.add_all([claim, observation])
|
|
db.flush()
|
|
task = ApprovalTask(
|
|
tenant_id="tenant-a",
|
|
entered_at=now - timedelta(hours=1),
|
|
sla_hours_snapshot=12,
|
|
)
|
|
|
|
returned = ApprovalTaskProjectionService.apply(task, claim=claim, now=now)
|
|
|
|
assert returned is task
|
|
assert task.risk_level == "high"
|
|
assert task.open_risk_count == 1
|
|
assert task.due_at == now + timedelta(hours=11)
|
|
|
|
|
|
def test_projection_filters_other_business_stage_and_only_honors_active_waiver() -> None:
|
|
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
|
claim = _claim(
|
|
claim_id="claim-projection-waiver",
|
|
claim_no="AP-PROJECTION-WAIVER",
|
|
risk_flags=[
|
|
{
|
|
"severity": "critical",
|
|
"triggered": True,
|
|
"business_stage": "reimbursement",
|
|
}
|
|
],
|
|
)
|
|
other_stage = RiskObservation(
|
|
id="observation-other-stage",
|
|
tenant_id="tenant-a",
|
|
observation_key="risk:projection:other-stage",
|
|
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="invoice",
|
|
risk_signal="invoice",
|
|
risk_level="critical",
|
|
control_stage="reimbursement",
|
|
status="pending_review",
|
|
feedback_status="unreviewed",
|
|
)
|
|
waived = RiskObservation(
|
|
id="observation-active-waiver",
|
|
tenant_id="tenant-a",
|
|
observation_key="risk:projection:active-waiver",
|
|
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="policy",
|
|
risk_signal="policy",
|
|
risk_level="high",
|
|
control_stage="expense_application",
|
|
status="pending_review",
|
|
feedback_status="confirmed",
|
|
)
|
|
disposition = RiskDisposition(
|
|
tenant_id="tenant-a",
|
|
observation_id=waived.id,
|
|
adjudication="confirmed",
|
|
lifecycle_status="waived",
|
|
waiver_expires_at=now + timedelta(days=1),
|
|
waiver_decision="approved",
|
|
)
|
|
service = ApprovalTaskProjectionService(None) # type: ignore[arg-type]
|
|
|
|
active = service.build(
|
|
claim,
|
|
tenant_id="tenant-a",
|
|
entered_at=now - timedelta(hours=1),
|
|
now=now,
|
|
observation_rows=[(other_stage, None), (waived, disposition)],
|
|
)
|
|
disposition.waiver_expires_at = now - timedelta(seconds=1)
|
|
expired = service.build(
|
|
claim,
|
|
tenant_id="tenant-a",
|
|
entered_at=now - timedelta(hours=1),
|
|
now=now,
|
|
observation_rows=[(other_stage, None), (waived, disposition)],
|
|
)
|
|
|
|
assert active.risk_level == "low"
|
|
assert active.open_risk_count == 0
|
|
assert active.batch_eligible is True
|
|
assert expired.risk_level == "high"
|
|
assert expired.open_risk_count == 1
|
|
assert expired.batch_eligible is False
|
|
|
|
|
|
def test_risk_upsert_refreshes_open_task_projection_without_resetting_sla_window() -> None:
|
|
engine = create_engine(
|
|
"sqlite+pysqlite:///:memory:",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(bind=engine)
|
|
db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
|
with db_factory() as db:
|
|
claim = _claim(claim_id="claim-projection-refresh")
|
|
task = ApprovalTask(
|
|
id="task-projection-refresh",
|
|
tenant_id="default",
|
|
claim_id=claim.id,
|
|
expense_case_id=None,
|
|
node_instance_id="node-projection-refresh",
|
|
node_entry_key="node-entry-projection-refresh",
|
|
parent_task_id=None,
|
|
task_kind="root",
|
|
node_key="direct_manager",
|
|
node_label="直属领导审批",
|
|
node_sequence=1,
|
|
sequence_order=0,
|
|
coordination_mode="single",
|
|
owner_kind="role",
|
|
owner_key="manager",
|
|
owner_name="审批经理",
|
|
assignee_kind="role",
|
|
assignee_key="manager",
|
|
assignee_name="审批经理",
|
|
status="pending",
|
|
version=1,
|
|
claim_status_snapshot="submitted",
|
|
claim_stage_snapshot="直属领导审批",
|
|
entered_at=now - timedelta(hours=1),
|
|
entered_at_source="workflow_event",
|
|
activated_at=now - timedelta(hours=1),
|
|
sla_hours_snapshot=24,
|
|
due_at=now + timedelta(hours=23),
|
|
escalation_level=1,
|
|
next_escalation_at=now + timedelta(hours=2),
|
|
priority_score=10,
|
|
priority_tier="normal",
|
|
priority_reasons_json=[
|
|
{
|
|
"code": "sla_escalated_l1",
|
|
"label": "审批超时已升级至 L1",
|
|
"weight": 10,
|
|
"tone": "danger",
|
|
}
|
|
],
|
|
risk_level="low",
|
|
open_risk_count=0,
|
|
evidence_completeness=Decimal("1.0000"),
|
|
batch_eligible=True,
|
|
batch_block_reasons_json=[],
|
|
projection_updated_at=now,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db.add_all([claim, task])
|
|
db.commit()
|
|
|
|
observation = RiskObservationService(db).upsert_observation(
|
|
{
|
|
"observation_key": "risk:projection:refresh",
|
|
"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": "policy_violation",
|
|
"risk_signal": "policy_violation",
|
|
"title": "政策风险",
|
|
"description": "等待人工处置。",
|
|
"risk_score": 90,
|
|
"risk_level": "high",
|
|
"control_stage": "reimbursement",
|
|
"status": "pending_review",
|
|
},
|
|
tenant_id="default",
|
|
)
|
|
|
|
db.refresh(task)
|
|
assert task.risk_level == "high"
|
|
assert task.open_risk_count == 1
|
|
assert task.batch_eligible is False
|
|
next_escalation_at = task.next_escalation_at
|
|
assert next_escalation_at is not None
|
|
if next_escalation_at.tzinfo is None:
|
|
next_escalation_at = next_escalation_at.replace(tzinfo=UTC)
|
|
assert next_escalation_at == now + timedelta(hours=2)
|
|
assert task.escalation_level == 1
|
|
assert any(
|
|
reason["code"] == "sla_escalated_l1"
|
|
for reason in task.priority_reasons_json
|
|
)
|
|
|
|
db.add(
|
|
RiskDisposition(
|
|
tenant_id="default",
|
|
observation_id=observation.id,
|
|
adjudication="confirmed",
|
|
lifecycle_status="waived",
|
|
waiver_requester_id="requester-projection-refresh",
|
|
waiver_requester_name="风险申请人",
|
|
waiver_requested_at=now - timedelta(hours=1),
|
|
waiver_reason="业务连续性需要",
|
|
waiver_scope="本次报销单",
|
|
waiver_decision="approved",
|
|
waiver_expires_at=now + timedelta(days=1),
|
|
waiver_conditions_json=["补充主管确认"],
|
|
waiver_decider_id="finance-projection-refresh",
|
|
waiver_decider_name="财务复核人",
|
|
waiver_decided_at=now,
|
|
waiver_decision_reason="风险受控且有补偿措施",
|
|
version=1,
|
|
)
|
|
)
|
|
db.flush()
|
|
ApprovalTaskProjectionRefreshService(db).refresh_claim(
|
|
tenant_id="default",
|
|
claim_id=claim.id,
|
|
now=now,
|
|
)
|
|
|
|
assert task.risk_level == "low"
|
|
assert task.open_risk_count == 0
|
|
assert task.batch_eligible is True
|
|
next_escalation_at = task.next_escalation_at
|
|
assert next_escalation_at is not None
|
|
if next_escalation_at.tzinfo is None:
|
|
next_escalation_at = next_escalation_at.replace(tzinfo=UTC)
|
|
assert next_escalation_at == now + timedelta(hours=2)
|