feat(approval): add task workflow and waiver decisions
This commit is contained in:
485
server/tests/test_approval_task_projection.py
Normal file
485
server/tests/test_approval_task_projection.py
Normal file
@@ -0,0 +1,485 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user