feat(approval): add task workflow and waiver decisions

This commit is contained in:
caoxiaozhu
2026-07-16 16:52:12 +08:00
parent 28b834edd3
commit 242d68c36f
89 changed files with 16313 additions and 294 deletions

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