601 lines
20 KiB
Python
601 lines
20 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from collections.abc import Generator
|
|||
|
|
from datetime import UTC, date, datetime, timedelta
|
|||
|
|
from decimal import Decimal
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from auth_helpers import install_legacy_header_auth_override
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
from sqlalchemy import create_engine, func, select
|
|||
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|||
|
|
from sqlalchemy.pool import StaticPool
|
|||
|
|
|
|||
|
|
from app.api.deps import CurrentUserContext, get_db
|
|||
|
|
from app.db.base import Base
|
|||
|
|
from app.main import create_app
|
|||
|
|
from app.models.approval_action import ApprovalActionLedger
|
|||
|
|
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
|
|||
|
|
from app.models.employee import Employee
|
|||
|
|
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
|
|||
|
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
|||
|
|
from app.models.role import Role
|
|||
|
|
from app.schemas.approval_task import (
|
|||
|
|
ApprovalTaskBatchApproveCreate,
|
|||
|
|
ApprovalTaskBatchApproveItem,
|
|||
|
|
)
|
|||
|
|
from app.services.approval_task_batch import ApprovalTaskBatchService
|
|||
|
|
from app.services.approval_task_query import ApprovalTaskQueryService
|
|||
|
|
from app.services.expense_claim_workflow_constants import (
|
|||
|
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
|||
|
|
FINANCE_APPROVAL_STAGE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _session_factory() -> sessionmaker[Session]:
|
|||
|
|
engine = create_engine(
|
|||
|
|
"sqlite+pysqlite:///:memory:",
|
|||
|
|
connect_args={"check_same_thread": False},
|
|||
|
|
poolclass=StaticPool,
|
|||
|
|
)
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
return sessionmaker(
|
|||
|
|
bind=engine,
|
|||
|
|
autoflush=False,
|
|||
|
|
autocommit=False,
|
|||
|
|
expire_on_commit=False,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _user(
|
|||
|
|
*,
|
|||
|
|
tenant_id: str = "tenant-a",
|
|||
|
|
employee_id: str = "approver-1",
|
|||
|
|
username: str = "approver@example.com",
|
|||
|
|
role_codes: list[str] | None = None,
|
|||
|
|
is_admin: bool = False,
|
|||
|
|
) -> CurrentUserContext:
|
|||
|
|
return CurrentUserContext(
|
|||
|
|
username=username,
|
|||
|
|
name="审批人",
|
|||
|
|
role_codes=list(role_codes or ["manager"]),
|
|||
|
|
is_admin=is_admin,
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
employee_id=employee_id,
|
|||
|
|
employee_no=employee_id,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _task_status_fields(
|
|||
|
|
status: str,
|
|||
|
|
*,
|
|||
|
|
now: datetime,
|
|||
|
|
) -> dict[str, object]:
|
|||
|
|
if status == "completed":
|
|||
|
|
return {
|
|||
|
|
"decision": "approved",
|
|||
|
|
"completed_at": now,
|
|||
|
|
"cancelled_at": None,
|
|||
|
|
}
|
|||
|
|
if status == "returned":
|
|||
|
|
return {
|
|||
|
|
"decision": "returned",
|
|||
|
|
"completed_at": now,
|
|||
|
|
"cancelled_at": None,
|
|||
|
|
}
|
|||
|
|
if status in {"cancelled", "superseded"}:
|
|||
|
|
return {
|
|||
|
|
"decision": None,
|
|||
|
|
"completed_at": None,
|
|||
|
|
"cancelled_at": now,
|
|||
|
|
}
|
|||
|
|
return {"decision": None, "completed_at": None, "cancelled_at": None}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_queue_task(
|
|||
|
|
*,
|
|||
|
|
tenant_id: str,
|
|||
|
|
case_id: str,
|
|||
|
|
index: int,
|
|||
|
|
now: datetime,
|
|||
|
|
assignee_id: str,
|
|||
|
|
status: str = "pending",
|
|||
|
|
risk_level: str | None = None,
|
|||
|
|
node_key: str | None = None,
|
|||
|
|
due_at: datetime | None = None,
|
|||
|
|
reason: str | None = None,
|
|||
|
|
) -> tuple[ExpenseClaim, ExpenseCaseLink, ApprovalTask]:
|
|||
|
|
claim_id = f"queue-claim-{tenant_id[-1]}-{index:03d}"
|
|||
|
|
task_id = f"queue-task-{tenant_id[-1]}-{index:03d}"
|
|||
|
|
selected_node = node_key or ("direct_manager" if index % 2 == 0 else "finance")
|
|||
|
|
stage = (
|
|||
|
|
DIRECT_MANAGER_APPROVAL_STAGE
|
|||
|
|
if selected_node == "direct_manager"
|
|||
|
|
else FINANCE_APPROVAL_STAGE
|
|||
|
|
)
|
|||
|
|
# 任务进入节点后才会产生 SLA 截止时间;即使构造“已逾期”样本,
|
|||
|
|
# due_at 也必须晚于 entered_at,保持测试数据满足真实表约束。
|
|||
|
|
entered_at = now - timedelta(hours=12 + index % 5, minutes=index % 7)
|
|||
|
|
selected_due_at = due_at or (
|
|||
|
|
now - timedelta(hours=2)
|
|||
|
|
if index % 3 == 0
|
|||
|
|
else now + timedelta(hours=2)
|
|||
|
|
if index % 3 == 1
|
|||
|
|
else now + timedelta(hours=8)
|
|||
|
|
)
|
|||
|
|
selected_risk = risk_level or ("high" if index % 5 == 0 else "low")
|
|||
|
|
claim = ExpenseClaim(
|
|||
|
|
id=claim_id,
|
|||
|
|
claim_no=f"RE-QUEUE-{tenant_id[-1].upper()}-{index:03d}",
|
|||
|
|
employee_id=None,
|
|||
|
|
employee_name=f"申请人{index:03d}",
|
|||
|
|
department_id=None,
|
|||
|
|
department_name="市场部",
|
|||
|
|
project_code="PRJ-QUEUE",
|
|||
|
|
expense_type="misc",
|
|||
|
|
reason=reason or f"队列分页测试费用 {index:03d}",
|
|||
|
|
location="上海",
|
|||
|
|
amount=Decimal("888.00") + Decimal(index),
|
|||
|
|
currency="CNY",
|
|||
|
|
invoice_count=1,
|
|||
|
|
occurred_at=now - timedelta(days=1),
|
|||
|
|
submitted_at=entered_at,
|
|||
|
|
status="submitted",
|
|||
|
|
approval_stage=stage,
|
|||
|
|
risk_flags_json=[],
|
|||
|
|
created_at=entered_at - timedelta(hours=1),
|
|||
|
|
updated_at=entered_at,
|
|||
|
|
)
|
|||
|
|
link = ExpenseCaseLink(
|
|||
|
|
id=f"queue-link-{tenant_id[-1]}-{index:03d}",
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
expense_case_id=case_id,
|
|||
|
|
resource_type="expense_claim",
|
|||
|
|
resource_id=claim_id,
|
|||
|
|
relation_type="claim",
|
|||
|
|
created_at=entered_at,
|
|||
|
|
)
|
|||
|
|
lifecycle = _task_status_fields(status, now=now)
|
|||
|
|
task = ApprovalTask(
|
|||
|
|
id=task_id,
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
claim_id=claim_id,
|
|||
|
|
expense_case_id=case_id,
|
|||
|
|
node_instance_id=f"queue-node-{tenant_id[-1]}-{index:03d}",
|
|||
|
|
node_entry_key=f"queue-entry-{tenant_id[-1]}-{index:03d}",
|
|||
|
|
parent_task_id=None,
|
|||
|
|
task_kind="root",
|
|||
|
|
node_key=selected_node,
|
|||
|
|
node_label=stage,
|
|||
|
|
node_sequence=1,
|
|||
|
|
sequence_order=0,
|
|||
|
|
coordination_mode="single",
|
|||
|
|
owner_kind="employee",
|
|||
|
|
owner_key=assignee_id,
|
|||
|
|
owner_employee_id=assignee_id,
|
|||
|
|
owner_name="审批人",
|
|||
|
|
assignee_kind="employee",
|
|||
|
|
assignee_key=assignee_id,
|
|||
|
|
assignee_employee_id=assignee_id,
|
|||
|
|
assignee_name="审批人张经理",
|
|||
|
|
status=status,
|
|||
|
|
decision=lifecycle["decision"],
|
|||
|
|
version=2 if status != "pending" else 1,
|
|||
|
|
claim_status_snapshot="submitted",
|
|||
|
|
claim_stage_snapshot=stage,
|
|||
|
|
entered_at=entered_at,
|
|||
|
|
entered_at_source="submitted_at",
|
|||
|
|
activated_at=entered_at,
|
|||
|
|
sla_hours_snapshot=24,
|
|||
|
|
due_at=selected_due_at,
|
|||
|
|
completed_at=lifecycle["completed_at"],
|
|||
|
|
cancelled_at=lifecycle["cancelled_at"],
|
|||
|
|
escalation_level=0,
|
|||
|
|
next_escalation_at=selected_due_at,
|
|||
|
|
priority_score=(index % 4) * 25,
|
|||
|
|
priority_tier="urgent" if index % 4 == 3 else "high" if index % 4 == 2 else "normal",
|
|||
|
|
priority_reasons_json=[],
|
|||
|
|
risk_level=selected_risk,
|
|||
|
|
open_risk_count=1 if selected_risk != "low" else 0,
|
|||
|
|
evidence_completeness=Decimal("1.0000"),
|
|||
|
|
batch_eligible=(status == "pending" and selected_risk == "low"),
|
|||
|
|
batch_block_reasons_json=[] if selected_risk == "low" else ["open_risk"],
|
|||
|
|
projection_updated_at=now,
|
|||
|
|
created_at=entered_at,
|
|||
|
|
updated_at=now,
|
|||
|
|
)
|
|||
|
|
return claim, link, task
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _seed_queue(
|
|||
|
|
db: Session,
|
|||
|
|
*,
|
|||
|
|
tenant_id: str = "tenant-a",
|
|||
|
|
pending_count: int = 27,
|
|||
|
|
assignee_id: str = "approver-1",
|
|||
|
|
include_processed: bool = False,
|
|||
|
|
) -> tuple[datetime, list[ApprovalTask]]:
|
|||
|
|
now = datetime.now(UTC).replace(microsecond=0)
|
|||
|
|
case_id = f"queue-case-{tenant_id[-1]}"
|
|||
|
|
expense_case = ExpenseCase(
|
|||
|
|
id=case_id,
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
case_no=f"CASE-QUEUE-{tenant_id[-1].upper()}",
|
|||
|
|
scene_code="misc",
|
|||
|
|
title="审批队列测试",
|
|||
|
|
current_stage="reviewing",
|
|||
|
|
status="active",
|
|||
|
|
created_at=now - timedelta(days=2),
|
|||
|
|
updated_at=now,
|
|||
|
|
)
|
|||
|
|
db.add(expense_case)
|
|||
|
|
tasks: list[ApprovalTask] = []
|
|||
|
|
for index in range(pending_count):
|
|||
|
|
claim, link, task = _build_queue_task(
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
case_id=case_id,
|
|||
|
|
index=index,
|
|||
|
|
now=now,
|
|||
|
|
assignee_id=assignee_id,
|
|||
|
|
reason=("火星专项客户拜访" if index == 7 else None),
|
|||
|
|
)
|
|||
|
|
db.add_all([claim, link, task])
|
|||
|
|
tasks.append(task)
|
|||
|
|
if include_processed:
|
|||
|
|
for offset, status in enumerate(
|
|||
|
|
("completed", "returned", "cancelled", "superseded"),
|
|||
|
|
start=pending_count,
|
|||
|
|
):
|
|||
|
|
claim, link, task = _build_queue_task(
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
case_id=case_id,
|
|||
|
|
index=offset,
|
|||
|
|
now=now,
|
|||
|
|
assignee_id=assignee_id,
|
|||
|
|
status=status,
|
|||
|
|
)
|
|||
|
|
db.add_all([claim, link, task])
|
|||
|
|
tasks.append(task)
|
|||
|
|
db.commit()
|
|||
|
|
return now, tasks
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_query_uses_real_database_pagination_and_stable_persisted_sorting() -> None:
|
|||
|
|
factory = _session_factory()
|
|||
|
|
with factory() as db:
|
|||
|
|
_now, tasks = _seed_queue(db)
|
|||
|
|
expected = sorted(
|
|||
|
|
tasks,
|
|||
|
|
key=lambda task: (
|
|||
|
|
-task.priority_score,
|
|||
|
|
task.due_at,
|
|||
|
|
task.entered_at,
|
|||
|
|
task.id,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
page = ApprovalTaskQueryService(db).list_tasks(
|
|||
|
|
_user(),
|
|||
|
|
page=2,
|
|||
|
|
page_size=10,
|
|||
|
|
)
|
|||
|
|
repeated = ApprovalTaskQueryService(db).list_tasks(
|
|||
|
|
_user(),
|
|||
|
|
page=2,
|
|||
|
|
page_size=10,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
expected_ids = [task.id for task in expected[10:20]]
|
|||
|
|
assert page.total == 27
|
|||
|
|
assert page.page == 2
|
|||
|
|
assert page.page_size == 10
|
|||
|
|
assert page.total_pages == 3
|
|||
|
|
assert [item.task.id for item in page.items] == expected_ids
|
|||
|
|
assert [item.task.id for item in repeated.items] == expected_ids
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_query_supports_status_risk_sla_node_and_keyword_filters() -> None:
|
|||
|
|
factory = _session_factory()
|
|||
|
|
with factory() as db:
|
|||
|
|
_now, tasks = _seed_queue(db, include_processed=True)
|
|||
|
|
tasks[0].escalation_level = 1
|
|||
|
|
db.commit()
|
|||
|
|
service = ApprovalTaskQueryService(db)
|
|||
|
|
user = _user()
|
|||
|
|
|
|||
|
|
assert service.list_tasks(user, status="processed").total == 4
|
|||
|
|
assert service.list_tasks(user, status="completed").total == 1
|
|||
|
|
assert service.list_tasks(user, risk_level="high").total == 6
|
|||
|
|
assert service.list_tasks(user, sla_state="overdue").total == 9
|
|||
|
|
assert service.list_tasks(user, sla_state="due_soon").total == 9
|
|||
|
|
assert service.list_tasks(user, sla_state="on_track").total == 9
|
|||
|
|
assert service.list_tasks(user, sla_state="escalated").total == 1
|
|||
|
|
assert service.list_tasks(user, node_key="finance").total == 13
|
|||
|
|
keyword = service.list_tasks(user, keyword="火星专项")
|
|||
|
|
assert keyword.total == 1
|
|||
|
|
assert keyword.items[0].claim.reason == "火星专项客户拜访"
|
|||
|
|
with pytest.raises(ValueError, match="SLA"):
|
|||
|
|
service.list_tasks(user, sla_state="unknown")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_query_enforces_tenant_scope_and_admin_visibility_without_action_authority() -> None:
|
|||
|
|
factory = _session_factory()
|
|||
|
|
with factory() as db:
|
|||
|
|
_seed_queue(db, tenant_id="tenant-a", pending_count=3)
|
|||
|
|
_seed_queue(
|
|||
|
|
db,
|
|||
|
|
tenant_id="tenant-b",
|
|||
|
|
pending_count=2,
|
|||
|
|
assignee_id="other-approver",
|
|||
|
|
)
|
|||
|
|
service = ApprovalTaskQueryService(db)
|
|||
|
|
|
|||
|
|
personal = service.list_tasks(_user(tenant_id="tenant-a"))
|
|||
|
|
admin_a = service.list_tasks(
|
|||
|
|
_user(
|
|||
|
|
tenant_id="tenant-a",
|
|||
|
|
employee_id="admin-a",
|
|||
|
|
username="admin-a@example.com",
|
|||
|
|
role_codes=["admin"],
|
|||
|
|
is_admin=True,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
admin_b = service.list_tasks(
|
|||
|
|
_user(
|
|||
|
|
tenant_id="tenant-b",
|
|||
|
|
employee_id="admin-b",
|
|||
|
|
username="admin-b@example.com",
|
|||
|
|
role_codes=["admin"],
|
|||
|
|
is_admin=True,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert personal.total == 3
|
|||
|
|
assert all(item.task.can_act for item in personal.items)
|
|||
|
|
assert admin_a.total == 3
|
|||
|
|
assert all(item.task.can_act is False for item in admin_a.items)
|
|||
|
|
assert all("approve" not in item.task.available_actions for item in admin_a.items)
|
|||
|
|
assert admin_b.total == 2
|
|||
|
|
assert {item.task.tenant_id for item in admin_b.items} == {"tenant-b"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _seed_batch(
|
|||
|
|
db: Session,
|
|||
|
|
) -> tuple[CurrentUserContext, ApprovalTask, ApprovalTask]:
|
|||
|
|
now = datetime.now(UTC).replace(microsecond=0)
|
|||
|
|
finance_role = Role(
|
|||
|
|
id="batch-role-finance",
|
|||
|
|
role_code="finance",
|
|||
|
|
name="批处理财务",
|
|||
|
|
)
|
|||
|
|
finance = Employee(
|
|||
|
|
id="batch-finance",
|
|||
|
|
employee_no="BATCH-FINANCE",
|
|||
|
|
name="财务审批人",
|
|||
|
|
email="batch-finance@example.com",
|
|||
|
|
roles=[finance_role],
|
|||
|
|
)
|
|||
|
|
owner = Employee(
|
|||
|
|
id="batch-owner",
|
|||
|
|
employee_no="BATCH-OWNER",
|
|||
|
|
name="批处理申请人",
|
|||
|
|
email="batch-owner@example.com",
|
|||
|
|
)
|
|||
|
|
expense_case = ExpenseCase(
|
|||
|
|
id="batch-case",
|
|||
|
|
tenant_id="tenant-a",
|
|||
|
|
case_no="CASE-BATCH-APPROVAL",
|
|||
|
|
scene_code="misc",
|
|||
|
|
title="批量审批事务测试",
|
|||
|
|
owner_employee_id=owner.id,
|
|||
|
|
current_stage="reviewing",
|
|||
|
|
status="active",
|
|||
|
|
created_at=now - timedelta(days=1),
|
|||
|
|
updated_at=now,
|
|||
|
|
)
|
|||
|
|
db.add_all([finance_role, finance, owner, expense_case])
|
|||
|
|
tasks: list[ApprovalTask] = []
|
|||
|
|
for index, eligible in enumerate((True, False), start=1):
|
|||
|
|
claim_id = f"batch-claim-{index}"
|
|||
|
|
claim = ExpenseClaim(
|
|||
|
|
id=claim_id,
|
|||
|
|
claim_no=f"RE-BATCH-{index:03d}",
|
|||
|
|
employee=owner,
|
|||
|
|
employee_name=owner.name,
|
|||
|
|
department_name="市场部",
|
|||
|
|
project_code="PRJ-BATCH",
|
|||
|
|
expense_type="misc",
|
|||
|
|
reason=f"批量审批测试 {index}",
|
|||
|
|
location="上海",
|
|||
|
|
amount=Decimal("88.00"),
|
|||
|
|
currency="CNY",
|
|||
|
|
invoice_count=1,
|
|||
|
|
occurred_at=now - timedelta(days=1),
|
|||
|
|
submitted_at=now - timedelta(hours=1),
|
|||
|
|
status="submitted",
|
|||
|
|
approval_stage=FINANCE_APPROVAL_STAGE,
|
|||
|
|
risk_flags_json=[],
|
|||
|
|
created_at=now - timedelta(days=1),
|
|||
|
|
updated_at=now,
|
|||
|
|
)
|
|||
|
|
claim.items = [
|
|||
|
|
ExpenseClaimItem(
|
|||
|
|
id=f"batch-item-{index}",
|
|||
|
|
claim_id=claim_id,
|
|||
|
|
item_date=date.today(),
|
|||
|
|
item_type="misc",
|
|||
|
|
item_reason="测试",
|
|||
|
|
item_location="上海",
|
|||
|
|
item_note="",
|
|||
|
|
item_amount=Decimal("88.00"),
|
|||
|
|
invoice_id=f"BATCH-INVOICE-{index}",
|
|||
|
|
created_at=now,
|
|||
|
|
updated_at=now,
|
|||
|
|
)
|
|||
|
|
]
|
|||
|
|
link = ExpenseCaseLink(
|
|||
|
|
id=f"batch-link-{index}",
|
|||
|
|
tenant_id="tenant-a",
|
|||
|
|
expense_case_id=expense_case.id,
|
|||
|
|
resource_type="expense_claim",
|
|||
|
|
resource_id=claim_id,
|
|||
|
|
relation_type="claim",
|
|||
|
|
created_at=now,
|
|||
|
|
)
|
|||
|
|
task = ApprovalTask(
|
|||
|
|
id=f"batch-task-{index}",
|
|||
|
|
tenant_id="tenant-a",
|
|||
|
|
claim_id=claim_id,
|
|||
|
|
expense_case_id=expense_case.id,
|
|||
|
|
node_instance_id=f"batch-node-{index}",
|
|||
|
|
node_entry_key=f"batch-entry-{index}",
|
|||
|
|
parent_task_id=None,
|
|||
|
|
task_kind="root",
|
|||
|
|
node_key="finance",
|
|||
|
|
node_label=FINANCE_APPROVAL_STAGE,
|
|||
|
|
node_sequence=1,
|
|||
|
|
sequence_order=0,
|
|||
|
|
coordination_mode="single",
|
|||
|
|
owner_kind="employee",
|
|||
|
|
owner_key=finance.id,
|
|||
|
|
owner_employee_id=finance.id,
|
|||
|
|
owner_name=finance.name,
|
|||
|
|
assignee_kind="employee",
|
|||
|
|
assignee_key=finance.id,
|
|||
|
|
assignee_employee_id=finance.id,
|
|||
|
|
assignee_name=finance.name,
|
|||
|
|
status="pending",
|
|||
|
|
decision=None,
|
|||
|
|
version=1,
|
|||
|
|
claim_status_snapshot="submitted",
|
|||
|
|
claim_stage_snapshot=FINANCE_APPROVAL_STAGE,
|
|||
|
|
entered_at=now - timedelta(hours=1),
|
|||
|
|
entered_at_source="submitted_at",
|
|||
|
|
activated_at=now - timedelta(hours=1),
|
|||
|
|
sla_hours_snapshot=8,
|
|||
|
|
due_at=now + timedelta(hours=7),
|
|||
|
|
escalation_level=0,
|
|||
|
|
next_escalation_at=now + timedelta(hours=7),
|
|||
|
|
priority_score=0,
|
|||
|
|
priority_tier="normal",
|
|||
|
|
priority_reasons_json=[],
|
|||
|
|
risk_level="low" if eligible else "high",
|
|||
|
|
open_risk_count=0 if eligible else 1,
|
|||
|
|
evidence_completeness=Decimal("1.0000"),
|
|||
|
|
batch_eligible=eligible,
|
|||
|
|
batch_block_reasons_json=[] if eligible else ["open_risk"],
|
|||
|
|
projection_updated_at=now,
|
|||
|
|
created_at=now,
|
|||
|
|
updated_at=now,
|
|||
|
|
)
|
|||
|
|
db.add_all([claim, link, task])
|
|||
|
|
tasks.append(task)
|
|||
|
|
db.commit()
|
|||
|
|
return (
|
|||
|
|
_user(
|
|||
|
|
tenant_id="tenant-a",
|
|||
|
|
employee_id=finance.id,
|
|||
|
|
username=finance.email,
|
|||
|
|
role_codes=["finance"],
|
|||
|
|
),
|
|||
|
|
tasks[0],
|
|||
|
|
tasks[1],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_batch_items_use_independent_transactions_and_retry_replays_success() -> None:
|
|||
|
|
factory = _session_factory()
|
|||
|
|
with factory() as db:
|
|||
|
|
user, successful_task, blocked_task = _seed_batch(db)
|
|||
|
|
payload = ApprovalTaskBatchApproveCreate(
|
|||
|
|
batch_request_id="batch-request-001",
|
|||
|
|
items=[
|
|||
|
|
ApprovalTaskBatchApproveItem(
|
|||
|
|
task_id=successful_task.id,
|
|||
|
|
expected_task_version=1,
|
|||
|
|
expected_status="submitted",
|
|||
|
|
expected_approval_stage=FINANCE_APPROVAL_STAGE,
|
|||
|
|
opinion="批量审批同意",
|
|||
|
|
),
|
|||
|
|
ApprovalTaskBatchApproveItem(
|
|||
|
|
task_id=blocked_task.id,
|
|||
|
|
expected_task_version=1,
|
|||
|
|
expected_status="submitted",
|
|||
|
|
expected_approval_stage=FINANCE_APPROVAL_STAGE,
|
|||
|
|
opinion="批量审批同意",
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
first = ApprovalTaskBatchService(factory).approve(payload, user)
|
|||
|
|
repeated = ApprovalTaskBatchService(factory).approve(payload, user)
|
|||
|
|
|
|||
|
|
assert first.status == "partial"
|
|||
|
|
assert first.succeeded_count == 1
|
|||
|
|
assert first.blocked_count == 1
|
|||
|
|
assert [item.status for item in first.items] == ["succeeded", "blocked"]
|
|||
|
|
assert repeated.status == "partial"
|
|||
|
|
assert repeated.replayed_count == 1
|
|||
|
|
assert repeated.blocked_count == 1
|
|||
|
|
assert [item.status for item in repeated.items] == ["replayed", "blocked"]
|
|||
|
|
with factory() as db:
|
|||
|
|
successful_claim = db.get(ExpenseClaim, successful_task.claim_id)
|
|||
|
|
blocked_claim = db.get(ExpenseClaim, blocked_task.claim_id)
|
|||
|
|
persisted_success = db.get(ApprovalTask, successful_task.id)
|
|||
|
|
persisted_blocked = db.get(ApprovalTask, blocked_task.id)
|
|||
|
|
assert successful_claim.status == "pending_payment"
|
|||
|
|
assert successful_claim.approval_stage == "待付款"
|
|||
|
|
assert persisted_success.status == "completed"
|
|||
|
|
assert persisted_success.decision == "approved"
|
|||
|
|
assert blocked_claim.status == "submitted"
|
|||
|
|
assert persisted_blocked.status == "pending"
|
|||
|
|
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
|
|||
|
|
assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_approval_task_list_http_contract_preserves_pagination_metadata() -> None:
|
|||
|
|
factory = _session_factory()
|
|||
|
|
with factory() as db:
|
|||
|
|
_now, tasks = _seed_queue(db)
|
|||
|
|
expected = sorted(
|
|||
|
|
tasks,
|
|||
|
|
key=lambda task: (
|
|||
|
|
-task.priority_score,
|
|||
|
|
task.due_at,
|
|||
|
|
task.entered_at,
|
|||
|
|
task.id,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
app = create_app()
|
|||
|
|
install_legacy_header_auth_override(app)
|
|||
|
|
|
|||
|
|
def override_db() -> Generator[Session, None, None]:
|
|||
|
|
with factory() as db:
|
|||
|
|
yield db
|
|||
|
|
|
|||
|
|
app.dependency_overrides[get_db] = override_db
|
|||
|
|
client = TestClient(app)
|
|||
|
|
try:
|
|||
|
|
response = client.get(
|
|||
|
|
"/api/v1/approval-tasks",
|
|||
|
|
params={"page": 3, "page_size": 10, "status": "pending"},
|
|||
|
|
headers={
|
|||
|
|
"X-Auth-Username": "approver@example.com",
|
|||
|
|
"X-Auth-Employee-Id": "approver-1",
|
|||
|
|
"X-Auth-Role-Codes": "manager",
|
|||
|
|
"X-Auth-Tenant-Id": "tenant-a",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
finally:
|
|||
|
|
client.close()
|
|||
|
|
app.dependency_overrides.clear()
|
|||
|
|
|
|||
|
|
body = response.json()
|
|||
|
|
assert response.status_code == 200
|
|||
|
|
assert body["total"] == 27
|
|||
|
|
assert body["page"] == 3
|
|||
|
|
assert body["page_size"] == 10
|
|||
|
|
assert body["total_pages"] == 3
|
|||
|
|
assert len(body["items"]) == 7
|
|||
|
|
assert body["items"][0]["task"]["id"] == expected[20].id
|